Commit b628b72c authored by ThinhNC's avatar ThinhNC

Merge branch 'feat/roles-permission-matrix-and-user-mgmt' into 'develop'

feat(roles,users): implement Roles & Permission Matrix module, dynamic RBAC, and developer tabs

See merge request !12
parents ff1b6c4d e8a24824
import { redirect } from "next/navigation";
export default function AdminUsersRedirectPage() {
redirect("/settings/developer/users");
}
import type { Metadata } from "next";
import { RolesManagementView } from "@/components/roles/roles-management-view";
export const metadata: Metadata = {
title: "Vai Trò & Phân Quyền",
description: "Quản lý vai trò người dùng, danh mục quyền hạn và ma trận phân quyền hệ thống.",
};
export default function RolesPage() {
return (
<div className="space-y-6 animate-in fade-in-50 duration-200">
<RolesManagementView showHeader={true} />
</div>
);
}
"use client";
import React, { useState } from "react";
import { KeyRound, Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useLanguage } from "@/providers/language-provider";
import {
useApiKeysList,
useToggleApiKey,
useRevokeApiKey,
} from "@/hooks/use-developer";
import { ApiKey } from "@/types/developer";
import { CreateApiKeyModal } from "@/components/developer/create-api-key-modal";
export default function ApiKeysPage() {
const { t, locale } = useLanguage();
const { data: apiKeys = [], isLoading: isLoadingKeys } = useApiKeysList();
const toggleApiKeyMutation = useToggleApiKey();
const revokeApiKeyMutation = useRevokeApiKey();
const [isKeyModalOpen, setIsKeyModalOpen] = useState(false);
const handleToggleKey = (key: ApiKey) => {
toggleApiKeyMutation.mutate({ id: key.id, isActive: !key.isActive });
};
const handleRevokeKey = (key: ApiKey) => {
if (window.confirm(`${t.developer.apiKeys.table.revokeConfirm}\n("${key.name}")`)) {
revokeApiKeyMutation.mutate(key.id);
}
};
return (
<div className="space-y-6 animate-in fade-in-50 duration-200">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h3 className="text-base font-bold text-foreground">
{t.developer.apiKeys.title}
</h3>
<p className="text-xs text-muted-foreground">
{t.developer.apiKeys.desc}
</p>
</div>
<Button
onClick={() => setIsKeyModalOpen(true)}
className="rounded-2xl h-10 px-4 bg-emerald-600 hover:bg-emerald-700 text-white shadow-sm shadow-emerald-600/20 cursor-pointer"
>
<Plus className="h-4 w-4 mr-1.5" />
{t.developer.apiKeys.createBtn}
</Button>
</div>
{/* Keys Table */}
{isLoadingKeys ? (
<div className="h-32 rounded-3xl bg-muted/40 animate-pulse" />
) : apiKeys.length === 0 ? (
<div className="p-10 text-center rounded-3xl border border-dashed border-border/80 bg-card/40 space-y-3">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
<KeyRound className="h-6 w-6" />
</div>
<h4 className="text-sm font-bold text-foreground">
{t.developer.apiKeys.emptyTitle}
</h4>
<p className="text-xs text-muted-foreground max-w-sm mx-auto">
{t.developer.apiKeys.emptyDesc}
</p>
<Button
size="sm"
onClick={() => setIsKeyModalOpen(true)}
className="rounded-2xl text-xs bg-emerald-600 hover:bg-emerald-700 text-white cursor-pointer"
>
<Plus className="h-3.5 w-3.5 mr-1" />
{t.developer.apiKeys.createBtn}
</Button>
</div>
) : (
<div className="overflow-x-auto rounded-3xl border border-emerald-500/15 bg-card/80 shadow-sm">
<table className="w-full text-left text-xs">
<thead className="border-b border-border/60 bg-muted/40 text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">
<tr>
<th className="px-5 py-3.5">{t.developer.apiKeys.table.name}</th>
<th className="px-4 py-3.5">{t.developer.apiKeys.table.prefix}</th>
<th className="px-4 py-3.5">{t.developer.apiKeys.table.status}</th>
<th className="px-4 py-3.5">{t.developer.apiKeys.table.lastUsed}</th>
<th className="px-4 py-3.5">{t.developer.apiKeys.table.expires}</th>
<th className="px-5 py-3.5 text-right">{t.developer.apiKeys.table.actions}</th>
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{apiKeys.map((key) => (
<tr key={key.id} className="hover:bg-muted/30 transition-colors">
<td className="px-5 py-4 font-bold text-foreground">
{key.name}
</td>
<td className="px-4 py-4 font-mono text-muted-foreground">
{key.keyPrefix}...****
</td>
<td className="px-4 py-4">
<button
type="button"
onClick={() => handleToggleKey(key)}
className={`rounded-full px-2.5 py-0.5 text-[10px] font-semibold border transition-colors cursor-pointer ${
key.isActive
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
: "bg-muted text-muted-foreground border-border"
}`}
>
{key.isActive
? t.developer.apiKeys.table.active
: t.developer.apiKeys.table.inactive}
</button>
</td>
<td className="px-4 py-4 text-muted-foreground">
{key.lastUsedAt
? new Date(key.lastUsedAt).toLocaleDateString(
locale === "vi" ? "vi-VN" : "en-US",
{ month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }
)
: t.developer.apiKeys.table.never}
</td>
<td className="px-4 py-4 text-muted-foreground">
{key.expiresAt
? new Date(key.expiresAt).toLocaleDateString(
locale === "vi" ? "vi-VN" : "en-US",
{ month: "short", day: "numeric", year: "numeric" }
)
: t.developer.apiKeys.table.never}
</td>
<td className="px-5 py-4 text-right">
<Button
size="sm"
variant="ghost"
onClick={() => handleRevokeKey(key)}
className="rounded-xl h-8 px-2.5 text-xs text-red-500 hover:bg-red-500/10 cursor-pointer"
>
<Trash2 className="h-3.5 w-3.5 mr-1" />
{t.developer.apiKeys.table.revoke}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Modal */}
<CreateApiKeyModal
isOpen={isKeyModalOpen}
onClose={() => setIsKeyModalOpen(false)}
/>
</div>
);
}
"use client";
import React, { useState } from "react";
import { Terminal, Code2, Check, Copy } from "lucide-react";
import { useLanguage } from "@/providers/language-provider";
export default function DeveloperDocsPage() {
const { t } = useLanguage();
const [copiedSnippet, setCopiedSnippet] = useState<string | null>(null);
const handleCopySnippet = (snippet: string, key: string) => {
navigator.clipboard.writeText(snippet);
setCopiedSnippet(key);
setTimeout(() => setCopiedSnippet(null), 2000);
};
const curlSnippet = `curl -X POST "https://api.datacrawler.io/api/v1/crawl-jobs" \\
-H "X-API-Key: dc_live_your_secret_key_here" \\
-H "Content-Type: application/json" \\
-d '{
"startUrl": "https://vnexpress.net/thoi-su",
"mode": "CRAWL",
"maxPages": 20
}'`;
const nodeSnippet = `import axios from 'axios';
const client = axios.create({
baseURL: 'https://api.datacrawler.io/api/v1',
headers: {
'X-API-Key': 'dc_live_your_secret_key_here',
'Content-Type': 'application/json'
}
});
const response = await client.post('/crawl-jobs', {
startUrl: 'https://vnexpress.net/thoi-su',
mode: 'CRAWL',
maxPages: 20
});
console.log('Job Created:', response.data);`;
return (
<div className="space-y-6 animate-in fade-in-50 duration-200">
<div>
<h3 className="text-base font-bold text-foreground">
{t.developer.snippets.title}
</h3>
<p className="text-xs text-muted-foreground">
{t.developer.snippets.desc}
</p>
</div>
{/* cURL Snippet */}
<div className="rounded-3xl border border-emerald-500/20 bg-card p-5 space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-foreground flex items-center gap-1.5">
<Terminal className="h-4 w-4 text-emerald-500" />
cURL
</span>
<button
type="button"
onClick={() => handleCopySnippet(curlSnippet, "curl")}
className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground cursor-pointer"
>
{copiedSnippet === "curl" ? (
<Check className="h-3.5 w-3.5 text-emerald-500" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
<span>{copiedSnippet === "curl" ? "Đã sao chép" : "Sao chép"}</span>
</button>
</div>
<pre className="p-4 rounded-2xl bg-muted/60 font-mono text-xs text-foreground overflow-x-auto">
{curlSnippet}
</pre>
</div>
{/* Node.js Snippet */}
<div className="rounded-3xl border border-emerald-500/20 bg-card p-5 space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-foreground flex items-center gap-1.5">
<Code2 className="h-4 w-4 text-emerald-500" />
Node.js / Axios
</span>
<button
type="button"
onClick={() => handleCopySnippet(nodeSnippet, "node")}
className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground cursor-pointer"
>
{copiedSnippet === "node" ? (
<Check className="h-3.5 w-3.5 text-emerald-500" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
<span>{copiedSnippet === "node" ? "Đã sao chép" : "Sao chép"}</span>
</button>
</div>
<pre className="p-4 rounded-2xl bg-muted/60 font-mono text-xs text-foreground overflow-x-auto">
{nodeSnippet}
</pre>
</div>
</div>
);
}
import type { Metadata } from "next";
import { DeveloperShell } from "@/components/developer/developer-shell";
export const metadata: Metadata = {
title: "Cài Đặt Nhà Phát Triển & Tích Hợp",
......@@ -11,5 +12,6 @@ export default function DeveloperSettingsLayout({
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
return <DeveloperShell>{children}</DeveloperShell>;
}
"use client";
import { redirect } from "next/navigation";
import React, { useState } from "react";
import {
Code2,
KeyRound,
Webhook,
Plus,
Copy,
Check,
Play,
Trash2,
Edit2,
RefreshCw,
Terminal,
ShieldAlert,
Send,
ExternalLink,
CheckCircle2,
AlertTriangle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useLanguage } from "@/providers/language-provider";
import {
useApiKeysList,
useToggleApiKey,
useRevokeApiKey,
useWebhookConfigsList,
useDeleteWebhookConfig,
useTestWebhookConfig,
useWebhookDeliveriesList,
useRedeliverWebhook,
} from "@/hooks/use-developer";
import { ApiKey, WebhookConfig } from "@/types/developer";
import { CreateApiKeyModal } from "@/components/developer/create-api-key-modal";
import { CreateWebhookModal } from "@/components/developer/create-webhook-modal";
export default function DeveloperSettingsPage() {
const { t, locale } = useLanguage();
const [activeTab, setActiveTab] = useState<"api-keys" | "webhooks" | "docs">("api-keys");
// API Keys state & hooks
const { data: apiKeys = [], isLoading: isLoadingKeys, refetch: refetchKeys } = useApiKeysList();
const toggleApiKeyMutation = useToggleApiKey();
const revokeApiKeyMutation = useRevokeApiKey();
const [isKeyModalOpen, setIsKeyModalOpen] = useState(false);
// Webhooks state & hooks
const { data: webhooks = [], isLoading: isLoadingWebhooks, refetch: refetchWebhooks } = useWebhookConfigsList();
const { data: deliveriesData, isLoading: isLoadingDeliveries, refetch: refetchDeliveries } = useWebhookDeliveriesList({ limit: 10 });
const testPingMutation = useTestWebhookConfig();
const deleteWebhookMutation = useDeleteWebhookConfig();
const redeliverMutation = useRedeliverWebhook();
const [isWebhookModalOpen, setIsWebhookModalOpen] = useState(false);
const [editingWebhook, setEditingWebhook] = useState<WebhookConfig | null>(null);
const [copiedSnippet, setCopiedSnippet] = useState<string | null>(null);
const deliveries = deliveriesData?.items || [];
const handleCopySnippet = (snippet: string, key: string) => {
navigator.clipboard.writeText(snippet);
setCopiedSnippet(key);
setTimeout(() => setCopiedSnippet(null), 2000);
};
const handleToggleKey = (key: ApiKey) => {
toggleApiKeyMutation.mutate({ id: key.id, isActive: !key.isActive });
};
const handleRevokeKey = (key: ApiKey) => {
if (window.confirm(`${t.developer.apiKeys.table.revokeConfirm}\n("${key.name}")`)) {
revokeApiKeyMutation.mutate(key.id);
}
};
const handleTestPing = (webhookId: string) => {
testPingMutation.mutate(webhookId);
};
const handleDeleteWebhook = (wh: WebhookConfig) => {
if (window.confirm(`${t.developer.webhooks.table.deleteConfirm}\n("${wh.url}")`)) {
deleteWebhookMutation.mutate(wh.id);
}
};
const curlSnippet = `curl -X POST "https://api.datacrawler.io/api/v1/crawl-jobs" \\
-H "X-API-Key: dc_live_your_secret_key_here" \\
-H "Content-Type: application/json" \\
-d '{
"startUrl": "https://vnexpress.net/thoi-su",
"mode": "CRAWL",
"maxPages": 20
}'`;
interface DeveloperPageProps {
searchParams: Promise<{ tab?: string }>;
}
const nodeSnippet = `import axios from 'axios';
export default async function DeveloperIndexPage({ searchParams }: DeveloperPageProps) {
const { tab } = await searchParams;
const client = axios.create({
baseURL: 'https://api.datacrawler.io/api/v1',
headers: {
'X-API-Key': 'dc_live_your_secret_key_here',
'Content-Type': 'application/json'
if (tab === "users") {
redirect("/settings/developer/users");
}
});
const response = await client.post('/crawl-jobs', {
startUrl: 'https://vnexpress.net/thoi-su',
mode: 'CRAWL',
maxPages: 20
});
console.log('Job Created:', response.data);`;
return (
<div className="min-h-screen bg-background pb-16">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 pt-8 space-y-8">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="space-y-1">
<div className="inline-flex items-center gap-2 rounded-full bg-emerald-500/10 px-3 py-1 text-xs font-semibold text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<Code2 className="h-3.5 w-3.5" />
<span>{t.developer.title}</span>
</div>
<h1 className="text-2xl sm:text-3xl font-extrabold tracking-tight text-foreground">
{t.developer.title}
</h1>
<p className="text-sm text-muted-foreground max-w-2xl">
{t.developer.subtitle}
</p>
</div>
</div>
{/* Tabs Bar */}
<div className="flex border-b border-border/70 space-x-2">
<button
type="button"
onClick={() => setActiveTab("api-keys")}
className={`flex items-center gap-2 px-4 py-3 text-xs font-bold border-b-2 transition-all cursor-pointer ${
activeTab === "api-keys"
? "border-emerald-500 text-emerald-600 dark:text-emerald-400"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<KeyRound className="h-4 w-4" />
<span>{t.developer.tabs.apiKeys}</span>
<span className="rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 text-[10px] px-2 py-0.5 border border-emerald-500/20">
{apiKeys.length}
</span>
</button>
<button
type="button"
onClick={() => setActiveTab("webhooks")}
className={`flex items-center gap-2 px-4 py-3 text-xs font-bold border-b-2 transition-all cursor-pointer ${
activeTab === "webhooks"
? "border-emerald-500 text-emerald-600 dark:text-emerald-400"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<Webhook className="h-4 w-4" />
<span>{t.developer.tabs.webhooks}</span>
<span className="rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 text-[10px] px-2 py-0.5 border border-emerald-500/20">
{webhooks.length}
</span>
</button>
<button
type="button"
onClick={() => setActiveTab("docs")}
className={`flex items-center gap-2 px-4 py-3 text-xs font-bold border-b-2 transition-all cursor-pointer ${
activeTab === "docs"
? "border-emerald-500 text-emerald-600 dark:text-emerald-400"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<Terminal className="h-4 w-4" />
<span>{t.developer.tabs.docs}</span>
</button>
</div>
{/* TAB 1: API KEYS */}
{activeTab === "api-keys" && (
<div className="space-y-6 animate-in fade-in-50 duration-200">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h3 className="text-base font-bold text-foreground">
{t.developer.apiKeys.title}
</h3>
<p className="text-xs text-muted-foreground">
{t.developer.apiKeys.desc}
</p>
</div>
<Button
onClick={() => setIsKeyModalOpen(true)}
className="rounded-2xl h-10 px-4 bg-emerald-600 hover:bg-emerald-700 text-white shadow-sm shadow-emerald-600/20 cursor-pointer"
>
<Plus className="h-4 w-4 mr-1.5" />
{t.developer.apiKeys.createBtn}
</Button>
</div>
{/* Keys Table */}
{isLoadingKeys ? (
<div className="h-32 rounded-3xl bg-muted/40 animate-pulse" />
) : apiKeys.length === 0 ? (
<div className="p-10 text-center rounded-3xl border border-dashed border-border/80 bg-card/40 space-y-3">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
<KeyRound className="h-6 w-6" />
</div>
<h4 className="text-sm font-bold text-foreground">
{t.developer.apiKeys.emptyTitle}
</h4>
<p className="text-xs text-muted-foreground max-w-sm mx-auto">
{t.developer.apiKeys.emptyDesc}
</p>
<Button
size="sm"
onClick={() => setIsKeyModalOpen(true)}
className="rounded-2xl text-xs bg-emerald-600 hover:bg-emerald-700 text-white cursor-pointer"
>
<Plus className="h-3.5 w-3.5 mr-1" />
{t.developer.apiKeys.createBtn}
</Button>
</div>
) : (
<div className="overflow-x-auto rounded-3xl border border-emerald-500/15 bg-card/80 shadow-sm">
<table className="w-full text-left text-xs">
<thead className="border-b border-border/60 bg-muted/40 text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">
<tr>
<th className="px-5 py-3.5">{t.developer.apiKeys.table.name}</th>
<th className="px-4 py-3.5">{t.developer.apiKeys.table.prefix}</th>
<th className="px-4 py-3.5">{t.developer.apiKeys.table.status}</th>
<th className="px-4 py-3.5">{t.developer.apiKeys.table.lastUsed}</th>
<th className="px-4 py-3.5">{t.developer.apiKeys.table.expires}</th>
<th className="px-5 py-3.5 text-right">{t.developer.apiKeys.table.actions}</th>
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{apiKeys.map((key) => (
<tr key={key.id} className="hover:bg-muted/30 transition-colors">
<td className="px-5 py-4 font-bold text-foreground">
{key.name}
</td>
<td className="px-4 py-4 font-mono text-muted-foreground">
{key.keyPrefix}...****
</td>
<td className="px-4 py-4">
<button
type="button"
onClick={() => handleToggleKey(key)}
className={`rounded-full px-2.5 py-0.5 text-[10px] font-semibold border transition-colors cursor-pointer ${
key.isActive
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
: "bg-muted text-muted-foreground border-border"
}`}
>
{key.isActive
? t.developer.apiKeys.table.active
: t.developer.apiKeys.table.inactive}
</button>
</td>
<td className="px-4 py-4 text-muted-foreground">
{key.lastUsedAt
? new Date(key.lastUsedAt).toLocaleDateString(
locale === "vi" ? "vi-VN" : "en-US",
{ month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }
)
: t.developer.apiKeys.table.never}
</td>
<td className="px-4 py-4 text-muted-foreground">
{key.expiresAt
? new Date(key.expiresAt).toLocaleDateString(
locale === "vi" ? "vi-VN" : "en-US",
{ month: "short", day: "numeric", year: "numeric" }
)
: t.developer.apiKeys.table.never}
</td>
<td className="px-5 py-4 text-right">
<Button
size="sm"
variant="ghost"
onClick={() => handleRevokeKey(key)}
className="rounded-xl h-8 px-2.5 text-xs text-red-500 hover:bg-red-500/10 cursor-pointer"
>
<Trash2 className="h-3.5 w-3.5 mr-1" />
{t.developer.apiKeys.table.revoke}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{/* TAB 2: WEBHOOKS */}
{activeTab === "webhooks" && (
<div className="space-y-8 animate-in fade-in-50 duration-200">
{/* Webhook Configs Section */}
<div className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h3 className="text-base font-bold text-foreground">
{t.developer.webhooks.title}
</h3>
<p className="text-xs text-muted-foreground">
{t.developer.webhooks.desc}
</p>
</div>
<Button
onClick={() => {
setEditingWebhook(null);
setIsWebhookModalOpen(true);
}}
className="rounded-2xl h-10 px-4 bg-emerald-600 hover:bg-emerald-700 text-white shadow-sm shadow-emerald-600/20 cursor-pointer"
>
<Plus className="h-4 w-4 mr-1.5" />
{t.developer.webhooks.createBtn}
</Button>
</div>
{isLoadingWebhooks ? (
<div className="h-32 rounded-3xl bg-muted/40 animate-pulse" />
) : webhooks.length === 0 ? (
<div className="p-10 text-center rounded-3xl border border-dashed border-border/80 bg-card/40 space-y-3">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
<Webhook className="h-6 w-6" />
</div>
<h4 className="text-sm font-bold text-foreground">
{t.developer.webhooks.emptyTitle}
</h4>
<p className="text-xs text-muted-foreground max-w-sm mx-auto">
{t.developer.webhooks.emptyDesc}
</p>
<Button
size="sm"
onClick={() => {
setEditingWebhook(null);
setIsWebhookModalOpen(true);
}}
className="rounded-2xl text-xs bg-emerald-600 hover:bg-emerald-700 text-white cursor-pointer"
>
<Plus className="h-3.5 w-3.5 mr-1" />
{t.developer.webhooks.createBtn}
</Button>
</div>
) : (
<div className="space-y-3">
{webhooks.map((wh) => (
<div
key={wh.id}
className="flex flex-col lg:flex-row lg:items-center justify-between p-4 rounded-3xl border border-emerald-500/15 bg-card/80 gap-4"
>
<div className="space-y-1.5 min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="font-mono text-xs font-bold text-foreground truncate">
{wh.url}
</p>
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold border ${
wh.isActive
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
: "bg-muted text-muted-foreground border-border"
}`}
>
{wh.isActive
? t.developer.webhooks.table.active
: t.developer.webhooks.table.inactive}
</span>
</div>
<div className="flex flex-wrap gap-1.5 pt-1">
{wh.events.map((ev) => (
<span
key={ev}
className="rounded-lg bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 px-2 py-0.5 text-[10px] font-mono border border-emerald-500/20"
>
{ev}
</span>
))}
</div>
</div>
<div className="flex items-center gap-2 self-start lg:self-center">
<Button
size="sm"
onClick={() => handleTestPing(wh.id)}
disabled={testPingMutation.isPending}
className="rounded-xl h-8 px-2.5 text-xs bg-emerald-600/15 hover:bg-emerald-600 text-emerald-700 dark:text-emerald-300 hover:text-white border border-emerald-500/25 transition-all cursor-pointer"
>
<Send className="h-3 w-3 mr-1" />
{t.developer.webhooks.table.testPing}
</Button>
<button
type="button"
onClick={() => {
setEditingWebhook(wh);
setIsWebhookModalOpen(true);
}}
className="p-1.5 rounded-xl text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
>
<Edit2 className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => handleDeleteWebhook(wh)}
className="p-1.5 rounded-xl text-muted-foreground hover:bg-red-500/10 hover:text-red-500 transition-colors cursor-pointer"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Deliveries Log Section */}
<div className="space-y-4 pt-4 border-t border-border/60">
<div>
<h3 className="text-base font-bold text-foreground">
{t.developer.webhooks.deliveries.title}
</h3>
<p className="text-xs text-muted-foreground">
{t.developer.webhooks.deliveries.desc}
</p>
</div>
{isLoadingDeliveries ? (
<div className="h-28 rounded-3xl bg-muted/40 animate-pulse" />
) : deliveries.length === 0 ? (
<p className="text-xs text-muted-foreground py-4">
{t.developer.webhooks.deliveries.empty}
</p>
) : (
<div className="overflow-x-auto rounded-3xl border border-emerald-500/15 bg-card/80 shadow-sm">
<table className="w-full text-left text-xs">
<thead className="border-b border-border/60 bg-muted/40 text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">
<tr>
<th className="px-5 py-3.5">{t.developer.webhooks.deliveries.event}</th>
<th className="px-4 py-3.5">{t.developer.webhooks.deliveries.status}</th>
<th className="px-4 py-3.5">{t.developer.webhooks.deliveries.code}</th>
<th className="px-4 py-3.5">{t.developer.webhooks.deliveries.attempt}</th>
<th className="px-4 py-3.5">{t.developer.webhooks.deliveries.time}</th>
<th className="px-5 py-3.5 text-right">Thao tác</th>
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{deliveries.map((del) => (
<tr key={del.id} className="hover:bg-muted/30 transition-colors">
<td className="px-5 py-3.5 font-mono text-xs font-bold text-foreground">
{del.event}
</td>
<td className="px-4 py-3.5">
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold border ${
del.status === "SUCCESS"
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20"
}`}
>
{del.status}
</span>
</td>
<td className="px-4 py-3.5 font-mono text-muted-foreground">
{del.statusCode || "--"}
</td>
<td className="px-4 py-3.5 text-muted-foreground">
{del.attempt}
</td>
<td className="px-4 py-3.5 text-muted-foreground">
{new Date(del.createdAt).toLocaleDateString(
locale === "vi" ? "vi-VN" : "en-US",
{
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
day: "numeric",
month: "short",
}
)}
</td>
<td className="px-5 py-3.5 text-right">
<Button
size="sm"
variant="ghost"
onClick={() => redeliverMutation.mutate(del.id)}
className="rounded-xl h-7 px-2 text-[11px] text-emerald-600 hover:bg-emerald-500/10 cursor-pointer"
>
<RefreshCw className="h-3 w-3 mr-1" />
{t.developer.webhooks.deliveries.redeliver}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)}
{/* TAB 3: CODE SNIPPETS / INTEGRATION */}
{activeTab === "docs" && (
<div className="space-y-6 animate-in fade-in-50 duration-200">
<div>
<h3 className="text-base font-bold text-foreground">
{t.developer.snippets.title}
</h3>
<p className="text-xs text-muted-foreground">
{t.developer.snippets.desc}
</p>
</div>
{/* cURL Snippet */}
<div className="rounded-3xl border border-emerald-500/20 bg-card p-5 space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-foreground flex items-center gap-1.5">
<Terminal className="h-4 w-4 text-emerald-500" />
cURL
</span>
<button
type="button"
onClick={() => handleCopySnippet(curlSnippet, "curl")}
className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground cursor-pointer"
>
{copiedSnippet === "curl" ? (
<Check className="h-3.5 w-3.5 text-emerald-500" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
<span>{copiedSnippet === "curl" ? "Đã sao chép" : "Sao chép"}</span>
</button>
</div>
<pre className="p-4 rounded-2xl bg-muted/60 font-mono text-xs text-foreground overflow-x-auto">
{curlSnippet}
</pre>
</div>
{/* Node.js Snippet */}
<div className="rounded-3xl border border-emerald-500/20 bg-card p-5 space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-foreground flex items-center gap-1.5">
<Code2 className="h-4 w-4 text-emerald-500" />
Node.js / Axios
</span>
<button
type="button"
onClick={() => handleCopySnippet(nodeSnippet, "node")}
className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground cursor-pointer"
>
{copiedSnippet === "node" ? (
<Check className="h-3.5 w-3.5 text-emerald-500" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
<span>{copiedSnippet === "node" ? "Đã sao chép" : "Sao chép"}</span>
</button>
</div>
<pre className="p-4 rounded-2xl bg-muted/60 font-mono text-xs text-foreground overflow-x-auto">
{nodeSnippet}
</pre>
</div>
</div>
)}
</div>
if (tab === "webhooks") {
redirect("/settings/developer/webhooks");
}
{/* Modals */}
<CreateApiKeyModal
isOpen={isKeyModalOpen}
onClose={() => setIsKeyModalOpen(false)}
/>
if (tab === "docs") {
redirect("/settings/developer/docs");
}
<CreateWebhookModal
isOpen={isWebhookModalOpen}
onClose={() => setIsWebhookModalOpen(false)}
initialData={editingWebhook}
/>
</div>
);
redirect("/settings/developer/api-keys");
}
import type { Metadata } from "next";
import { RolesManagementView } from "@/components/roles/roles-management-view";
export const metadata: Metadata = {
title: "Vai Trò & Phân Quyền | Nhà Phát Triển",
description: "Quản trị danh mục vai trò người dùng và ma trận phân quyền hệ thống.",
};
export default function DeveloperRolesPage() {
return (
<div className="space-y-6 animate-in fade-in-50 duration-200">
<RolesManagementView showHeader={false} />
</div>
);
}
import type { Metadata } from "next";
import { UsersManagementView } from "@/components/users/users-management-view";
export const metadata: Metadata = {
title: "Quản Lý Người Dùng | Nhà Phát Triển",
description: "Quản trị danh sách người dùng hệ thống, phân quyền và trạng thái tài khoản.",
};
export default function DeveloperUsersPage() {
return (
<div className="space-y-6 animate-in fade-in-50 duration-200">
<UsersManagementView showHeader={false} />
</div>
);
}
"use client";
import React, { useState } from "react";
import {
Webhook,
Plus,
Edit2,
Trash2,
Send,
RefreshCw,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useLanguage } from "@/providers/language-provider";
import {
useWebhookConfigsList,
useDeleteWebhookConfig,
useTestWebhookConfig,
useWebhookDeliveriesList,
useRedeliverWebhook,
} from "@/hooks/use-developer";
import { WebhookConfig } from "@/types/developer";
import { CreateWebhookModal } from "@/components/developer/create-webhook-modal";
export default function WebhooksPage() {
const { t, locale } = useLanguage();
const { data: webhooks = [], isLoading: isLoadingWebhooks } = useWebhookConfigsList();
const { data: deliveriesData, isLoading: isLoadingDeliveries } = useWebhookDeliveriesList({ limit: 10 });
const testPingMutation = useTestWebhookConfig();
const deleteWebhookMutation = useDeleteWebhookConfig();
const redeliverMutation = useRedeliverWebhook();
const [isWebhookModalOpen, setIsWebhookModalOpen] = useState(false);
const [editingWebhook, setEditingWebhook] = useState<WebhookConfig | null>(null);
const deliveries = deliveriesData?.items || [];
const handleTestPing = (webhookId: string) => {
testPingMutation.mutate(webhookId);
};
const handleDeleteWebhook = (wh: WebhookConfig) => {
if (window.confirm(`${t.developer.webhooks.table.deleteConfirm}\n("${wh.url}")`)) {
deleteWebhookMutation.mutate(wh.id);
}
};
return (
<div className="space-y-8 animate-in fade-in-50 duration-200">
{/* Webhook Configs Section */}
<div className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h3 className="text-base font-bold text-foreground">
{t.developer.webhooks.title}
</h3>
<p className="text-xs text-muted-foreground">
{t.developer.webhooks.desc}
</p>
</div>
<Button
onClick={() => {
setEditingWebhook(null);
setIsWebhookModalOpen(true);
}}
className="rounded-2xl h-10 px-4 bg-emerald-600 hover:bg-emerald-700 text-white shadow-sm shadow-emerald-600/20 cursor-pointer"
>
<Plus className="h-4 w-4 mr-1.5" />
{t.developer.webhooks.createBtn}
</Button>
</div>
{isLoadingWebhooks ? (
<div className="h-32 rounded-3xl bg-muted/40 animate-pulse" />
) : webhooks.length === 0 ? (
<div className="p-10 text-center rounded-3xl border border-dashed border-border/80 bg-card/40 space-y-3">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
<Webhook className="h-6 w-6" />
</div>
<h4 className="text-sm font-bold text-foreground">
{t.developer.webhooks.emptyTitle}
</h4>
<p className="text-xs text-muted-foreground max-w-sm mx-auto">
{t.developer.webhooks.emptyDesc}
</p>
<Button
size="sm"
onClick={() => {
setEditingWebhook(null);
setIsWebhookModalOpen(true);
}}
className="rounded-2xl text-xs bg-emerald-600 hover:bg-emerald-700 text-white cursor-pointer"
>
<Plus className="h-3.5 w-3.5 mr-1" />
{t.developer.webhooks.createBtn}
</Button>
</div>
) : (
<div className="space-y-3">
{webhooks.map((wh) => (
<div
key={wh.id}
className="flex flex-col lg:flex-row lg:items-center justify-between p-4 rounded-3xl border border-emerald-500/15 bg-card/80 gap-4"
>
<div className="space-y-1.5 min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="font-mono text-xs font-bold text-foreground truncate">
{wh.url}
</p>
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold border ${
wh.isActive
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
: "bg-muted text-muted-foreground border-border"
}`}
>
{wh.isActive
? t.developer.webhooks.table.active
: t.developer.webhooks.table.inactive}
</span>
</div>
<div className="flex flex-wrap gap-1.5 pt-1">
{wh.events.map((ev) => (
<span
key={ev}
className="rounded-lg bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 px-2 py-0.5 text-[10px] font-mono border border-emerald-500/20"
>
{ev}
</span>
))}
</div>
</div>
<div className="flex items-center gap-2 self-start lg:self-center">
<Button
size="sm"
onClick={() => handleTestPing(wh.id)}
disabled={testPingMutation.isPending}
className="rounded-xl h-8 px-2.5 text-xs bg-emerald-600/15 hover:bg-emerald-600 text-emerald-700 dark:text-emerald-300 hover:text-white border border-emerald-500/25 transition-all cursor-pointer"
>
<Send className="h-3 w-3 mr-1" />
{t.developer.webhooks.table.testPing}
</Button>
<button
type="button"
onClick={() => {
setEditingWebhook(wh);
setIsWebhookModalOpen(true);
}}
className="p-1.5 rounded-xl text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
>
<Edit2 className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => handleDeleteWebhook(wh)}
className="p-1.5 rounded-xl text-muted-foreground hover:bg-red-500/10 hover:text-red-500 transition-colors cursor-pointer"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Deliveries Log Section */}
<div className="space-y-4 pt-4 border-t border-border/60">
<div>
<h3 className="text-base font-bold text-foreground">
{t.developer.webhooks.deliveries.title}
</h3>
<p className="text-xs text-muted-foreground">
{t.developer.webhooks.deliveries.desc}
</p>
</div>
{isLoadingDeliveries ? (
<div className="h-28 rounded-3xl bg-muted/40 animate-pulse" />
) : deliveries.length === 0 ? (
<p className="text-xs text-muted-foreground py-4">
{t.developer.webhooks.deliveries.empty}
</p>
) : (
<div className="overflow-x-auto rounded-3xl border border-emerald-500/15 bg-card/80 shadow-sm">
<table className="w-full text-left text-xs">
<thead className="border-b border-border/60 bg-muted/40 text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">
<tr>
<th className="px-5 py-3.5">{t.developer.webhooks.deliveries.event}</th>
<th className="px-4 py-3.5">{t.developer.webhooks.deliveries.status}</th>
<th className="px-4 py-3.5">{t.developer.webhooks.deliveries.code}</th>
<th className="px-4 py-3.5">{t.developer.webhooks.deliveries.attempt}</th>
<th className="px-4 py-3.5">{t.developer.webhooks.deliveries.time}</th>
<th className="px-5 py-3.5 text-right">Thao tác</th>
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{deliveries.map((del) => (
<tr key={del.id} className="hover:bg-muted/30 transition-colors">
<td className="px-5 py-3.5 font-mono text-xs font-bold text-foreground">
{del.event}
</td>
<td className="px-4 py-3.5">
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold border ${
del.status === "SUCCESS"
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20"
}`}
>
{del.status}
</span>
</td>
<td className="px-4 py-3.5 font-mono text-muted-foreground">
{del.statusCode || "--"}
</td>
<td className="px-4 py-3.5 text-muted-foreground">
{del.attempt}
</td>
<td className="px-4 py-3.5 text-muted-foreground">
{new Date(del.createdAt).toLocaleDateString(
locale === "vi" ? "vi-VN" : "en-US",
{
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
day: "numeric",
month: "short",
}
)}
</td>
<td className="px-5 py-3.5 text-right">
<Button
size="sm"
variant="ghost"
onClick={() => redeliverMutation.mutate(del.id)}
className="rounded-xl h-7 px-2 text-[11px] text-emerald-600 hover:bg-emerald-500/10 cursor-pointer"
>
<RefreshCw className="h-3 w-3 mr-1" />
{t.developer.webhooks.deliveries.redeliver}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Modal */}
<CreateWebhookModal
isOpen={isWebhookModalOpen}
onClose={() => setIsWebhookModalOpen(false)}
initialData={editingWebhook}
/>
</div>
);
}
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Quản Lý Người Dùng | Data Crawler",
description:
"Giao diện quản trị thành viên, phân quyền vai trò RBAC và kiểm soát trạng thái tài khoản người dùng trong hệ thống Data Crawler.",
};
export default function UsersLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}
import { redirect } from "next/navigation";
export default function UsersPage() {
redirect("/settings/developer/users");
}
......@@ -144,7 +144,7 @@ export function UserMenu() {
</Link>
<Link
href="/settings/developer"
href="/settings/developer/api-keys"
onClick={() => setIsOpen(false)}
className="flex items-center gap-2.5 rounded-xl px-3 py-2 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
......
"use client";
import React from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
Code2,
KeyRound,
Webhook,
Terminal,
Users,
Shield,
} from "lucide-react";
import { useLanguage } from "@/providers/language-provider";
import { useAuth } from "@/hooks/use-auth";
import { useApiKeysList, useWebhookConfigsList } from "@/hooks/use-developer";
import { useUsersList } from "@/hooks/use-users";
import { useRolesList } from "@/hooks/use-roles";
interface DeveloperShellProps {
children: React.ReactNode;
}
export function DeveloperShell({ children }: DeveloperShellProps) {
const { t } = useLanguage();
const { role, hasPermission } = useAuth();
const pathname = usePathname();
const canManageUsers = hasPermission("users.read") || role === "ADMIN";
const canManageRoles = hasPermission("roles.read") || role === "ADMIN";
// Counts for badges
const { data: apiKeys = [] } = useApiKeysList();
const { data: webhooks = [] } = useWebhookConfigsList();
const { data: usersData } = useUsersList({ limit: 1 }, { enabled: canManageUsers });
const { data: rolesData } = useRolesList({ limit: 1 }, { enabled: canManageRoles });
const totalUsers = usersData?.meta?.total || 0;
const totalRoles = rolesData?.meta?.total ?? rolesData?.items?.length ?? 0;
const isUsersTab = pathname.startsWith("/settings/developer/users");
const isRolesTab =
pathname.startsWith("/settings/developer/roles") ||
pathname.startsWith("/roles");
const isApiKeysTab =
pathname === "/settings/developer/api-keys" ||
pathname === "/settings/developer";
const isWebhooksTab = pathname.startsWith("/settings/developer/webhooks");
const isDocsTab = pathname.startsWith("/settings/developer/docs");
return (
<div className="min-h-screen bg-background pb-16">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 pt-8 space-y-8">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="space-y-1">
<div className="inline-flex items-center gap-2 rounded-full bg-emerald-500/10 px-3 py-1 text-xs font-semibold text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<Code2 className="h-3.5 w-3.5" />
<span>{t.developer.title}</span>
</div>
<h1 className="text-2xl sm:text-3xl font-extrabold tracking-tight text-foreground">
{t.developer.title}
</h1>
<p className="text-sm text-muted-foreground max-w-2xl">
{t.developer.subtitle}
</p>
</div>
</div>
{/* Tabs Bar with real URL Links */}
<div className="flex border-b border-border/70 space-x-2 overflow-x-auto">
{/* 1. Tab Người Dùng (RBAC Permission) */}
{canManageUsers && (
<Link
href="/settings/developer/users"
className={`flex items-center gap-2 px-4 py-3 text-xs font-bold border-b-2 transition-all cursor-pointer whitespace-nowrap ${
isUsersTab
? "border-emerald-500 text-emerald-600 dark:text-emerald-400"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<Users className="h-4 w-4" />
<span>{t.users.title}</span>
{totalUsers > 0 && (
<span className="rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 text-[10px] px-2 py-0.5 border border-emerald-500/20 font-semibold">
{totalUsers}
</span>
)}
</Link>
)}
{/* 2. Tab Vai Trò & Ma Trận Phân Quyền (RBAC Permission) */}
{canManageRoles && (
<Link
href="/settings/developer/roles"
className={`flex items-center gap-2 px-4 py-3 text-xs font-bold border-b-2 transition-all cursor-pointer whitespace-nowrap ${
isRolesTab
? "border-emerald-500 text-emerald-600 dark:text-emerald-400"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<Shield className="h-4 w-4" />
<span>{t.roles.title}</span>
{totalRoles > 0 && (
<span className="rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 text-[10px] px-2 py-0.5 border border-emerald-500/20 font-semibold">
{totalRoles}
</span>
)}
</Link>
)}
{/* 2. Tab Khóa API */}
<Link
href="/settings/developer/api-keys"
className={`flex items-center gap-2 px-4 py-3 text-xs font-bold border-b-2 transition-all cursor-pointer whitespace-nowrap ${
isApiKeysTab
? "border-emerald-500 text-emerald-600 dark:text-emerald-400"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<KeyRound className="h-4 w-4" />
<span>{t.developer.tabs.apiKeys}</span>
<span className="rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 text-[10px] px-2 py-0.5 border border-emerald-500/20 font-semibold">
{apiKeys.length}
</span>
</Link>
{/* 3. Tab Webhooks */}
<Link
href="/settings/developer/webhooks"
className={`flex items-center gap-2 px-4 py-3 text-xs font-bold border-b-2 transition-all cursor-pointer whitespace-nowrap ${
isWebhooksTab
? "border-emerald-500 text-emerald-600 dark:text-emerald-400"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<Webhook className="h-4 w-4" />
<span>{t.developer.tabs.webhooks}</span>
<span className="rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 text-[10px] px-2 py-0.5 border border-emerald-500/20 font-semibold">
{webhooks.length}
</span>
</Link>
{/* 4. Tab Tích Hợp Nhanh (Docs) */}
<Link
href="/settings/developer/docs"
className={`flex items-center gap-2 px-4 py-3 text-xs font-bold border-b-2 transition-all cursor-pointer whitespace-nowrap ${
isDocsTab
? "border-emerald-500 text-emerald-600 dark:text-emerald-400"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<Terminal className="h-4 w-4" />
<span>{t.developer.tabs.docs}</span>
</Link>
</div>
{/* Tab Page Content */}
<div className="pt-2">{children}</div>
</div>
</div>
);
}
"use client";
import React, { useState, useEffect, useMemo } from "react";
import {
X,
Sparkles,
Shield,
Search,
Check,
CheckCheck,
RotateCcw,
Globe,
Calendar,
FileCode2,
Download,
Webhook,
KeyRound,
Users,
History,
LayoutDashboard,
Lock,
ChevronRight,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useLanguage } from "@/providers/language-provider";
import { RoleItem } from "@/types/role";
import { PermissionItem } from "@/types/permission";
import { useRolePermissions, useSetRolePermissions } from "@/hooks/use-roles";
import { usePermissionsList } from "@/hooks/use-permissions";
interface PermissionMatrixDialogProps {
isOpen: boolean;
onClose: () => void;
role: RoleItem | null;
}
interface ModuleConfig {
name: string;
nameEn: string;
icon: React.ComponentType<{ className?: string }>;
color: string;
}
const MODULE_CONFIGS: Record<string, ModuleConfig> = {
crawl_jobs: {
name: "Tác Vụ Crawl",
nameEn: "Crawl Jobs",
icon: Globe,
color: "text-emerald-500",
},
crawl_schedules: {
name: "Lập Lịch Crawl",
nameEn: "Crawl Schedules",
icon: Calendar,
color: "text-blue-500",
},
extraction_templates: {
name: "Mẫu Bóc Tách",
nameEn: "Extraction Templates",
icon: FileCode2,
color: "text-purple-500",
},
exports: {
name: "Xuất Dữ Liệu",
nameEn: "Data Exports",
icon: Download,
color: "text-amber-500",
},
webhooks: {
name: "Webhooks",
nameEn: "Webhooks",
icon: Webhook,
color: "text-indigo-500",
},
api_keys: {
name: "Khóa API",
nameEn: "API Keys",
icon: KeyRound,
color: "text-teal-500",
},
users: {
name: "Quản Lý Người Dùng",
nameEn: "User Management",
icon: Users,
color: "text-rose-500",
},
roles: {
name: "Vai Trò & Phân Quyền",
nameEn: "Roles & Permissions",
icon: Shield,
color: "text-emerald-600",
},
permissions: {
name: "Danh Mục Quyền",
nameEn: "System Permissions",
icon: Lock,
color: "text-cyan-500",
},
audit_logs: {
name: "Nhật Ký Hệ Thống",
nameEn: "Audit Logs",
icon: History,
color: "text-orange-500",
},
dashboard: {
name: "Bảng Điều Khiển",
nameEn: "Dashboard",
icon: LayoutDashboard,
color: "text-emerald-500",
},
};
export function PermissionMatrixDialog({
isOpen,
onClose,
role,
}: PermissionMatrixDialogProps) {
const { t, locale } = useLanguage();
// 1. Fetch all system permissions catalog: GET /permissions
const { data: allPermissions = [], isLoading: isCatalogLoading } =
usePermissionsList(undefined, { enabled: isOpen });
// 2. Fetch current role assigned permissions: GET /roles/:id/permissions
const { data: rolePermissions, isLoading: isRolePermsLoading } =
useRolePermissions(role?.id || "", { enabled: isOpen && !!role });
// Mutation to save matrix: PUT /roles/:id/permissions
const setPermissionsMutation = useSetRolePermissions();
// Local state of selected permission IDs
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState("");
// Sync initial permissions into local state with equality check
useEffect(() => {
if (!isOpen || !rolePermissions) return;
const initialIds = rolePermissions.map((p) => p.id);
setSelectedIds((prev) => {
if (
prev.length === initialIds.length &&
prev.every((id) => initialIds.includes(id))
) {
return prev;
}
return initialIds;
});
}, [isOpen, rolePermissions]);
// Handle escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen) {
onClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
// Filter permissions based on search query
const filteredPermissions = useMemo(() => {
const q = searchQuery.trim().toLowerCase();
if (!q) return allPermissions;
return allPermissions.filter(
(p) =>
p.name.toLowerCase().includes(q) ||
p.slug.toLowerCase().includes(q) ||
(p.description && p.description.toLowerCase().includes(q)) ||
p.resource.toLowerCase().includes(q)
);
}, [allPermissions, searchQuery]);
// Group filtered permissions by resource module
const groupedModules = useMemo(() => {
const map = new Map<string, PermissionItem[]>();
for (const p of filteredPermissions) {
const list = map.get(p.resource) || [];
list.push(p);
map.set(p.resource, list);
}
return Array.from(map.entries()).map(([resource, permissions]) => ({
resource,
config: MODULE_CONFIGS[resource] || {
name: resource,
nameEn: resource,
icon: Shield,
color: "text-muted-foreground",
},
permissions,
}));
}, [filteredPermissions]);
if (!isOpen || !role) return null;
const totalPermissionsCount = allPermissions.length;
const selectedCount = selectedIds.length;
const isLoading = isCatalogLoading || isRolePermsLoading;
// Global Toggle All
const handleToggleAll = () => {
if (selectedIds.length === allPermissions.length) {
setSelectedIds([]);
} else {
setSelectedIds(allPermissions.map((p) => p.id));
}
};
// Module Toggle All
const handleToggleModule = (modulePermissions: PermissionItem[]) => {
const moduleIds = modulePermissions.map((p) => p.id);
const allSelected = moduleIds.every((id) => selectedIds.includes(id));
if (allSelected) {
setSelectedIds((prev) => prev.filter((id) => !moduleIds.includes(id)));
} else {
setSelectedIds((prev) => Array.from(new Set([...prev, ...moduleIds])));
}
};
// Individual Permission Toggle
const handleTogglePermission = (permissionId: string) => {
setSelectedIds((prev) =>
prev.includes(permissionId)
? prev.filter((id) => id !== permissionId)
: [...prev, permissionId]
);
};
// Save changes to backend: PUT /roles/:id/permissions
const handleSave = async () => {
await setPermissionsMutation.mutateAsync({
roleId: role.id,
permissionIds: selectedIds,
});
onClose();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4 bg-background/80 backdrop-blur-sm animate-in fade-in-0 duration-200">
<div className="relative w-full max-w-4xl max-h-[92vh] flex flex-col rounded-3xl border border-emerald-500/20 bg-card shadow-2xl shadow-emerald-950/20 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-5 sm:p-6 pb-4 border-b border-border/60 shrink-0">
<div className="flex items-center gap-3 min-w-0">
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20 shrink-0">
<Sparkles className="h-5 w-5" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h2 className="text-lg font-bold tracking-tight text-foreground truncate">
{t.roles.matrix.title}
</h2>
<span className="rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 text-xs px-2.5 py-0.5 font-bold border border-emerald-500/20">
{role.name}
</span>
<span className="font-mono text-xs text-muted-foreground hidden sm:inline">
({role.slug})
</span>
</div>
<p className="text-xs text-muted-foreground truncate pt-0.5">
{t.roles.matrix.subtitle} <span className="font-semibold text-foreground">{role.name}</span>
</p>
</div>
</div>
<button
onClick={onClose}
className="rounded-xl p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer shrink-0"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Toolbar & Progress Bar */}
<div className="p-4 sm:px-6 bg-muted/20 border-b border-border/60 shrink-0 space-y-3">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
{/* Search Input */}
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t.roles.matrix.searchPlaceholder}
className="pl-9 h-9 text-xs rounded-2xl border-border/80 bg-card/60 focus:border-emerald-500"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground text-xs cursor-pointer"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
{/* Quick Actions & Counter */}
<div className="flex items-center gap-2.5 justify-between sm:justify-end">
<div className="text-xs">
<span className="text-muted-foreground">
{t.roles.matrix.selectedSummary}{" "}
</span>
<span className="font-bold text-emerald-600 dark:text-emerald-400">
{selectedCount} / {totalPermissionsCount}
</span>
</div>
<Button
size="sm"
variant="outline"
onClick={handleToggleAll}
className="rounded-xl text-xs h-8 cursor-pointer border-border/70"
>
{selectedCount === totalPermissionsCount ? (
<>
<RotateCcw className="h-3 w-3 mr-1 text-muted-foreground" />
{t.roles.matrix.deselectAll}
</>
) : (
<>
<CheckCheck className="h-3 w-3 mr-1 text-emerald-500" />
{t.roles.matrix.selectAll}
</>
)}
</Button>
</div>
</div>
{/* Progress bar visual indicator */}
<div className="w-full bg-muted rounded-full h-1.5 overflow-hidden">
<div
className="bg-gradient-to-r from-emerald-500 to-teal-400 h-1.5 transition-all duration-300 rounded-full"
style={{
width: `${
totalPermissionsCount > 0
? (selectedCount / totalPermissionsCount) * 100
: 0
}%`,
}}
/>
</div>
</div>
{/* Modules Permission Matrix List */}
<div className="flex-1 overflow-y-auto p-4 sm:p-6 space-y-6">
{isLoading ? (
<div className="space-y-4 py-4">
{[...Array(3)].map((_, i) => (
<div
key={i}
className="h-32 rounded-3xl bg-muted/40 animate-pulse border border-border/40"
/>
))}
</div>
) : groupedModules.length === 0 ? (
<div className="p-12 text-center text-xs text-muted-foreground">
Không tìm thấy quyền hạn nào khớp với từ khóa &quot;{searchQuery}&quot;.
</div>
) : (
groupedModules.map(({ resource, config, permissions }) => {
const Icon = config.icon;
const moduleIds = permissions.map((p) => p.id);
const moduleSelectedCount = moduleIds.filter((id) =>
selectedIds.includes(id)
).length;
const isAllModuleSelected =
moduleSelectedCount === permissions.length &&
permissions.length > 0;
const isSomeModuleSelected =
moduleSelectedCount > 0 && !isAllModuleSelected;
return (
<div
key={resource}
className="rounded-3xl border border-emerald-500/15 bg-card/80 p-4 sm:p-5 shadow-xs space-y-3.5 transition-all duration-150"
>
{/* Module Header */}
<div className="flex items-center justify-between pb-3 border-b border-border/60 gap-2">
<div className="flex items-center gap-2.5">
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-muted/60 border border-border/60 shrink-0">
<Icon className={`h-4 w-4 ${config.color}`} />
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="font-bold text-xs text-foreground">
{locale === "vi" ? config.name : config.nameEn}
</h3>
<span className="font-mono text-[10px] text-muted-foreground rounded-md bg-muted px-1.5 py-0.5 border border-border/60">
{resource}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-[11px] text-muted-foreground hidden sm:inline">
{moduleSelectedCount} / {permissions.length}
</span>
{/* Module Select All Checkbox */}
<button
type="button"
onClick={() => handleToggleModule(permissions)}
className="flex items-center gap-1.5 px-2.5 py-1 rounded-xl text-xs font-semibold hover:bg-muted/80 transition-colors cursor-pointer border border-border/70"
>
<div
className={`flex h-4 w-4 items-center justify-center rounded-md border transition-colors ${
isAllModuleSelected
? "bg-emerald-600 border-emerald-600 text-white"
: isSomeModuleSelected
? "bg-emerald-500/30 border-emerald-500 text-emerald-700 dark:text-emerald-300"
: "border-muted-foreground/40 bg-background"
}`}
>
{isAllModuleSelected && (
<Check className="h-3 w-3 stroke-[3]" />
)}
{isSomeModuleSelected && (
<span className="h-1.5 w-1.5 rounded-xs bg-emerald-600" />
)}
</div>
<span className="text-[11px] text-muted-foreground">
{isAllModuleSelected ? "Bỏ chọn" : "Chọn nhóm"}
</span>
</button>
</div>
</div>
{/* Module Permissions Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-2.5">
{permissions.map((perm) => {
const isChecked = selectedIds.includes(perm.id);
return (
<label
key={perm.id}
onClick={(e) => {
e.preventDefault();
handleTogglePermission(perm.id);
}}
className={`flex items-start gap-3 p-3 rounded-2xl border transition-all duration-150 cursor-pointer select-none ${
isChecked
? "border-emerald-500/40 bg-emerald-500/10 shadow-xs"
: "border-border/60 bg-card/60 hover:bg-muted/40 hover:border-emerald-500/25"
}`}
>
{/* Checkbox */}
<div
className={`flex h-4 w-4 items-center justify-center rounded-md border mt-0.5 transition-colors shrink-0 ${
isChecked
? "bg-emerald-600 border-emerald-600 text-white"
: "border-muted-foreground/40 bg-background"
}`}
>
{isChecked && (
<Check className="h-3 w-3 stroke-[3]" />
)}
</div>
{/* Permission Details */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-1">
<span className="font-semibold text-xs text-foreground truncate">
{perm.name}
</span>
<span className="font-mono text-[9px] rounded-md bg-muted/60 px-1.5 py-0.5 text-muted-foreground border border-border/50 uppercase shrink-0">
{perm.action}
</span>
</div>
<p className="font-mono text-[10px] text-emerald-600/90 dark:text-emerald-400/90 pt-0.5 truncate">
{perm.slug}
</p>
{perm.description && (
<p className="text-[11px] text-muted-foreground line-clamp-1 pt-0.5">
{perm.description}
</p>
)}
</div>
</label>
);
})}
</div>
</div>
);
})
)}
</div>
{/* Footer Actions */}
<div className="p-4 sm:px-6 border-t border-border/60 bg-muted/20 flex items-center justify-between shrink-0 gap-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Shield className="h-4 w-4 text-emerald-500" />
<span className="hidden sm:inline">
Quyền hạn sẽ có hiệu lực ngay lập tức sau khi lưu.
</span>
</div>
<div className="flex items-center gap-2.5">
<Button
type="button"
variant="outline"
onClick={onClose}
className="rounded-2xl text-xs cursor-pointer"
>
{t.roles.matrix.cancel}
</Button>
<Button
type="button"
disabled={setPermissionsMutation.isPending || isLoading}
onClick={handleSave}
className="rounded-2xl bg-emerald-600 hover:bg-emerald-700 text-white text-xs font-semibold px-5 shadow-md shadow-emerald-600/20 cursor-pointer transition-all hover:scale-[1.02]"
>
{setPermissionsMutation.isPending
? t.roles.matrix.saving
: t.roles.matrix.saveBtn}
</Button>
</div>
</div>
</div>
</div>
);
}
"use client";
import React from "react";
import {
Shield,
ShieldCheck,
Users,
KeyRound,
Edit2,
Trash2,
Lock,
Sparkles,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useLanguage } from "@/providers/language-provider";
import { useAuth } from "@/hooks/use-auth";
import { RoleItem } from "@/types/role";
interface RoleCardProps {
role: RoleItem;
onEdit: (role: RoleItem) => void;
onOpenMatrix: (role: RoleItem) => void;
onViewUsers: (role: RoleItem) => void;
onDelete: (role: RoleItem) => void;
}
export function RoleCard({
role,
onEdit,
onOpenMatrix,
onViewUsers,
onDelete,
}: RoleCardProps) {
const { t } = useLanguage();
const { role: userRole, hasPermission } = useAuth();
const canEdit = hasPermission("roles.update") || userRole === "ADMIN";
const canDelete = hasPermission("roles.delete") || userRole === "ADMIN";
const canAssignPerms = hasPermission("roles.update") || userRole === "ADMIN";
const userCount = role.userCount || 0;
const permissionCount = role.permissions?.length || 0;
const isProtected = role.isSystem || userCount > 0;
return (
<div className="flex flex-col justify-between rounded-3xl border border-emerald-500/15 bg-card/90 p-5 shadow-xs transition-all duration-200 hover:shadow-md hover:border-emerald-500/30 group">
{/* Top Header */}
<div className="space-y-3">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
<div
className={`flex h-11 w-11 items-center justify-center rounded-2xl border shrink-0 transition-transform duration-200 group-hover:scale-105 ${
role.isSystem
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/25"
: "bg-teal-500/10 text-teal-600 dark:text-teal-400 border-teal-500/25"
}`}
>
{role.isSystem ? (
<ShieldCheck className="h-5 w-5" />
) : (
<Shield className="h-5 w-5" />
)}
</div>
<div>
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-bold text-sm text-foreground">
{role.name}
</h3>
{role.isSystem ? (
<span className="rounded-full bg-emerald-500/10 px-2.5 py-0.5 text-[10px] font-semibold text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
{t.roles.table.systemBadge}
</span>
) : (
<span className="rounded-full bg-teal-500/10 px-2.5 py-0.5 text-[10px] font-semibold text-teal-600 dark:text-teal-400 border border-teal-500/20">
{t.roles.table.customBadge}
</span>
)}
</div>
<p className="font-mono text-xs text-muted-foreground pt-0.5">
{role.slug}
</p>
</div>
</div>
<span
className={`rounded-full px-2.5 py-0.5 text-[10px] font-semibold border shrink-0 ${
role.isActive
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
: "bg-muted text-muted-foreground border-border"
}`}
>
{role.isActive
? t.roles.table.activeBadge
: t.roles.table.inactiveBadge}
</span>
</div>
{/* Description */}
<p className="text-xs text-muted-foreground line-clamp-2 min-h-[32px] leading-relaxed">
{role.description || "Không có mô tả cho vai trò này."}
</p>
</div>
{/* Middle Stats & Badges */}
<div className="py-4 my-2 border-y border-border/50 flex items-center justify-between gap-2">
{/* Permissions Pill Button */}
<button
type="button"
onClick={() => onOpenMatrix(role)}
title={t.roles.card.permissionsTooltip}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-muted/60 hover:bg-emerald-500/10 hover:text-emerald-600 dark:hover:text-emerald-400 transition-colors text-xs font-semibold text-foreground cursor-pointer border border-border/60"
>
<KeyRound className="h-3.5 w-3.5 text-emerald-500" />
<span>{permissionCount}</span>
<span className="text-[11px] font-normal text-muted-foreground">
{t.roles.table.permissionsUnit}
</span>
</button>
{/* Users Pill Button */}
<button
type="button"
onClick={() => onViewUsers(role)}
title={t.roles.card.usersTooltip}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-xl transition-colors text-xs font-semibold cursor-pointer border ${
userCount > 0
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300 border-emerald-500/25 hover:bg-emerald-500/20"
: "bg-muted/60 text-muted-foreground border-border/60 hover:bg-muted"
}`}
>
<Users className="h-3.5 w-3.5 text-emerald-500" />
<span>{userCount}</span>
<span className="text-[11px] font-normal text-muted-foreground">
{t.roles.table.membersUnit}
</span>
</button>
</div>
{/* Bottom Actions */}
<div className="flex items-center justify-between gap-2 pt-1">
{/* Permission Matrix Action */}
{canAssignPerms ? (
<Button
size="sm"
onClick={() => onOpenMatrix(role)}
className="rounded-2xl text-xs h-8 px-3 bg-emerald-600/15 hover:bg-emerald-600 text-emerald-700 dark:text-emerald-300 hover:text-white border border-emerald-500/25 transition-all cursor-pointer font-semibold shadow-2xs"
>
<Sparkles className="h-3.5 w-3.5 mr-1" />
{t.roles.actions.matrix}
</Button>
) : (
<div />
)}
<div className="flex items-center gap-1">
{/* Edit Button */}
{canEdit && (
<button
type="button"
onClick={() => onEdit(role)}
className="p-1.5 rounded-xl text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
title={t.roles.actions.edit}
>
<Edit2 className="h-4 w-4" />
</button>
)}
{/* Delete Button */}
{canDelete && (
<button
type="button"
disabled={isProtected}
onClick={() => onDelete(role)}
title={
role.isSystem
? t.roles.dialogDelete.isSystemWarning
: userCount > 0
? t.roles.dialogDelete.hasUsersWarning.replace("{count}", String(userCount))
: t.roles.actions.delete
}
className={`p-1.5 rounded-xl transition-colors ${
isProtected
? "text-muted-foreground/40 cursor-not-allowed"
: "text-muted-foreground hover:bg-red-500/10 hover:text-red-500 cursor-pointer"
}`}
>
{role.isSystem ? (
<Lock className="h-4 w-4" />
) : (
<Trash2 className="h-4 w-4" />
)}
</button>
)}
</div>
</div>
</div>
);
}
"use client";
import React, { useEffect } from "react";
import { AlertTriangle, ShieldAlert, Lock, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useLanguage } from "@/providers/language-provider";
import { RoleItem } from "@/types/role";
import { useDeleteRole } from "@/hooks/use-roles";
interface RoleDeleteDialogProps {
isOpen: boolean;
onClose: () => void;
role: RoleItem | null;
}
export function RoleDeleteDialog({
isOpen,
onClose,
role,
}: RoleDeleteDialogProps) {
const { t } = useLanguage();
const deleteMutation = useDeleteRole();
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen) {
onClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen || !role) return null;
const userCount = role.userCount || 0;
const isSystem = role.isSystem;
const isBlocked = isSystem || userCount > 0;
const handleConfirmDelete = async () => {
if (isBlocked) return;
await deleteMutation.mutateAsync(role.id);
onClose();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm animate-in fade-in-0 duration-200">
<div className="relative w-full max-w-md rounded-3xl border border-red-500/20 bg-card p-6 shadow-2xl shadow-red-950/20">
{/* Close Button */}
<button
onClick={onClose}
className="absolute right-4 top-4 rounded-xl p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
>
<X className="h-4 w-4" />
</button>
<div className="space-y-4">
{/* Icon */}
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-red-500/10 text-red-600 dark:text-red-400 border border-red-500/20">
{isBlocked ? (
<Lock className="h-6 w-6" />
) : (
<AlertTriangle className="h-6 w-6" />
)}
</div>
{/* Title & Desc */}
<div>
<h3 className="text-base font-bold text-foreground">
{t.roles.dialogDelete.title}
</h3>
<p className="text-xs text-muted-foreground pt-1 leading-relaxed">
{t.roles.dialogDelete.desc}
</p>
</div>
{/* Target Role Card */}
<div className="rounded-2xl border border-border/70 bg-muted/30 p-3 flex items-center justify-between">
<div>
<span className="text-xs font-bold text-foreground block">
{role.name}
</span>
<span className="font-mono text-[11px] text-muted-foreground">
{role.slug}
</span>
</div>
{isSystem ? (
<span className="rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 text-[10px] px-2 py-0.5 font-bold border border-emerald-500/20">
Hệ thống
</span>
) : (
<span className="text-[11px] font-semibold text-muted-foreground">
{userCount} thành viên
</span>
)}
</div>
{/* Block Warnings */}
{isSystem && (
<div className="rounded-2xl border border-amber-500/30 bg-amber-500/10 p-3 flex items-start gap-2.5 text-xs text-amber-700 dark:text-amber-400">
<ShieldAlert className="h-4 w-4 shrink-0 mt-0.5" />
<span>{t.roles.dialogDelete.isSystemWarning}</span>
</div>
)}
{!isSystem && userCount > 0 && (
<div className="rounded-2xl border border-amber-500/30 bg-amber-500/10 p-3 flex items-start gap-2.5 text-xs text-amber-700 dark:text-amber-400">
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5" />
<span>
{t.roles.dialogDelete.hasUsersWarning.replace(
"{count}",
String(userCount)
)}
</span>
</div>
)}
{/* Action Buttons */}
<div className="pt-2 flex items-center justify-end gap-2.5">
<Button
type="button"
variant="outline"
onClick={onClose}
className="rounded-2xl text-xs cursor-pointer"
>
{t.roles.dialogDelete.cancel}
</Button>
<Button
type="button"
disabled={isBlocked || deleteMutation.isPending}
onClick={handleConfirmDelete}
className="rounded-2xl bg-red-600 hover:bg-red-700 text-white text-xs font-semibold px-4 cursor-pointer shadow-md shadow-red-600/20"
>
{deleteMutation.isPending
? t.roles.dialogDelete.deleting
: t.roles.dialogDelete.confirm}
</Button>
</div>
</div>
</div>
</div>
);
}
"use client";
import React, { useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { X, ShieldPlus, Edit3, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useLanguage } from "@/providers/language-provider";
import { RoleItem } from "@/types/role";
import { useCreateRole, useUpdateRole } from "@/hooks/use-roles";
import {
createRoleSchema,
CreateRoleInput,
} from "@/schemas/role.schema";
interface RoleFormModalProps {
isOpen: boolean;
onClose: () => void;
roleToEdit?: RoleItem | null;
}
export function RoleFormModal({
isOpen,
onClose,
roleToEdit,
}: RoleFormModalProps) {
const { t } = useLanguage();
const createMutation = useCreateRole();
const updateMutation = useUpdateRole();
const isEditing = !!roleToEdit;
const {
register,
handleSubmit,
setValue,
watch,
reset,
formState: { errors, isSubmitting },
} = useForm<CreateRoleInput>({
resolver: zodResolver(createRoleSchema),
defaultValues: {
name: "",
slug: "",
description: "",
},
});
const nameValue = watch("name");
// Sync initial values when modal opens or roleToEdit changes
useEffect(() => {
if (isOpen) {
if (roleToEdit) {
reset({
name: roleToEdit.name,
slug: roleToEdit.slug,
description: roleToEdit.description || "",
});
} else {
reset({
name: "",
slug: "",
description: "",
});
}
}
}, [isOpen, roleToEdit, reset]);
// Auto slug generator when creating a new role
const handleGenerateSlug = () => {
if (!isEditing && nameValue) {
const generated = nameValue
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/đ/g, "d")
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
setValue("slug", generated, { shouldValidate: true });
}
};
// Close on escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen) {
onClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen) return null;
const onSubmit = async (data: CreateRoleInput) => {
if (isEditing && roleToEdit) {
await updateMutation.mutateAsync({
id: roleToEdit.id,
dto: {
name: data.name,
description: data.description || undefined,
},
});
} else {
await createMutation.mutateAsync({
name: data.name,
slug: data.slug.toLowerCase().trim(),
description: data.description || undefined,
});
}
onClose();
};
const isPending = createMutation.isPending || updateMutation.isPending || isSubmitting;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm animate-in fade-in-0 duration-200">
<div className="relative w-full max-w-lg rounded-3xl border border-emerald-500/20 bg-card p-6 shadow-2xl shadow-emerald-950/20">
{/* Header */}
<div className="flex items-center justify-between pb-4 border-b border-border/60">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
{isEditing ? (
<Edit3 className="h-5 w-5" />
) : (
<ShieldPlus className="h-5 w-5" />
)}
</div>
<div>
<h2 className="text-lg font-bold tracking-tight text-foreground">
{isEditing
? t.roles.modalForm.editTitle
: t.roles.modalForm.createTitle}
</h2>
<p className="text-xs text-muted-foreground">
{isEditing
? t.roles.modalForm.editDesc
: t.roles.modalForm.createDesc}
</p>
</div>
</div>
<button
onClick={onClose}
className="rounded-xl p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 pt-4">
{/* Role Name */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-foreground">
{t.roles.modalForm.nameLabel}{" "}
<span className="text-red-500">*</span>
</Label>
<Input
{...register("name")}
placeholder={t.roles.modalForm.namePlaceholder}
className="h-10 rounded-2xl border-border/80 bg-muted/30 focus:border-emerald-500 text-xs"
/>
{errors.name && (
<p className="text-[11px] text-red-500 font-medium">
{errors.name.message}
</p>
)}
</div>
{/* Role Slug */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label className="text-xs font-semibold text-foreground">
{t.roles.modalForm.slugLabel}{" "}
<span className="text-red-500">*</span>
</Label>
{!isEditing && (
<button
type="button"
onClick={handleGenerateSlug}
className="flex items-center gap-1 text-[11px] text-emerald-600 dark:text-emerald-400 hover:underline cursor-pointer font-medium"
>
<Sparkles className="h-3 w-3" />
Tạo từ tên
</button>
)}
</div>
<Input
{...register("slug")}
disabled={isEditing}
placeholder={t.roles.modalForm.slugPlaceholder}
className={`h-10 rounded-2xl border-border/80 text-xs font-mono ${
isEditing
? "bg-muted/60 text-muted-foreground cursor-not-allowed"
: "bg-muted/30 focus:border-emerald-500"
}`}
/>
{errors.slug && (
<p className="text-[11px] text-red-500 font-medium">
{errors.slug.message}
</p>
)}
<p className="text-[11px] text-muted-foreground leading-tight">
{t.roles.modalForm.slugHelper}
</p>
</div>
{/* Description */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-foreground">
{t.roles.modalForm.descLabel}
</Label>
<textarea
{...register("description")}
rows={3}
placeholder={t.roles.modalForm.descPlaceholder}
className="w-full rounded-2xl border border-border/80 bg-muted/30 p-3 text-xs focus:outline-hidden focus:border-emerald-500 text-foreground resize-none"
/>
{errors.description && (
<p className="text-[11px] text-red-500 font-medium">
{errors.description.message}
</p>
)}
</div>
{/* Buttons */}
<div className="pt-4 flex items-center justify-end gap-2.5 border-t border-border/60">
<Button
type="button"
variant="outline"
onClick={onClose}
className="rounded-2xl text-xs cursor-pointer"
>
{t.roles.modalForm.cancel}
</Button>
<Button
type="submit"
disabled={isPending}
className="rounded-2xl bg-emerald-600 hover:bg-emerald-700 text-white text-xs font-semibold px-5 shadow-md shadow-emerald-600/20 cursor-pointer transition-all hover:scale-[1.02]"
>
{isPending ? t.roles.modalForm.saving : t.roles.modalForm.save}
</Button>
</div>
</form>
</div>
</div>
);
}
"use client";
import React from "react";
import {
Shield,
ShieldCheck,
Users,
KeyRound,
Edit2,
Trash2,
Lock,
Sparkles,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useLanguage } from "@/providers/language-provider";
import { useAuth } from "@/hooks/use-auth";
import { RoleItem } from "@/types/role";
interface RoleTableProps {
roles: RoleItem[];
onEdit: (role: RoleItem) => void;
onOpenMatrix: (role: RoleItem) => void;
onViewUsers: (role: RoleItem) => void;
onDelete: (role: RoleItem) => void;
}
export function RoleTable({
roles,
onEdit,
onOpenMatrix,
onViewUsers,
onDelete,
}: RoleTableProps) {
const { t } = useLanguage();
const { role: userRole, hasPermission } = useAuth();
const canEdit = hasPermission("roles.update") || userRole === "ADMIN";
const canDelete = hasPermission("roles.delete") || userRole === "ADMIN";
const canAssignPerms = hasPermission("roles.update") || userRole === "ADMIN";
return (
<div className="overflow-x-auto rounded-3xl border border-emerald-500/15 bg-card/80 shadow-xs">
<table className="w-full text-left text-xs">
<thead className="border-b border-border/60 bg-muted/40 text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">
<tr>
<th className="px-5 py-3.5">{t.roles.table.name}</th>
<th className="px-4 py-3.5">{t.roles.table.slug}</th>
<th className="px-4 py-3.5">{t.roles.table.type}</th>
<th className="px-4 py-3.5">{t.roles.table.permissionsCount}</th>
<th className="px-4 py-3.5">{t.roles.table.usersCount}</th>
<th className="px-4 py-3.5">{t.roles.table.status}</th>
<th className="px-5 py-3.5 text-right">{t.roles.table.actions}</th>
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{roles.map((role) => {
const userCount = role.userCount || 0;
const permissionCount = role.permissions?.length || 0;
const isProtected = role.isSystem || userCount > 0;
return (
<tr
key={role.id}
className="hover:bg-muted/30 transition-colors duration-150"
>
{/* Name */}
<td className="px-5 py-4">
<div className="flex items-center gap-3">
<div
className={`flex h-9 w-9 items-center justify-center rounded-xl border shrink-0 ${
role.isSystem
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/25"
: "bg-teal-500/10 text-teal-600 dark:text-teal-400 border-teal-500/25"
}`}
>
{role.isSystem ? (
<ShieldCheck className="h-4 w-4" />
) : (
<Shield className="h-4 w-4" />
)}
</div>
<div>
<span className="font-bold text-foreground block">
{role.name}
</span>
{role.description && (
<span className="text-[11px] text-muted-foreground line-clamp-1 max-w-xs">
{role.description}
</span>
)}
</div>
</div>
</td>
{/* Slug */}
<td className="px-4 py-4 font-mono text-muted-foreground">
{role.slug}
</td>
{/* Type Badge */}
<td className="px-4 py-4">
{role.isSystem ? (
<span className="rounded-full bg-emerald-500/10 px-2.5 py-0.5 text-[10px] font-semibold text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
{t.roles.table.systemBadge}
</span>
) : (
<span className="rounded-full bg-teal-500/10 px-2.5 py-0.5 text-[10px] font-semibold text-teal-600 dark:text-teal-400 border border-teal-500/20">
{t.roles.table.customBadge}
</span>
)}
</td>
{/* Permissions Count */}
<td className="px-4 py-4">
<button
type="button"
onClick={() => onOpenMatrix(role)}
title={t.roles.card.permissionsTooltip}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-xl bg-muted/60 hover:bg-emerald-500/15 hover:text-emerald-600 dark:hover:text-emerald-400 transition-colors text-xs font-semibold cursor-pointer border border-border/60"
>
<KeyRound className="h-3 w-3 text-emerald-500" />
<span>{permissionCount}</span>
<span className="text-[10px] font-normal text-muted-foreground">
{t.roles.table.permissionsUnit}
</span>
</button>
</td>
{/* Members Count */}
<td className="px-4 py-4">
<button
type="button"
onClick={() => onViewUsers(role)}
title={t.roles.card.usersTooltip}
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-xl transition-colors text-xs font-semibold cursor-pointer border ${
userCount > 0
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300 border-emerald-500/25 hover:bg-emerald-500/20"
: "bg-muted/60 text-muted-foreground border-border/60 hover:bg-muted"
}`}
>
<Users className="h-3 w-3 text-emerald-500" />
<span>{userCount}</span>
<span className="text-[10px] font-normal text-muted-foreground">
{t.roles.table.membersUnit}
</span>
</button>
</td>
{/* Status */}
<td className="px-4 py-4">
<span
className={`rounded-full px-2.5 py-0.5 text-[10px] font-semibold border ${
role.isActive
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
: "bg-muted text-muted-foreground border-border"
}`}
>
{role.isActive
? t.roles.table.activeBadge
: t.roles.table.inactiveBadge}
</span>
</td>
{/* Actions */}
<td className="px-5 py-4 text-right">
<div className="flex items-center justify-end gap-1.5">
{canAssignPerms && (
<Button
size="sm"
onClick={() => onOpenMatrix(role)}
className="rounded-xl text-[11px] h-7 px-2.5 bg-emerald-600/15 hover:bg-emerald-600 text-emerald-700 dark:text-emerald-300 hover:text-white border border-emerald-500/25 transition-all cursor-pointer font-medium"
>
<Sparkles className="h-3 w-3 mr-1" />
{t.roles.actions.matrix}
</Button>
)}
{canEdit && (
<button
type="button"
onClick={() => onEdit(role)}
className="p-1.5 rounded-xl text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
title={t.roles.actions.edit}
>
<Edit2 className="h-3.5 w-3.5" />
</button>
)}
{canDelete && (
<button
type="button"
disabled={isProtected}
onClick={() => onDelete(role)}
title={
role.isSystem
? t.roles.dialogDelete.isSystemWarning
: userCount > 0
? t.roles.dialogDelete.hasUsersWarning.replace(
"{count}",
String(userCount)
)
: t.roles.actions.delete
}
className={`p-1.5 rounded-xl transition-colors ${
isProtected
? "text-muted-foreground/40 cursor-not-allowed"
: "text-muted-foreground hover:bg-red-500/10 hover:text-red-500 cursor-pointer"
}`}
>
{role.isSystem ? (
<Lock className="h-3.5 w-3.5" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
"use client";
import React, { useState, useMemo, useEffect } from "react";
import { X, Users, Search, AlertCircle, ShieldCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useLanguage } from "@/providers/language-provider";
import { RoleItem } from "@/types/role";
import { useRoleUsers } from "@/hooks/use-roles";
interface RoleUsersModalProps {
isOpen: boolean;
onClose: () => void;
role: RoleItem | null;
}
export function RoleUsersModal({
isOpen,
onClose,
role,
}: RoleUsersModalProps) {
const { t } = useLanguage();
const [search, setSearch] = useState("");
const { data: users = [], isLoading, isError } = useRoleUsers(
role?.id || "",
{ enabled: isOpen && !!role }
);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen) {
onClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
const filteredUsers = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return users;
return users.filter(
(u) =>
u.email.toLowerCase().includes(q) ||
(u.fullName && u.fullName.toLowerCase().includes(q))
);
}, [users, search]);
if (!isOpen || !role) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm animate-in fade-in-0 duration-200">
<div className="relative w-full max-w-xl max-h-[85vh] flex flex-col rounded-3xl border border-emerald-500/20 bg-card p-6 shadow-2xl shadow-emerald-950/20 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between pb-4 border-b border-border/60 shrink-0">
<div className="flex items-center gap-3 min-w-0">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20 shrink-0">
<Users className="h-5 w-5" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h2 className="text-lg font-bold tracking-tight text-foreground truncate">
{t.roles.modalUsers.title}
</h2>
<span className="rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 text-xs px-2.5 py-0.5 font-bold border border-emerald-500/20">
{role.name}
</span>
</div>
<p className="text-xs text-muted-foreground truncate pt-0.5">
{t.roles.modalUsers.subtitle}
</p>
</div>
</div>
<button
onClick={onClose}
className="rounded-xl p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer shrink-0"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Search */}
{users.length > 5 && (
<div className="pt-4 shrink-0">
<div className="relative">
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Tìm kiếm thành viên theo tên hoặc email..."
className="pl-9 h-9 text-xs rounded-2xl border-border/80 bg-muted/30 focus:border-emerald-500"
/>
</div>
</div>
)}
{/* Users List */}
<div className="flex-1 overflow-y-auto py-4 space-y-2 pr-1">
{isLoading ? (
<div className="space-y-2 py-4">
{[...Array(3)].map((_, i) => (
<div
key={i}
className="h-14 rounded-2xl bg-muted/50 animate-pulse border border-border/40"
/>
))}
</div>
) : isError ? (
<div className="p-8 text-center text-xs text-red-500">
<AlertCircle className="h-5 w-5 mx-auto mb-1" />
Không thể tải danh sách người dùng của vai trò này.
</div>
) : filteredUsers.length === 0 ? (
<div className="p-8 text-center text-xs text-muted-foreground rounded-2xl border border-dashed border-border/70">
{t.roles.modalUsers.empty}
</div>
) : (
filteredUsers.map((u) => {
const initials = (u.fullName || u.email)
.substring(0, 2)
.toUpperCase();
return (
<div
key={u.id}
className="flex items-center justify-between p-3 rounded-2xl border border-border/70 bg-card/60 hover:bg-muted/30 transition-colors gap-3"
>
<div className="flex items-center gap-3 min-w-0">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-500 to-teal-700 text-white font-bold text-xs shrink-0 shadow-2xs">
{initials}
</div>
<div className="flex flex-col min-w-0">
<span className="font-semibold text-xs text-foreground truncate">
{u.fullName || "Người dùng"}
</span>
<span className="text-[11px] text-muted-foreground font-mono truncate">
{u.email}
</span>
</div>
</div>
<span
className={`rounded-full px-2.5 py-0.5 text-[10px] font-semibold border shrink-0 ${
u.isActive
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
: "bg-muted text-muted-foreground border-border"
}`}
>
{u.isActive ? "Hoạt động" : "Bị khóa"}
</span>
</div>
);
})
)}
</div>
{/* Footer */}
<div className="pt-3 border-t border-border/60 flex items-center justify-between shrink-0">
<span className="text-xs text-muted-foreground">
Tổng cộng: <strong className="text-foreground">{users.length}</strong> thành viên
</span>
<Button
type="button"
variant="outline"
onClick={onClose}
className="rounded-2xl text-xs cursor-pointer"
>
{t.roles.modalUsers.close}
</Button>
</div>
</div>
</div>
);
}
"use client";
import React, { useState, useMemo, useRef, useEffect } from "react";
import {
Shield,
ShieldCheck,
Plus,
Search,
SlidersHorizontal,
RotateCcw,
LayoutGrid,
Table as TableIcon,
AlertCircle,
RefreshCw,
Lock,
Sparkles,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useLanguage } from "@/providers/language-provider";
import { useAuth } from "@/hooks/use-auth";
import { RoleItem } from "@/types/role";
import { useRolesList } from "@/hooks/use-roles";
import { usePermissionsList } from "@/hooks/use-permissions";
import { RoleCard } from "./role-card";
import { RoleTable } from "./role-table";
import { RoleFormModal } from "./role-form-modal";
import { PermissionMatrixDialog } from "./permission-matrix-dialog";
import { RoleUsersModal } from "./role-users-modal";
import { RoleDeleteDialog } from "./role-delete-dialog";
interface RolesManagementViewProps {
showHeader?: boolean;
}
export function RolesManagementView({
showHeader = true,
}: RolesManagementViewProps) {
const { t } = useLanguage();
const { role: userRole, hasPermission } = useAuth();
const canCreateRole = hasPermission("roles.create") || userRole === "ADMIN";
// Filters & State
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [selectedStatus, setSelectedStatus] = useState<
"ALL" | "ACTIVE" | "INACTIVE"
>("ALL");
const [selectedType, setSelectedType] = useState<
"ALL" | "SYSTEM" | "CUSTOM"
>("ALL");
const [viewMode, setViewMode] = useState<"grid" | "table">("grid");
// Dropdown states
const [isStatusOpen, setIsStatusOpen] = useState(false);
const [isTypeOpen, setIsTypeOpen] = useState(false);
const statusRef = useRef<HTMLDivElement>(null);
const typeRef = useRef<HTMLDivElement>(null);
// Modals state
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [editingRole, setEditingRole] = useState<RoleItem | null>(null);
const [matrixRole, setMatrixRole] = useState<RoleItem | null>(null);
const [usersModalRole, setUsersModalRole] = useState<RoleItem | null>(null);
const [deletingRole, setDeletingRole] = useState<RoleItem | null>(null);
// Debounce search
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(search.trim());
}, 300);
return () => clearTimeout(timer);
}, [search]);
// Click outside dropdowns
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (statusRef.current && !statusRef.current.contains(e.target as Node)) {
setIsStatusOpen(false);
}
if (typeRef.current && !typeRef.current.contains(e.target as Node)) {
setIsTypeOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
// Fetch Roles: GET /roles
const {
data: rolesResponse,
isLoading: isRolesLoading,
isError: isRolesError,
refetch: refetchRoles,
} = useRolesList({
search: debouncedSearch || undefined,
isActive:
selectedStatus === "ACTIVE"
? true
: selectedStatus === "INACTIVE"
? false
: undefined,
isSystem:
selectedType === "SYSTEM"
? true
: selectedType === "CUSTOM"
? false
: undefined,
limit: 100,
});
// Fetch Permissions Catalog for overall count
const { data: allPermissions = [] } = usePermissionsList();
const roles = rolesResponse?.items || [];
// Stats calculation
const stats = useMemo(() => {
const total = roles.length;
const system = roles.filter((r) => r.isSystem).length;
const custom = total - system;
const totalPermissions = allPermissions.length;
return { total, system, custom, totalPermissions };
}, [roles, allPermissions]);
const handleResetFilters = () => {
setSearch("");
setDebouncedSearch("");
setSelectedStatus("ALL");
setSelectedType("ALL");
};
const hasActiveFilters =
debouncedSearch || selectedStatus !== "ALL" || selectedType !== "ALL";
return (
<div className="space-y-8 animate-in fade-in-50 duration-200">
{/* Header Section */}
{showHeader && (
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="space-y-1">
<div className="inline-flex items-center gap-2 rounded-full bg-emerald-500/10 px-3 py-1 text-xs font-semibold text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<Shield className="h-3.5 w-3.5" />
<span>{t.roles.title}</span>
</div>
<h1 className="text-2xl sm:text-3xl font-extrabold tracking-tight text-foreground">
{t.roles.title}
</h1>
<p className="text-sm text-muted-foreground max-w-2xl">
{t.roles.subtitle}
</p>
</div>
{canCreateRole && (
<Button
onClick={() => setIsCreateOpen(true)}
className="rounded-2xl h-10 px-4 bg-emerald-600 hover:bg-emerald-700 text-white shadow-sm shadow-emerald-600/20 cursor-pointer self-start sm:self-auto font-semibold"
>
<Plus className="h-4 w-4 mr-1.5" />
{t.roles.createBtn}
</Button>
)}
</div>
)}
{/* KPI Stats Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Total Roles */}
<div className="rounded-3xl border border-emerald-500/15 bg-card/80 p-5 shadow-2xs space-y-1">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-muted-foreground">
{t.roles.stats.total}
</span>
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
<Shield className="h-4 w-4" />
</div>
</div>
<p className="text-2xl font-black tracking-tight text-foreground">
{stats.total}
</p>
<p className="text-[11px] text-muted-foreground">
{t.roles.stats.totalDesc}
</p>
</div>
{/* System Roles */}
<div className="rounded-3xl border border-emerald-500/15 bg-card/80 p-5 shadow-2xs space-y-1">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-muted-foreground">
{t.roles.stats.system}
</span>
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
<ShieldCheck className="h-4 w-4" />
</div>
</div>
<p className="text-2xl font-black tracking-tight text-foreground">
{stats.system}
</p>
<p className="text-[11px] text-muted-foreground">
{t.roles.stats.systemDesc}
</p>
</div>
{/* Custom Roles */}
<div className="rounded-3xl border border-teal-500/15 bg-card/80 p-5 shadow-2xs space-y-1">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-muted-foreground">
{t.roles.stats.custom}
</span>
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-teal-500/10 text-teal-600 dark:text-teal-400">
<Sparkles className="h-4 w-4" />
</div>
</div>
<p className="text-2xl font-black tracking-tight text-foreground">
{stats.custom}
</p>
<p className="text-[11px] text-muted-foreground">
{t.roles.stats.customDesc}
</p>
</div>
{/* Total Permissions */}
<div className="rounded-3xl border border-purple-500/15 bg-card/80 p-5 shadow-2xs space-y-1">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-muted-foreground">
{t.roles.stats.permissions}
</span>
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-purple-500/10 text-purple-600 dark:text-purple-400">
<Lock className="h-4 w-4" />
</div>
</div>
<p className="text-2xl font-black tracking-tight text-foreground">
{stats.totalPermissions}
</p>
<p className="text-[11px] text-muted-foreground">
{t.roles.stats.permissionsDesc}
</p>
</div>
</div>
{/* Filter & Toolbar */}
<div className="rounded-3xl border border-emerald-500/15 bg-card/80 p-4 space-y-3 shadow-xs">
<div className="flex flex-col md:flex-row items-stretch md:items-center justify-between gap-3">
{/* Search Input */}
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t.roles.filters.searchPlaceholder}
className="pl-9 h-10 text-xs rounded-2xl border-border/80 bg-muted/30 focus:border-emerald-500"
/>
</div>
{/* Filter Dropdowns & View Mode Toggle */}
<div className="flex items-center gap-2.5 flex-wrap">
{/* Status Dropdown */}
<div className="relative" ref={statusRef}>
<Button
variant="outline"
size="sm"
onClick={() => setIsStatusOpen(!isStatusOpen)}
className="h-10 rounded-2xl border-border/80 text-xs font-medium cursor-pointer"
>
<SlidersHorizontal className="h-3.5 w-3.5 mr-1.5 text-emerald-500" />
{selectedStatus === "ALL"
? t.roles.filters.allStatus
: selectedStatus === "ACTIVE"
? t.roles.filters.statusActive
: t.roles.filters.statusInactive}
</Button>
{isStatusOpen && (
<div className="absolute right-0 mt-1.5 w-44 rounded-2xl border border-emerald-500/20 bg-card p-1.5 shadow-xl z-20 animate-in fade-in-50 zoom-in-95 duration-150">
<button
type="button"
onClick={() => {
setSelectedStatus("ALL");
setIsStatusOpen(false);
}}
className={`w-full text-left rounded-xl px-3 py-2 text-xs font-medium transition-colors cursor-pointer ${
selectedStatus === "ALL"
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: "hover:bg-muted text-foreground"
}`}
>
{t.roles.filters.allStatus}
</button>
<button
type="button"
onClick={() => {
setSelectedStatus("ACTIVE");
setIsStatusOpen(false);
}}
className={`w-full text-left rounded-xl px-3 py-2 text-xs font-medium transition-colors cursor-pointer ${
selectedStatus === "ACTIVE"
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: "hover:bg-muted text-foreground"
}`}
>
{t.roles.filters.statusActive}
</button>
<button
type="button"
onClick={() => {
setSelectedStatus("INACTIVE");
setIsStatusOpen(false);
}}
className={`w-full text-left rounded-xl px-3 py-2 text-xs font-medium transition-colors cursor-pointer ${
selectedStatus === "INACTIVE"
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: "hover:bg-muted text-foreground"
}`}
>
{t.roles.filters.statusInactive}
</button>
</div>
)}
</div>
{/* Type Dropdown */}
<div className="relative" ref={typeRef}>
<Button
variant="outline"
size="sm"
onClick={() => setIsTypeOpen(!isTypeOpen)}
className="h-10 rounded-2xl border-border/80 text-xs font-medium cursor-pointer"
>
<Shield className="h-3.5 w-3.5 mr-1.5 text-emerald-500" />
{selectedType === "ALL"
? t.roles.filters.allTypes
: selectedType === "SYSTEM"
? t.roles.filters.typeSystem
: t.roles.filters.typeCustom}
</Button>
{isTypeOpen && (
<div className="absolute right-0 mt-1.5 w-44 rounded-2xl border border-emerald-500/20 bg-card p-1.5 shadow-xl z-20 animate-in fade-in-50 zoom-in-95 duration-150">
<button
type="button"
onClick={() => {
setSelectedType("ALL");
setIsTypeOpen(false);
}}
className={`w-full text-left rounded-xl px-3 py-2 text-xs font-medium transition-colors cursor-pointer ${
selectedType === "ALL"
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: "hover:bg-muted text-foreground"
}`}
>
{t.roles.filters.allTypes}
</button>
<button
type="button"
onClick={() => {
setSelectedType("SYSTEM");
setIsTypeOpen(false);
}}
className={`w-full text-left rounded-xl px-3 py-2 text-xs font-medium transition-colors cursor-pointer ${
selectedType === "SYSTEM"
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: "hover:bg-muted text-foreground"
}`}
>
{t.roles.filters.typeSystem}
</button>
<button
type="button"
onClick={() => {
setSelectedType("CUSTOM");
setIsTypeOpen(false);
}}
className={`w-full text-left rounded-xl px-3 py-2 text-xs font-medium transition-colors cursor-pointer ${
selectedType === "CUSTOM"
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: "hover:bg-muted text-foreground"
}`}
>
{t.roles.filters.typeCustom}
</button>
</div>
)}
</div>
{/* View Mode Toggle */}
<div className="flex items-center rounded-2xl border border-border/80 bg-muted/40 p-1">
<button
type="button"
onClick={() => setViewMode("grid")}
title={t.roles.filters.viewCards}
className={`p-1.5 rounded-xl transition-colors cursor-pointer ${
viewMode === "grid"
? "bg-card text-emerald-600 dark:text-emerald-400 shadow-2xs font-bold"
: "text-muted-foreground hover:text-foreground"
}`}
>
<LayoutGrid className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => setViewMode("table")}
title={t.roles.filters.viewTable}
className={`p-1.5 rounded-xl transition-colors cursor-pointer ${
viewMode === "table"
? "bg-card text-emerald-600 dark:text-emerald-400 shadow-2xs font-bold"
: "text-muted-foreground hover:text-foreground"
}`}
>
<TableIcon className="h-4 w-4" />
</button>
</div>
{/* Reset Filter Button */}
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
onClick={handleResetFilters}
className="h-10 rounded-2xl text-xs text-muted-foreground hover:text-foreground cursor-pointer"
>
<RotateCcw className="h-3.5 w-3.5 mr-1" />
Đặt lại
</Button>
)}
</div>
</div>
</div>
{/* Main Content Area */}
{isRolesLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{[...Array(6)].map((_, i) => (
<div
key={i}
className="h-48 rounded-3xl bg-muted/40 animate-pulse border border-border/40"
/>
))}
</div>
) : isRolesError ? (
<div className="p-12 text-center rounded-3xl border border-red-500/20 bg-card/60 space-y-3">
<AlertCircle className="h-8 w-8 mx-auto text-red-500" />
<h4 className="text-sm font-bold text-foreground">
{t.roles.table.errorTitle}
</h4>
<Button
size="sm"
onClick={() => refetchRoles()}
className="rounded-2xl text-xs bg-emerald-600 hover:bg-emerald-700 text-white cursor-pointer"
>
<RefreshCw className="h-3.5 w-3.5 mr-1" />
{t.roles.table.retry}
</Button>
</div>
) : roles.length === 0 ? (
<div className="p-14 text-center rounded-3xl border border-dashed border-border/80 bg-card/40 space-y-3">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
<Shield className="h-6 w-6" />
</div>
<h4 className="text-sm font-bold text-foreground">
{t.roles.table.emptyTitle}
</h4>
<p className="text-xs text-muted-foreground max-w-sm mx-auto">
{t.roles.table.emptyDesc}
</p>
{canCreateRole && (
<Button
size="sm"
onClick={() => setIsCreateOpen(true)}
className="rounded-2xl text-xs bg-emerald-600 hover:bg-emerald-700 text-white cursor-pointer"
>
<Plus className="h-3.5 w-3.5 mr-1" />
{t.roles.table.emptyBtn}
</Button>
)}
</div>
) : viewMode === "grid" ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{roles.map((role) => (
<RoleCard
key={role.id}
role={role}
onEdit={(r) => setEditingRole(r)}
onOpenMatrix={(r) => setMatrixRole(r)}
onViewUsers={(r) => setUsersModalRole(r)}
onDelete={(r) => setDeletingRole(r)}
/>
))}
</div>
) : (
<RoleTable
roles={roles}
onEdit={(r) => setEditingRole(r)}
onOpenMatrix={(r) => setMatrixRole(r)}
onViewUsers={(r) => setUsersModalRole(r)}
onDelete={(r) => setDeletingRole(r)}
/>
)}
{/* Modals mounted conditionally */}
{isCreateOpen && (
<RoleFormModal
isOpen={isCreateOpen}
onClose={() => setIsCreateOpen(false)}
/>
)}
{editingRole && (
<RoleFormModal
isOpen={!!editingRole}
onClose={() => setEditingRole(null)}
roleToEdit={editingRole}
/>
)}
{matrixRole && (
<PermissionMatrixDialog
isOpen={!!matrixRole}
onClose={() => setMatrixRole(null)}
role={matrixRole}
/>
)}
{usersModalRole && (
<RoleUsersModal
isOpen={!!usersModalRole}
onClose={() => setUsersModalRole(null)}
role={usersModalRole}
/>
)}
{deletingRole && (
<RoleDeleteDialog
isOpen={!!deletingRole}
onClose={() => setDeletingRole(null)}
role={deletingRole}
/>
)}
</div>
);
}
"use client";
import React, { useState, useEffect, useRef } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
X,
UserPlus,
Mail,
Lock,
User,
Shield,
Sliders,
ChevronDown,
Check,
Eye,
EyeOff,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useLanguage } from "@/providers/language-provider";
import {
createUserSchema,
CreateUserInput,
} from "@/schemas/user.schema";
import { useCreateUser } from "@/hooks/use-users";
interface UserCreateModalProps {
isOpen: boolean;
onClose: () => void;
}
const AVAILABLE_ROLES = [
{
value: "CRAWLER_USER",
label: "Người dùng Crawler",
labelEn: "Crawler User",
desc: "Được tạo tác vụ, lập lịch và bóc tách dữ liệu",
descEn: "Can create tasks, schedules and scrape data",
color: "text-blue-500",
},
{
value: "ADMIN",
label: "Quản trị viên",
labelEn: "Administrator",
desc: "Toàn quyền quản trị hệ thống, thành viên và phân quyền",
descEn: "Full access to system, members and permissions",
color: "text-emerald-500",
},
{
value: "VIEWER",
label: "Người xem",
labelEn: "Viewer",
desc: "Chỉ được xem dữ liệu, không có quyền chỉnh sửa",
descEn: "Read-only access, cannot modify data",
color: "text-muted-foreground",
},
];
export function UserCreateModal({ isOpen, onClose }: UserCreateModalProps) {
const { t, locale } = useLanguage();
const createMutation = useCreateUser();
const [showPassword, setShowPassword] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
const [isRoleDropdownOpen, setIsRoleDropdownOpen] = useState(false);
const roleDropdownRef = useRef<HTMLDivElement>(null);
const {
register,
handleSubmit,
setValue,
watch,
reset,
formState: { errors },
} = useForm<CreateUserInput>({
resolver: zodResolver(createUserSchema),
defaultValues: {
fullName: "",
email: "",
password: "",
role: "CRAWLER_USER",
maxPagesLimit: 1000,
maxJobsPerDayLimit: 50,
maxConcurrentJobsLimit: 5,
},
});
const selectedRole = watch("role");
// Reset form when modal opens
useEffect(() => {
if (isOpen) {
reset({
fullName: "",
email: "",
password: "",
role: "CRAWLER_USER",
maxPagesLimit: 1000,
maxJobsPerDayLimit: 50,
maxConcurrentJobsLimit: 5,
});
setShowAdvanced(false);
setShowPassword(false);
}
}, [isOpen, reset]);
// Handle escape key and click outside dropdown
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen) {
if (isRoleDropdownOpen) {
setIsRoleDropdownOpen(false);
} else {
onClose();
}
}
};
function handleClickOutside(e: MouseEvent) {
if (
roleDropdownRef.current &&
!roleDropdownRef.current.contains(e.target as Node)
) {
setIsRoleDropdownOpen(false);
}
}
window.addEventListener("keydown", handleKeyDown);
document.addEventListener("mousedown", handleClickOutside);
return () => {
window.removeEventListener("keydown", handleKeyDown);
document.removeEventListener("mousedown", handleClickOutside);
};
}, [isOpen, isRoleDropdownOpen, onClose]);
if (!isOpen) return null;
const onSubmit = async (data: CreateUserInput) => {
await createMutation.mutateAsync({
email: data.email.trim(),
password: data.password,
fullName: data.fullName?.trim() || undefined,
role: data.role,
maxPagesLimit: data.maxPagesLimit,
maxJobsPerDayLimit: data.maxJobsPerDayLimit,
maxConcurrentJobsLimit: data.maxConcurrentJobsLimit,
});
onClose();
};
const currentRoleObj =
AVAILABLE_ROLES.find((r) => r.value === selectedRole) || AVAILABLE_ROLES[0];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm animate-in fade-in-0 duration-200">
<div className="relative w-full max-w-lg max-h-[90vh] flex flex-col rounded-3xl border border-emerald-500/20 bg-card p-6 shadow-2xl shadow-emerald-950/20 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between pb-4 border-b border-border/60">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<UserPlus className="h-5 w-5" />
</div>
<div>
<h2 className="text-lg font-bold tracking-tight text-foreground">
{t.users.modalCreate.title}
</h2>
<p className="text-xs text-muted-foreground">
{t.users.modalCreate.desc}
</p>
</div>
</div>
<button
onClick={onClose}
className="rounded-xl p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Form Body */}
<form
onSubmit={handleSubmit(onSubmit)}
className="flex-1 overflow-y-auto py-4 space-y-4 pr-1"
>
{/* Họ và tên */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-foreground flex items-center gap-1.5">
<User className="h-3.5 w-3.5 text-emerald-500" />
{t.users.modalCreate.fullName}
</Label>
<Input
{...register("fullName")}
placeholder={t.users.modalCreate.fullNamePlaceholder}
className="rounded-2xl border-border/80 text-xs bg-muted/30 focus:ring-emerald-500/20"
/>
{errors.fullName && (
<p className="text-[11px] text-red-500">{errors.fullName.message}</p>
)}
</div>
{/* Email */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-foreground flex items-center gap-1.5">
<Mail className="h-3.5 w-3.5 text-emerald-500" />
{t.users.modalCreate.email} <span className="text-red-500">*</span>
</Label>
<Input
type="email"
{...register("email")}
placeholder={t.users.modalCreate.emailPlaceholder}
className="rounded-2xl border-border/80 text-xs bg-muted/30 focus:ring-emerald-500/20 font-mono"
/>
{errors.email && (
<p className="text-[11px] text-red-500">{errors.email.message}</p>
)}
</div>
{/* Mật khẩu khởi tạo */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-foreground flex items-center gap-1.5">
<Lock className="h-3.5 w-3.5 text-emerald-500" />
{t.users.modalCreate.password} <span className="text-red-500">*</span>
</Label>
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
{...register("password")}
placeholder={t.users.modalCreate.passwordPlaceholder}
className="rounded-2xl border-border/80 text-xs bg-muted/30 focus:ring-emerald-500/20 pr-10 font-mono"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground cursor-pointer"
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
{errors.password && (
<p className="text-[11px] text-red-500">{errors.password.message}</p>
)}
</div>
{/* Chọn Vai trò ban đầu (Custom Dropdown ThemeToggle Pattern) */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-foreground flex items-center gap-1.5">
<Shield className="h-3.5 w-3.5 text-emerald-500" />
{t.users.modalCreate.role}
</Label>
<div className="relative" ref={roleDropdownRef}>
<button
type="button"
onClick={() => setIsRoleDropdownOpen(!isRoleDropdownOpen)}
className="w-full flex items-center justify-between rounded-2xl border border-border/80 bg-muted/30 px-3.5 py-2.5 text-xs text-foreground hover:bg-muted/50 transition-colors cursor-pointer focus:outline-none focus:ring-2 focus:ring-emerald-500/20"
>
<div className="flex items-center gap-2">
<span
className={`h-2 w-2 rounded-full ${
selectedRole === "ADMIN"
? "bg-emerald-500"
: selectedRole === "CRAWLER_USER"
? "bg-blue-500"
: "bg-muted-foreground"
}`}
/>
<span className="font-semibold">
{locale === "vi"
? currentRoleObj.label
: currentRoleObj.labelEn}
</span>
</div>
<ChevronDown
className={`h-4 w-4 text-muted-foreground transition-transform duration-200 ${
isRoleDropdownOpen ? "rotate-180" : ""
}`}
/>
</button>
{/* Menu Popup */}
{isRoleDropdownOpen && (
<div className="absolute left-0 right-0 top-full mt-1.5 rounded-2xl border border-emerald-500/20 bg-card/95 p-1.5 shadow-xl shadow-emerald-950/10 backdrop-blur-xl z-50 animate-in fade-in-50 zoom-in-95 duration-150 space-y-1">
{AVAILABLE_ROLES.map((role) => {
const isSelected = selectedRole === role.value;
return (
<button
key={role.value}
type="button"
onClick={() => {
setValue(
"role",
role.value as "ADMIN" | "CRAWLER_USER" | "VIEWER"
);
setIsRoleDropdownOpen(false);
}}
className={`w-full flex items-center justify-between rounded-xl px-3 py-2 text-xs transition-colors cursor-pointer text-left ${
isSelected
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 font-semibold border border-emerald-500/20"
: "text-foreground hover:bg-muted/60"
}`}
>
<div className="flex flex-col">
<span className="font-semibold">
{locale === "vi" ? role.label : role.labelEn}
</span>
<span className="text-[10px] text-muted-foreground">
{locale === "vi" ? role.desc : role.descEn}
</span>
</div>
{isSelected && (
<Check className="h-4 w-4 text-emerald-600 dark:text-emerald-400 shrink-0" />
)}
</button>
);
})}
</div>
)}
</div>
</div>
{/* Tùy chọn nâng cao (Hạn mức tài nguyên) */}
<div className="pt-2">
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex items-center gap-1.5 text-xs font-semibold text-emerald-600 dark:text-emerald-400 hover:underline cursor-pointer"
>
<Sliders className="h-3.5 w-3.5" />
<span>{t.users.modalCreate.advanced}</span>
<ChevronDown
className={`h-3 w-3 transition-transform ${
showAdvanced ? "rotate-180" : ""
}`}
/>
</button>
{showAdvanced && (
<div className="mt-3 p-3.5 rounded-2xl border border-emerald-500/15 bg-emerald-500/5 space-y-3 animate-in fade-in-50 duration-150">
<div className="space-y-1">
<Label className="text-[11px] text-muted-foreground">
{t.users.modalCreate.maxPages}
</Label>
<Input
type="number"
{...register("maxPagesLimit", { valueAsNumber: true })}
className="rounded-xl border-border/80 text-xs bg-background/80"
/>
{errors.maxPagesLimit && (
<p className="text-[10px] text-red-500">
{errors.maxPagesLimit.message}
</p>
)}
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-[11px] text-muted-foreground">
{t.users.modalCreate.maxJobsPerDay}
</Label>
<Input
type="number"
{...register("maxJobsPerDayLimit", { valueAsNumber: true })}
className="rounded-xl border-border/80 text-xs bg-background/80"
/>
</div>
<div className="space-y-1">
<Label className="text-[11px] text-muted-foreground">
{t.users.modalCreate.maxConcurrentJobs}
</Label>
<Input
type="number"
{...register("maxConcurrentJobsLimit", { valueAsNumber: true })}
className="rounded-xl border-border/80 text-xs bg-background/80"
/>
</div>
</div>
</div>
)}
</div>
{/* Footer Buttons */}
<div className="pt-4 flex items-center justify-end gap-2.5 border-t border-border/60">
<Button
type="button"
variant="outline"
onClick={onClose}
className="rounded-2xl text-xs cursor-pointer"
>
{t.users.modalCreate.cancel}
</Button>
<Button
type="submit"
disabled={createMutation.isPending}
className="rounded-2xl bg-emerald-600 hover:bg-emerald-700 text-white text-xs font-semibold px-5 shadow-md shadow-emerald-600/20 cursor-pointer transition-all hover:scale-[1.02]"
>
{createMutation.isPending
? t.users.modalCreate.submitting
: t.users.modalCreate.submit}
</Button>
</div>
</form>
</div>
</div>
);
}
"use client";
import React, { useEffect } from "react";
import { AlertTriangle, Trash2, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useLanguage } from "@/providers/language-provider";
import { UserItem } from "@/types/user";
import { useDeleteUser } from "@/hooks/use-users";
interface UserDeleteDialogProps {
isOpen: boolean;
onClose: () => void;
user: UserItem | null;
}
export function UserDeleteDialog({
isOpen,
onClose,
user,
}: UserDeleteDialogProps) {
const { t } = useLanguage();
const deleteMutation = useDeleteUser();
// Handle escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen) {
onClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen || !user) return null;
const handleConfirmDelete = async () => {
await deleteMutation.mutateAsync(user.id);
onClose();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm animate-in fade-in-0 duration-200">
<div className="relative w-full max-w-md flex flex-col rounded-3xl border border-red-500/30 bg-card p-6 shadow-2xl shadow-red-950/20 overflow-hidden">
{/* Close Button */}
<button
onClick={onClose}
className="absolute right-4 top-4 rounded-xl p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
>
<X className="h-4 w-4" />
</button>
{/* Warning Icon */}
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-red-500/10 text-red-600 dark:text-red-400 border border-red-500/20 mb-4">
<AlertTriangle className="h-7 w-7" />
</div>
{/* Title & Desc */}
<div className="text-center space-y-2">
<h2 className="text-lg font-bold tracking-tight text-foreground">
{t.users.dialogDelete.title}
</h2>
<p className="text-xs text-muted-foreground leading-relaxed">
{t.users.dialogDelete.desc}
</p>
</div>
{/* Target User Card */}
<div className="my-4 p-3 rounded-2xl border border-red-500/20 bg-red-500/5 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-red-500/10 text-red-600 dark:text-red-400 font-bold text-xs border border-red-500/20 shrink-0">
{user.fullName?.[0] || user.email[0].toUpperCase()}
</div>
<div className="flex flex-col min-w-0 text-left">
<span className="font-semibold text-xs text-foreground truncate">
{user.fullName || "Người dùng"}
</span>
<span className="text-[11px] text-muted-foreground font-mono truncate">
{user.email}
</span>
</div>
</div>
{/* Footer Action Buttons */}
<div className="pt-2 flex items-center justify-end gap-2.5">
<Button
type="button"
variant="outline"
onClick={onClose}
className="rounded-2xl text-xs cursor-pointer flex-1"
>
{t.users.dialogDelete.cancel}
</Button>
<Button
type="button"
disabled={deleteMutation.isPending}
onClick={handleConfirmDelete}
className="rounded-2xl bg-red-600 hover:bg-red-700 text-white text-xs font-semibold px-4 shadow-md shadow-red-600/20 cursor-pointer transition-all hover:scale-[1.02] flex-1"
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
{deleteMutation.isPending
? t.users.dialogDelete.deleting
: t.users.dialogDelete.confirm}
</Button>
</div>
</div>
</div>
);
}
"use client";
import React, { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
X,
Edit3,
Mail,
User,
Sliders,
Lock,
Unlock,
AlertTriangle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useLanguage } from "@/providers/language-provider";
import { updateUserSchema, UpdateUserInput } from "@/schemas/user.schema";
import { useUpdateUser } from "@/hooks/use-users";
import { UserItem } from "@/types/user";
interface UserEditModalProps {
isOpen: boolean;
onClose: () => void;
user: UserItem | null;
currentUserId?: string;
}
export function UserEditModal({
isOpen,
onClose,
user,
currentUserId,
}: UserEditModalProps) {
const { t } = useLanguage();
const updateMutation = useUpdateUser();
const [showAdvanced, setShowAdvanced] = useState(false);
const {
register,
handleSubmit,
setValue,
watch,
reset,
formState: { errors },
} = useForm<UpdateUserInput>({
resolver: zodResolver(updateUserSchema),
defaultValues: {
fullName: "",
email: "",
isActive: true,
maxPagesLimit: 1000,
maxJobsPerDayLimit: 50,
maxConcurrentJobsLimit: 5,
},
});
const isActive = watch("isActive");
const isSelf = currentUserId === user?.id;
// Sync user data to form on open
useEffect(() => {
if (user && isOpen) {
reset({
fullName: user.fullName || "",
email: user.email || "",
isActive: user.isActive,
role: user.role as "ADMIN" | "CRAWLER_USER" | "VIEWER",
maxPagesLimit: user.maxPagesLimit,
maxJobsPerDayLimit: user.maxJobsPerDayLimit,
maxConcurrentJobsLimit: user.maxConcurrentJobsLimit,
});
setShowAdvanced(false);
}
}, [user, isOpen, reset]);
// Handle escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen) {
onClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen || !user) return null;
const onSubmit = async (data: UpdateUserInput) => {
await updateMutation.mutateAsync({
id: user.id,
dto: {
fullName: data.fullName?.trim() || undefined,
email: data.email?.trim() || undefined,
isActive: data.isActive,
role: data.role,
maxPagesLimit: data.maxPagesLimit,
maxJobsPerDayLimit: data.maxJobsPerDayLimit,
maxConcurrentJobsLimit: data.maxConcurrentJobsLimit,
},
});
onClose();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm animate-in fade-in-0 duration-200">
<div className="relative w-full max-w-lg max-h-[90vh] flex flex-col rounded-3xl border border-emerald-500/20 bg-card p-6 shadow-2xl shadow-emerald-950/20 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between pb-4 border-b border-border/60">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-teal-500/10 text-teal-600 dark:text-teal-400 border border-teal-500/20">
<Edit3 className="h-5 w-5" />
</div>
<div>
<h2 className="text-lg font-bold tracking-tight text-foreground">
{t.users.modalEdit.title}
</h2>
<p className="text-xs text-muted-foreground">
{t.users.modalEdit.desc}
</p>
</div>
</div>
<button
onClick={onClose}
className="rounded-xl p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Form Body */}
<form
onSubmit={handleSubmit(onSubmit)}
className="flex-1 overflow-y-auto py-4 space-y-4 pr-1"
>
{/* Họ và tên */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-foreground flex items-center gap-1.5">
<User className="h-3.5 w-3.5 text-emerald-500" />
{t.users.modalEdit.fullName}
</Label>
<Input
{...register("fullName")}
placeholder={t.users.modalEdit.fullNamePlaceholder}
className="rounded-2xl border-border/80 text-xs bg-muted/30 focus:ring-emerald-500/20"
/>
{errors.fullName && (
<p className="text-[11px] text-red-500">{errors.fullName.message}</p>
)}
</div>
{/* Email */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-foreground flex items-center gap-1.5">
<Mail className="h-3.5 w-3.5 text-emerald-500" />
{t.users.modalEdit.email}
</Label>
<Input
type="email"
{...register("email")}
placeholder={t.users.modalEdit.emailPlaceholder}
className="rounded-2xl border-border/80 text-xs bg-muted/30 focus:ring-emerald-500/20 font-mono"
/>
{errors.email && (
<p className="text-[11px] text-red-500">{errors.email.message}</p>
)}
</div>
{/* Công tắc (Switch) Khóa / Mở khóa tài khoản */}
<div className="space-y-2 pt-1">
<Label className="text-xs font-semibold text-foreground">
{t.users.modalEdit.statusTitle}
</Label>
<div
className={`flex items-center justify-between p-3.5 rounded-2xl border transition-all duration-200 ${
isActive
? "border-emerald-500/25 bg-emerald-500/5 shadow-xs"
: "border-amber-500/30 bg-amber-500/5 shadow-xs"
}`}
>
<div className="space-y-0.5 pr-3">
<div className="flex items-center gap-2">
{isActive ? (
<Unlock className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
) : (
<Lock className="h-4 w-4 text-amber-600 dark:text-amber-400" />
)}
<span
className={`font-bold text-xs ${
isActive
? "text-emerald-700 dark:text-emerald-400"
: "text-amber-700 dark:text-amber-400"
}`}
>
{isActive
? t.users.modalEdit.statusSwitchActive
: t.users.modalEdit.statusSwitchLocked}
</span>
</div>
<p className="text-[11px] text-muted-foreground leading-relaxed">
{isActive
? t.users.modalEdit.statusActiveDesc
: t.users.modalEdit.statusLockedDesc}
</p>
</div>
{/* Switch Toggle */}
<button
type="button"
role="switch"
aria-checked={isActive}
disabled={isSelf}
onClick={() => setValue("isActive", !isActive)}
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-emerald-500/30 ${
isActive
? "bg-emerald-600 dark:bg-emerald-500"
: "bg-muted-foreground/30"
} ${isSelf ? "opacity-50 cursor-not-allowed" : ""}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow-md ring-0 transition duration-200 ease-in-out ${
isActive ? "translate-x-5" : "translate-x-0"
}`}
/>
</button>
</div>
{isSelf && (
<p className="text-[11px] text-amber-600 dark:text-amber-400 flex items-center gap-1 pt-1">
<AlertTriangle className="h-3 w-3 shrink-0" />
Bạn không thể tự khóa tài khoản quản trị hiện tại của chính mình.
</p>
)}
</div>
{/* Tùy chọn nâng cao: Hạn mức tài nguyên */}
<div className="pt-2">
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex items-center gap-1.5 text-xs font-semibold text-emerald-600 dark:text-emerald-400 hover:underline cursor-pointer"
>
<Sliders className="h-3.5 w-3.5" />
<span>{t.users.modalEdit.quotaTitle}</span>
</button>
{showAdvanced && (
<div className="mt-3 p-3.5 rounded-2xl border border-emerald-500/15 bg-emerald-500/5 space-y-3 animate-in fade-in-50 duration-150">
<div className="space-y-1">
<Label className="text-[11px] text-muted-foreground">
{t.users.modalCreate.maxPages}
</Label>
<Input
type="number"
{...register("maxPagesLimit", { valueAsNumber: true })}
className="rounded-xl border-border/80 text-xs bg-background/80"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-[11px] text-muted-foreground">
{t.users.modalCreate.maxJobsPerDay}
</Label>
<Input
type="number"
{...register("maxJobsPerDayLimit", { valueAsNumber: true })}
className="rounded-xl border-border/80 text-xs bg-background/80"
/>
</div>
<div className="space-y-1">
<Label className="text-[11px] text-muted-foreground">
{t.users.modalCreate.maxConcurrentJobs}
</Label>
<Input
type="number"
{...register("maxConcurrentJobsLimit", { valueAsNumber: true })}
className="rounded-xl border-border/80 text-xs bg-background/80"
/>
</div>
</div>
</div>
)}
</div>
{/* Footer Buttons */}
<div className="pt-4 flex items-center justify-end gap-2.5 border-t border-border/60">
<Button
type="button"
variant="outline"
onClick={onClose}
className="rounded-2xl text-xs cursor-pointer"
>
{t.users.modalEdit.cancel}
</Button>
<Button
type="submit"
disabled={updateMutation.isPending}
className="rounded-2xl bg-emerald-600 hover:bg-emerald-700 text-white text-xs font-semibold px-5 shadow-md shadow-emerald-600/20 cursor-pointer transition-all hover:scale-[1.02]"
>
{updateMutation.isPending
? t.users.modalEdit.submitting
: t.users.modalEdit.submit}
</Button>
</div>
</form>
</div>
</div>
);
}
"use client";
import React, { useState, useEffect } from "react";
import {
X,
ShieldCheck,
ShieldAlert,
Check,
Sparkles,
AlertCircle,
RefreshCw,
User,
Info,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useLanguage } from "@/providers/language-provider";
import { UserItem, RoleItem } from "@/types/user";
import { useUserRoles, useAssignUserRoles } from "@/hooks/use-users";
import { useRolesList } from "@/hooks/use-roles";
interface UserRolesModalProps {
isOpen: boolean;
onClose: () => void;
user: UserItem | null;
currentUserId?: string;
}
const ROLES_LIST_PARAMS = { limit: 100 };
export function UserRolesModal({
isOpen,
onClose,
user,
currentUserId,
}: UserRolesModalProps) {
const { t } = useLanguage();
// 1. Fetch user's active assigned roles: GET /users/:id/roles
const {
data: userRoles,
isLoading: isUserRolesLoading,
refetch: refetchUserRoles,
} = useUserRoles(user?.id || "", isOpen && !!user);
// 2. Fetch all system roles: GET /roles
const {
data: rolesResponse,
isLoading: isRolesLoading,
isError: isRolesError,
refetch: refetchRoles,
} = useRolesList(ROLES_LIST_PARAMS, { enabled: isOpen });
const allRoles: RoleItem[] = rolesResponse?.items || [];
// Mutation for syncing: PUT /users/:id/roles
const assignRolesMutation = useAssignUserRoles();
// Local state for checked role IDs
const [selectedRoleIds, setSelectedRoleIds] = useState<string[]>([]);
// Sync current user roles into local state with equality check to prevent loops
useEffect(() => {
if (!isOpen || !userRoles || !Array.isArray(userRoles)) return;
const newIds = userRoles.map((r) => r.id);
setSelectedRoleIds((prev) => {
if (
prev.length === newIds.length &&
prev.every((id) => newIds.includes(id))
) {
return prev;
}
return newIds;
});
}, [isOpen, userRoles]);
// Handle escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen) {
onClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen || !user) return null;
const isSelf = currentUserId === user.id;
const isLoading = isUserRolesLoading || isRolesLoading;
const handleToggleRole = (roleId: string) => {
if (isSelf) return; // Self-escalation guard
setSelectedRoleIds((prev) =>
prev.includes(roleId)
? prev.filter((id) => id !== roleId)
: [...prev, roleId]
);
};
const handleSave = async () => {
await assignRolesMutation.mutateAsync({
userId: user.id,
roleIds: selectedRoleIds,
});
onClose();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm animate-in fade-in-0 duration-200">
<div className="relative w-full max-w-xl max-h-[90vh] flex flex-col rounded-3xl border border-emerald-500/20 bg-card p-6 shadow-2xl shadow-emerald-950/20 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between pb-4 border-b border-border/60">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<ShieldCheck className="h-5 w-5" />
</div>
<div>
<h2 className="text-lg font-bold tracking-tight text-foreground">
{t.users.modalRoles.title}
</h2>
<p className="text-xs text-muted-foreground">
{t.users.modalRoles.desc}
</p>
</div>
</div>
<button
onClick={onClose}
className="rounded-xl p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
>
<X className="h-4 w-4" />
</button>
</div>
{/* User Info Bar */}
<div className="my-3 flex items-center justify-between p-3 rounded-2xl border border-border/70 bg-muted/40">
<div className="flex items-center gap-2.5 min-w-0">
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-500 to-teal-700 text-white font-bold text-xs shrink-0">
{user.fullName?.[0] || user.email[0].toUpperCase()}
</div>
<div className="flex flex-col min-w-0">
<span className="font-semibold text-xs text-foreground truncate">
{user.fullName || "Người dùng"}
</span>
<span className="text-[11px] text-muted-foreground font-mono truncate">
{user.email}
</span>
</div>
</div>
<div className="text-right shrink-0 pl-2">
<span className="text-[11px] text-muted-foreground block">
{t.users.modalRoles.selectedCount}
</span>
<span className="text-xs font-bold text-emerald-600 dark:text-emerald-400">
{selectedRoleIds.length} {t.users.modalRoles.roles}
</span>
</div>
</div>
{/* Self modification warning */}
{isSelf && (
<div className="mb-3 p-3 rounded-2xl border border-amber-500/30 bg-amber-500/10 flex items-start gap-2.5 text-xs text-amber-700 dark:text-amber-400">
<ShieldAlert className="h-4 w-4 shrink-0 mt-0.5" />
<span>
Để bảo đảm an toàn hệ thống, bạn không được phép tự phân quyền hoặc
thay đổi vai trò tài khoản quản trị của chính mình.
</span>
</div>
)}
{/* Roles List */}
<div className="flex-1 overflow-y-auto space-y-2 py-1 pr-1">
{isLoading ? (
<div className="space-y-2 py-4">
{[...Array(4)].map((_, i) => (
<div
key={i}
className="h-16 rounded-2xl bg-muted/50 animate-pulse border border-border/40"
/>
))}
</div>
) : isRolesError ? (
<div className="p-6 text-center text-xs text-red-500">
<AlertCircle className="h-5 w-5 mx-auto mb-1" />
Không thể tải danh sách vai trò từ máy chủ.
<Button
variant="outline"
size="sm"
onClick={() => {
refetchRoles();
refetchUserRoles();
}}
className="mt-2 text-xs rounded-xl"
>
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
{t.users.table.retry}
</Button>
</div>
) : allRoles.length === 0 ? (
<div className="p-6 text-center text-xs text-muted-foreground">
{t.users.modalRoles.noRolesAvailable}
</div>
) : (
allRoles.map((role) => {
const isChecked = selectedRoleIds.includes(role.id);
return (
<label
key={role.id}
onClick={(e) => {
e.preventDefault();
handleToggleRole(role.id);
}}
className={`flex items-start gap-3 p-3.5 rounded-2xl border transition-all duration-150 select-none ${
isSelf
? "cursor-not-allowed opacity-75"
: "cursor-pointer hover:border-emerald-500/40"
} ${
isChecked
? "border-emerald-500/40 bg-emerald-500/10 shadow-xs"
: "border-border/70 bg-card/60 hover:bg-muted/40"
}`}
>
{/* Custom Checkbox */}
<div
className={`flex h-5 w-5 items-center justify-center rounded-lg border mt-0.5 transition-colors shrink-0 ${
isChecked
? "bg-emerald-600 border-emerald-600 text-white"
: "border-muted-foreground/40 bg-background"
}`}
>
{isChecked && <Check className="h-3.5 w-3.5 stroke-[3]" />}
</div>
{/* Role Details */}
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold text-xs text-foreground">
{role.name}
</span>
<span className="font-mono text-[10px] rounded-md bg-muted px-1.5 py-0.2 text-muted-foreground border border-border/70">
{role.slug}
</span>
{role.isSystem ? (
<span className="rounded-full bg-emerald-500/15 px-2 py-0.2 text-[9px] font-semibold text-emerald-600 dark:text-emerald-400 border border-emerald-500/25">
{t.users.modalRoles.systemRoleBadge}
</span>
) : (
<span className="rounded-full bg-teal-500/15 px-2 py-0.2 text-[9px] font-semibold text-teal-600 dark:text-teal-400 border border-teal-500/25">
{t.users.modalRoles.customRoleBadge}
</span>
)}
</div>
{role.description && (
<p className="mt-1 text-[11px] text-muted-foreground leading-relaxed">
{role.description}
</p>
)}
</div>
</label>
);
})
)}
</div>
{/* Footer Buttons */}
<div className="pt-4 flex items-center justify-end gap-2.5 border-t border-border/60">
<Button
type="button"
variant="outline"
onClick={onClose}
className="rounded-2xl text-xs cursor-pointer"
>
{t.users.modalRoles.cancel}
</Button>
<Button
type="button"
disabled={assignRolesMutation.isPending || isSelf || isLoading}
onClick={handleSave}
className="rounded-2xl bg-emerald-600 hover:bg-emerald-700 text-white text-xs font-semibold px-5 shadow-md shadow-emerald-600/20 cursor-pointer transition-all hover:scale-[1.02]"
>
{assignRolesMutation.isPending
? t.users.modalRoles.saving
: t.users.modalRoles.save}
</Button>
</div>
</div>
</div>
);
}
"use client";
import React from "react";
import {
Edit3,
ShieldCheck,
Trash2,
Lock,
Unlock,
Users,
AlertCircle,
RefreshCw,
Plus,
Clock,
Mail,
UserCheck,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { UserItem } from "@/types/user";
import { useLanguage } from "@/providers/language-provider";
import { formatDate } from "@/lib/utils";
interface UserTableProps {
users: UserItem[];
isLoading: boolean;
isError: boolean;
onRetry: () => void;
onEdit: (user: UserItem) => void;
onManageRoles: (user: UserItem) => void;
onDelete: (user: UserItem) => void;
onToggleStatus: (user: UserItem) => void;
onCreateNew: () => void;
currentUserId?: string;
isTogglingStatus?: boolean;
}
export function UserTable({
users,
isLoading,
isError,
onRetry,
onEdit,
onManageRoles,
onDelete,
onToggleStatus,
onCreateNew,
currentUserId,
isTogglingStatus,
}: UserTableProps) {
const { t, locale } = useLanguage();
// 1. Loading Skeleton State
if (isLoading) {
return (
<div className="overflow-hidden rounded-3xl border border-emerald-500/15 bg-card/60 shadow-sm backdrop-blur-sm">
<div className="p-6 space-y-4">
<div className="flex items-center justify-between pb-2 border-b border-border/50">
<div className="h-4 w-32 bg-muted/60 rounded-md animate-pulse" />
<div className="h-4 w-24 bg-muted/60 rounded-md animate-pulse" />
</div>
{[...Array(5)].map((_, i) => (
<div
key={i}
className="flex items-center justify-between gap-4 py-3.5 border-b border-border/40 last:border-0"
>
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-2xl bg-muted/70 animate-pulse shrink-0" />
<div className="space-y-1.5">
<div className="h-3.5 w-36 bg-muted/70 rounded-md animate-pulse" />
<div className="h-3 w-48 bg-muted/50 rounded-md animate-pulse" />
</div>
</div>
<div className="hidden sm:flex items-center gap-2">
<div className="h-5 w-20 bg-muted/60 rounded-full animate-pulse" />
<div className="h-5 w-16 bg-muted/50 rounded-full animate-pulse" />
</div>
<div className="h-5 w-20 bg-muted/60 rounded-full animate-pulse" />
<div className="hidden md:block h-3.5 w-24 bg-muted/50 rounded-md animate-pulse" />
<div className="flex items-center gap-1.5">
<div className="h-8 w-8 bg-muted/60 rounded-xl animate-pulse" />
<div className="h-8 w-8 bg-muted/60 rounded-xl animate-pulse" />
<div className="h-8 w-8 bg-muted/60 rounded-xl animate-pulse" />
</div>
</div>
))}
</div>
</div>
);
}
// 2. Error State
if (isError) {
return (
<div className="rounded-3xl border border-red-500/20 bg-red-500/5 p-8 text-center backdrop-blur-sm">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-red-500/10 text-red-600 dark:text-red-400 mb-3 border border-red-500/20">
<AlertCircle className="h-6 w-6" />
</div>
<h3 className="text-base font-semibold text-foreground">
{t.users.table.errorTitle}
</h3>
<p className="mt-1 text-xs text-muted-foreground max-w-md mx-auto">
Không thể kết nối đến máy chủ quản trị hoặc bạn không đủ quyền hạn.
</p>
<Button
onClick={onRetry}
variant="outline"
size="sm"
className="mt-4 rounded-xl border-red-500/30 text-red-600 dark:text-red-400 hover:bg-red-500/10 cursor-pointer"
>
<RefreshCw className="mr-2 h-3.5 w-3.5" />
{t.users.table.retry}
</Button>
</div>
);
}
// 3. Empty State
if (!users || users.length === 0) {
return (
<div className="rounded-3xl border border-emerald-500/15 bg-card/60 p-12 text-center shadow-sm backdrop-blur-sm">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20 mb-4">
<Users className="h-7 w-7" />
</div>
<h3 className="text-base font-bold text-foreground">
{t.users.table.emptyTitle}
</h3>
<p className="mt-1 text-xs text-muted-foreground max-w-sm mx-auto">
{t.users.table.emptyDesc}
</p>
<Button
onClick={onCreateNew}
className="mt-5 rounded-2xl bg-emerald-600 hover:bg-emerald-700 text-white text-xs font-semibold px-4 shadow-md shadow-emerald-600/20 cursor-pointer transition-all hover:scale-[1.02]"
>
<Plus className="mr-1.5 h-4 w-4" />
{t.users.table.emptyBtn}
</Button>
</div>
);
}
// Helper to extract initials
const getInitials = (name?: string | null, email?: string) => {
const source = name?.trim() || email?.split("@")[0] || "U";
return source
.split(" ")
.map((part) => part[0])
.join("")
.substring(0, 2)
.toUpperCase();
};
// Helper to render role badges
const renderRoleBadges = (user: UserItem) => {
if (user.roles && user.roles.length > 0) {
return (
<div className="flex flex-wrap gap-1.5 max-w-[220px]">
{user.roles.map((role) => {
const isSuperOrAdmin =
role.slug.toLowerCase().includes("admin") ||
role.name.toLowerCase().includes("admin");
return (
<span
key={role.id}
title={role.description || role.name}
className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-[11px] font-semibold border transition-colors ${
isSuperOrAdmin
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/25"
: "bg-teal-500/10 text-teal-600 dark:text-teal-400 border-teal-500/20"
}`}
>
<span
className={`h-1.5 w-1.5 rounded-full ${
isSuperOrAdmin ? "bg-emerald-500" : "bg-teal-500"
}`}
/>
{role.name}
</span>
);
})}
</div>
);
}
// Fallback to legacy role enum string
const roleSlug = user.role?.toUpperCase() || "VIEWER";
const isAdmin = roleSlug === "ADMIN";
const isCrawler = roleSlug === "CRAWLER_USER";
return (
<span
className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-[11px] font-semibold border ${
isAdmin
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/25"
: isCrawler
? "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20"
: "bg-muted text-muted-foreground border-border"
}`}
>
<span
className={`h-1.5 w-1.5 rounded-full ${
isAdmin
? "bg-emerald-500"
: isCrawler
? "bg-blue-500"
: "bg-muted-foreground"
}`}
/>
{isAdmin
? "Quản trị viên"
: isCrawler
? "Người dùng Crawler"
: "Người xem"}
</span>
);
};
return (
<div className="overflow-hidden rounded-3xl border border-emerald-500/15 bg-card/60 shadow-sm backdrop-blur-sm transition-colors duration-200">
<div className="overflow-x-auto">
<table className="w-full text-left text-xs text-foreground">
{/* Table Header */}
<thead className="border-b border-border/70 bg-muted/40 text-[11px] uppercase tracking-wider text-muted-foreground select-none">
<tr>
<th scope="col" className="px-5 py-3.5 font-semibold">
{t.users.table.user}
</th>
<th scope="col" className="px-4 py-3.5 font-semibold">
{t.users.table.email}
</th>
<th scope="col" className="px-4 py-3.5 font-semibold">
{t.users.table.roles}
</th>
<th scope="col" className="px-4 py-3.5 font-semibold text-center">
{t.users.table.status}
</th>
<th
scope="col"
className="hidden lg:table-cell px-4 py-3.5 font-semibold"
>
{t.users.table.createdAt}
</th>
<th
scope="col"
className="px-5 py-3.5 font-semibold text-right"
>
{t.users.table.actions}
</th>
</tr>
</thead>
{/* Table Body */}
<tbody className="divide-y divide-border/50">
{users.map((user) => {
const isSelf = currentUserId === user.id;
return (
<tr
key={user.id}
className="transition-colors hover:bg-emerald-500/5 group"
>
{/* Avatar + Full Name */}
<td className="px-5 py-4 whitespace-nowrap">
<div className="flex items-center gap-3">
{user.avatarUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={user.avatarUrl}
alt={user.fullName || user.email}
className="h-10 w-10 rounded-2xl object-cover border border-emerald-500/20 shadow-xs"
/>
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-gradient-to-br from-emerald-500 to-teal-700 text-white font-bold text-xs shadow-sm shadow-emerald-600/20 shrink-0">
{getInitials(user.fullName, user.email)}
</div>
)}
<div className="flex flex-col min-w-0">
<div className="flex items-center gap-1.5">
<span className="font-semibold text-sm text-foreground truncate max-w-[180px] sm:max-w-[220px]">
{user.fullName || "—"}
</span>
{isSelf && (
<span className="rounded-full bg-emerald-500/10 px-1.5 py-0.2 text-[9px] font-bold text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
Bạn
</span>
)}
</div>
<span className="text-[11px] text-muted-foreground font-mono truncate max-w-[160px]">
ID: {user.id.substring(0, 8)}...
</span>
</div>
</div>
</td>
{/* Email */}
<td className="px-4 py-4 whitespace-nowrap">
<div className="flex items-center gap-1.5 text-muted-foreground text-xs font-mono">
<Mail className="h-3.5 w-3.5 text-emerald-500 shrink-0" />
<span className="truncate max-w-[200px]">{user.email}</span>
</div>
</td>
{/* Assigned Roles */}
<td className="px-4 py-4 whitespace-nowrap">
{renderRoleBadges(user)}
</td>
{/* Status with Quick Toggle Switch */}
<td className="px-4 py-4 whitespace-nowrap text-center">
<div className="inline-flex items-center gap-2">
<button
type="button"
role="switch"
aria-checked={user.isActive}
title={
user.isActive
? t.users.actions.lock
: t.users.actions.unlock
}
onClick={() => onToggleStatus(user)}
disabled={isTogglingStatus || isSelf}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-emerald-500/30 ${
user.isActive
? "bg-emerald-600 dark:bg-emerald-500"
: "bg-muted-foreground/30"
} ${
isSelf ? "opacity-50 cursor-not-allowed" : ""
}`}
>
<span
className={`pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow-md ring-0 transition duration-200 ease-in-out ${
user.isActive ? "translate-x-4" : "translate-x-0"
}`}
/>
</button>
<span
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold border ${
user.isActive
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20"
}`}
>
{user.isActive ? (
<Unlock className="h-2.5 w-2.5" />
) : (
<Lock className="h-2.5 w-2.5" />
)}
{user.isActive
? t.users.table.activeBadge
: t.users.table.lockedBadge}
</span>
</div>
</td>
{/* Created Date */}
<td className="hidden lg:table-cell px-4 py-4 whitespace-nowrap">
<div className="flex items-center gap-1 text-muted-foreground text-xs">
<Clock className="h-3.5 w-3.5 text-muted-foreground/70" />
<span>{formatDate(user.createdAt, locale)}</span>
</div>
</td>
{/* Action Buttons */}
<td className="px-5 py-4 whitespace-nowrap text-right">
<div className="flex items-center justify-end gap-1">
{/* Phân vai trò */}
<button
onClick={() => onManageRoles(user)}
title={t.users.actions.assignRoles}
className="flex h-8 w-8 items-center justify-center rounded-xl text-muted-foreground hover:bg-emerald-500/10 hover:text-emerald-600 dark:hover:text-emerald-400 border border-transparent hover:border-emerald-500/20 transition-all cursor-pointer"
>
<ShieldCheck className="h-4 w-4" />
</button>
{/* Chỉnh sửa thông tin */}
<button
onClick={() => onEdit(user)}
title={t.users.actions.edit}
className="flex h-8 w-8 items-center justify-center rounded-xl text-muted-foreground hover:bg-teal-500/10 hover:text-teal-600 dark:hover:text-teal-400 border border-transparent hover:border-teal-500/20 transition-all cursor-pointer"
>
<Edit3 className="h-4 w-4" />
</button>
{/* Xóa người dùng */}
<button
onClick={() => onDelete(user)}
disabled={isSelf}
title={
isSelf
? "Không thể xóa chính tài khoản của bạn"
: t.users.actions.delete
}
className={`flex h-8 w-8 items-center justify-center rounded-xl transition-all cursor-pointer ${
isSelf
? "opacity-30 cursor-not-allowed text-muted-foreground"
: "text-muted-foreground hover:bg-red-500/10 hover:text-red-600 dark:hover:text-red-400 border border-transparent hover:border-red-500/20"
}`}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
"use client";
import React, { useState, useEffect, useRef, useMemo } from "react";
import {
Users,
UserPlus,
Search,
Filter,
Check,
ChevronDown,
RefreshCw,
UserCheck,
UserX,
Shield,
RotateCcw,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useLanguage } from "@/providers/language-provider";
import { useAuth } from "@/hooks/use-auth";
import { useUsersList, useUpdateUser } from "@/hooks/use-users";
import { UserItem } from "@/types/user";
import { UserTable } from "./user-table";
import { UserCreateModal } from "./user-create-modal";
import { UserEditModal } from "./user-edit-modal";
import { UserRolesModal } from "./user-roles-modal";
import { UserDeleteDialog } from "./user-delete-dialog";
interface UsersManagementViewProps {
showHeader?: boolean;
}
export function UsersManagementView({
showHeader = true,
}: UsersManagementViewProps) {
const { t } = useLanguage();
const { user: currentUser, role, hasPermission } = useAuth();
const canCreateUser = hasPermission("users.create") || role === "ADMIN";
// Search and filter states
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<"ALL" | "true" | "false">("ALL");
const [roleFilter, setRoleFilter] = useState<string>("ALL");
const [page, setPage] = useState(1);
const [limit, setLimit] = useState(20);
// Dropdown open states
const [isStatusDropdownOpen, setIsStatusDropdownOpen] = useState(false);
const [isRoleDropdownOpen, setIsRoleDropdownOpen] = useState(false);
const statusDropdownRef = useRef<HTMLDivElement>(null);
const roleDropdownRef = useRef<HTMLDivElement>(null);
// Modals state
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [editingUser, setEditingUser] = useState<UserItem | null>(null);
const [rolesUser, setRolesUser] = useState<UserItem | null>(null);
const [deletingUser, setDeletingUser] = useState<UserItem | null>(null);
// Quick toggle status mutation
const updateMutation = useUpdateUser();
// Debounce search input by 300ms
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(search.trim());
setPage(1);
}, 300);
return () => clearTimeout(timer);
}, [search]);
// Close dropdowns on outside click or escape
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (
statusDropdownRef.current &&
!statusDropdownRef.current.contains(e.target as Node)
) {
setIsStatusDropdownOpen(false);
}
if (
roleDropdownRef.current &&
!roleDropdownRef.current.contains(e.target as Node)
) {
setIsRoleDropdownOpen(false);
}
}
function handleEscape(e: KeyboardEvent) {
if (e.key === "Escape") {
setIsStatusDropdownOpen(false);
setIsRoleDropdownOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
};
}, []);
// Server-side Query Parameters
const queryParams = useMemo(() => {
return {
search: debouncedSearch || undefined,
isActive:
statusFilter === "ALL"
? undefined
: (statusFilter as "true" | "false"),
role: roleFilter === "ALL" ? undefined : roleFilter,
page,
limit,
};
}, [debouncedSearch, statusFilter, roleFilter, page, limit]);
// Fetch users with TanStack Query
const {
data: usersData,
isLoading,
isError,
refetch,
isFetching,
} = useUsersList(queryParams);
const users: UserItem[] = usersData?.items || [];
const meta = usersData?.meta || {
total: 0,
page: 1,
limit: 20,
totalPages: 1,
};
// Quick toggle active/locked status
const handleToggleStatus = (targetUser: UserItem) => {
updateMutation.mutate({
id: targetUser.id,
dto: {
isActive: !targetUser.isActive,
},
});
};
// Reset all filters
const handleResetFilters = () => {
setSearch("");
setDebouncedSearch("");
setStatusFilter("ALL");
setRoleFilter("ALL");
setPage(1);
};
// Status Filter Options
const STATUS_OPTIONS = [
{ value: "ALL", label: t.users.filters.allStatus },
{ value: "true", label: t.users.filters.statusActive },
{ value: "false", label: t.users.filters.statusLocked },
];
// Role Filter Options
const ROLE_OPTIONS = [
{ value: "ALL", label: t.users.filters.allRoles },
{ value: "ADMIN", label: "Quản trị viên (ADMIN)" },
{ value: "CRAWLER_USER", label: "Người dùng (CRAWLER_USER)" },
{ value: "VIEWER", label: "Người xem (VIEWER)" },
];
// Compute Impact Metrics from available data
const stats = useMemo(() => {
const total = meta.total || 0;
const active = users.filter((u) => u.isActive).length;
const locked = users.filter((u) => !u.isActive).length;
const admins = users.filter(
(u) =>
u.role === "ADMIN" ||
u.roles?.some((r) => r.slug.toLowerCase().includes("admin"))
).length;
return { total, active, locked, admins };
}, [meta.total, users]);
return (
<div className="space-y-6">
{/* Optional Page Header */}
{showHeader && (
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="space-y-1">
<div className="inline-flex items-center gap-2 rounded-full bg-emerald-500/10 px-3 py-1 text-xs font-semibold text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<Users className="h-3.5 w-3.5" />
<span>{t.users.title}</span>
</div>
<h2 className="text-xl sm:text-2xl font-extrabold tracking-tight text-foreground">
{t.users.title}
</h2>
<p className="text-xs text-muted-foreground max-w-2xl">
{t.users.subtitle}
</p>
</div>
{canCreateUser && (
<Button
onClick={() => setIsCreateModalOpen(true)}
className="rounded-2xl h-10 px-4 bg-emerald-600 hover:bg-emerald-700 text-white shadow-md shadow-emerald-600/20 transition-all hover:scale-[1.02] cursor-pointer"
>
<UserPlus className="h-4 w-4 mr-1.5" />
{t.users.createBtn}
</Button>
)}
</div>
)}
{!showHeader && (
<div className="flex items-center justify-between">
<div>
<h3 className="text-base font-bold text-foreground">
{t.users.title}
</h3>
<p className="text-xs text-muted-foreground">
{t.users.subtitle}
</p>
</div>
{canCreateUser && (
<Button
onClick={() => setIsCreateModalOpen(true)}
className="rounded-2xl h-10 px-4 bg-emerald-600 hover:bg-emerald-700 text-white shadow-md shadow-emerald-600/20 transition-all hover:scale-[1.02] cursor-pointer"
>
<UserPlus className="h-4 w-4 mr-1.5" />
{t.users.createBtn}
</Button>
)}
</div>
)}
{/* 4 KPI Cards / Impact Metrics */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3.5">
{/* Card 1: Tổng Người Dùng */}
<div className="p-3.5 rounded-2xl border border-emerald-500/15 bg-card/60 shadow-xs backdrop-blur-sm transition-all hover:border-emerald-500/30">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-muted-foreground">
{t.users.stats.total}
</p>
<div className="flex h-6 w-6 items-center justify-center rounded-lg bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
<Users className="h-3.5 w-3.5" />
</div>
</div>
<p className="text-xl font-bold tracking-tight text-foreground mt-1.5">
{stats.total}
</p>
<p className="text-[10px] text-emerald-600 dark:text-emerald-400 mt-0.5">
{t.users.stats.totalDesc}
</p>
</div>
{/* Card 2: Đang Hoạt Động */}
<div className="p-3.5 rounded-2xl border border-emerald-500/15 bg-card/60 shadow-xs backdrop-blur-sm transition-all hover:border-emerald-500/30">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-muted-foreground">
{t.users.stats.active}
</p>
<div className="flex h-6 w-6 items-center justify-center rounded-lg bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
<UserCheck className="h-3.5 w-3.5" />
</div>
</div>
<p className="text-xl font-bold tracking-tight text-foreground mt-1.5">
{stats.active}
</p>
<p className="text-[10px] text-emerald-600 dark:text-emerald-400 mt-0.5">
{t.users.stats.activeDesc}
</p>
</div>
{/* Card 3: Đã Bị Khóa */}
<div className="p-3.5 rounded-2xl border border-emerald-500/15 bg-card/60 shadow-xs backdrop-blur-sm transition-all hover:border-emerald-500/30">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-muted-foreground">
{t.users.stats.locked}
</p>
<div className="flex h-6 w-6 items-center justify-center rounded-lg bg-amber-500/10 text-amber-600 dark:text-amber-400">
<UserX className="h-3.5 w-3.5" />
</div>
</div>
<p className="text-xl font-bold tracking-tight text-foreground mt-1.5">
{stats.locked}
</p>
<p className="text-[10px] text-amber-600 dark:text-amber-400 mt-0.5">
{t.users.stats.lockedDesc}
</p>
</div>
{/* Card 4: Quản Trị Viên */}
<div className="p-3.5 rounded-2xl border border-emerald-500/15 bg-card/60 shadow-xs backdrop-blur-sm transition-all hover:border-emerald-500/30">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-muted-foreground">
{t.users.stats.admins}
</p>
<div className="flex h-6 w-6 items-center justify-center rounded-lg bg-teal-500/10 text-teal-600 dark:text-teal-400">
<Shield className="h-3.5 w-3.5" />
</div>
</div>
<p className="text-xl font-bold tracking-tight text-foreground mt-1.5">
{stats.admins}
</p>
<p className="text-[10px] text-teal-600 dark:text-teal-400 mt-0.5">
{t.users.stats.adminsDesc}
</p>
</div>
</div>
{/* Filter & Search Toolbar */}
<div className="flex flex-col lg:flex-row gap-3 items-stretch lg:items-center justify-between">
{/* Search Bar */}
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t.users.filters.searchPlaceholder}
className="pl-9 rounded-2xl border-border/80 text-xs bg-card/60 focus:ring-emerald-500/20 shadow-xs"
/>
</div>
{/* Right Filters */}
<div className="flex flex-wrap items-center gap-2">
{/* Status Dropdown */}
<div className="relative" ref={statusDropdownRef}>
<button
type="button"
onClick={() => setIsStatusDropdownOpen(!isStatusDropdownOpen)}
className="flex items-center justify-between gap-2 rounded-2xl border border-border/80 bg-card/80 px-3 py-2 text-xs font-medium text-foreground hover:bg-muted/60 transition-colors cursor-pointer shadow-xs"
>
<div className="flex items-center gap-1.5">
<Filter className="h-3.5 w-3.5 text-emerald-500" />
<span>
{STATUS_OPTIONS.find((o) => o.value === statusFilter)?.label}
</span>
</div>
<ChevronDown
className={`h-3.5 w-3.5 text-muted-foreground transition-transform duration-200 ${
isStatusDropdownOpen ? "rotate-180" : ""
}`}
/>
</button>
{isStatusDropdownOpen && (
<div className="absolute right-0 top-full mt-1.5 w-44 rounded-2xl border border-emerald-500/20 bg-card/95 p-1.5 shadow-xl shadow-emerald-950/10 backdrop-blur-xl z-50 animate-in fade-in-50 zoom-in-95 duration-150 space-y-0.5">
{STATUS_OPTIONS.map((option) => {
const isSelected = statusFilter === option.value;
return (
<button
key={option.value}
type="button"
onClick={() => {
setStatusFilter(
option.value as "ALL" | "true" | "false"
);
setPage(1);
setIsStatusDropdownOpen(false);
}}
className={`w-full flex items-center justify-between rounded-xl px-2.5 py-2 text-xs transition-colors cursor-pointer text-left ${
isSelected
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 font-semibold border border-emerald-500/20"
: "text-foreground hover:bg-muted/60"
}`}
>
<span>{option.label}</span>
{isSelected && <Check className="h-3.5 w-3.5" />}
</button>
);
})}
</div>
)}
</div>
{/* Role Dropdown */}
<div className="relative" ref={roleDropdownRef}>
<button
type="button"
onClick={() => setIsRoleDropdownOpen(!isRoleDropdownOpen)}
className="flex items-center justify-between gap-2 rounded-2xl border border-border/80 bg-card/80 px-3 py-2 text-xs font-medium text-foreground hover:bg-muted/60 transition-colors cursor-pointer shadow-xs"
>
<div className="flex items-center gap-1.5">
<Shield className="h-3.5 w-3.5 text-teal-500" />
<span>
{ROLE_OPTIONS.find((o) => o.value === roleFilter)?.label}
</span>
</div>
<ChevronDown
className={`h-3.5 w-3.5 text-muted-foreground transition-transform duration-200 ${
isRoleDropdownOpen ? "rotate-180" : ""
}`}
/>
</button>
{isRoleDropdownOpen && (
<div className="absolute right-0 top-full mt-1.5 w-56 rounded-2xl border border-emerald-500/20 bg-card/95 p-1.5 shadow-xl shadow-emerald-950/10 backdrop-blur-xl z-50 animate-in fade-in-50 zoom-in-95 duration-150 space-y-0.5">
{ROLE_OPTIONS.map((option) => {
const isSelected = roleFilter === option.value;
return (
<button
key={option.value}
type="button"
onClick={() => {
setRoleFilter(option.value);
setPage(1);
setIsRoleDropdownOpen(false);
}}
className={`w-full flex items-center justify-between rounded-xl px-2.5 py-2 text-xs transition-colors cursor-pointer text-left ${
isSelected
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 font-semibold border border-emerald-500/20"
: "text-foreground hover:bg-muted/60"
}`}
>
<span className="truncate">{option.label}</span>
{isSelected && <Check className="h-3.5 w-3.5 shrink-0" />}
</button>
);
})}
</div>
)}
</div>
{/* Reset Button */}
{(search || statusFilter !== "ALL" || roleFilter !== "ALL") && (
<button
type="button"
onClick={handleResetFilters}
title={t.users.filters.reset}
className="flex items-center gap-1 rounded-2xl border border-border/80 bg-card/60 px-3 py-2 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors cursor-pointer shadow-xs"
>
<RotateCcw className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{t.users.filters.reset}</span>
</button>
)}
{/* Refresh Button */}
<button
type="button"
onClick={() => refetch()}
disabled={isFetching}
title="Làm mới dữ liệu"
className="flex h-9 w-9 items-center justify-center rounded-2xl border border-border/80 bg-card/80 text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors cursor-pointer shadow-xs"
>
<RefreshCw
className={`h-3.5 w-3.5 ${isFetching ? "animate-spin text-emerald-500" : ""}`}
/>
</button>
</div>
</div>
{/* Users Table */}
<UserTable
users={users}
isLoading={isLoading}
isError={isError}
onRetry={() => refetch()}
onEdit={(u) => setEditingUser(u)}
onManageRoles={(u) => setRolesUser(u)}
onDelete={(u) => setDeletingUser(u)}
onToggleStatus={handleToggleStatus}
onCreateNew={() => setIsCreateModalOpen(true)}
currentUserId={currentUser?.id}
isTogglingStatus={updateMutation.isPending}
/>
{/* Pagination */}
{meta.totalPages > 1 && (
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 pt-1">
<p className="text-xs text-muted-foreground">
{t.users.pagination.showing}{" "}
<span className="font-semibold text-foreground">
{(page - 1) * limit + 1}
</span>{" "}
-{" "}
<span className="font-semibold text-foreground">
{Math.min(page * limit, meta.total)}
</span>{" "}
{t.users.pagination.of}{" "}
<span className="font-semibold text-foreground">{meta.total}</span>{" "}
{t.users.pagination.users}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1 || isFetching}
className="rounded-xl h-8 px-3 text-xs cursor-pointer"
>
<ChevronLeft className="h-3.5 w-3.5 mr-1" />
{t.users.pagination.prev}
</Button>
<div className="flex items-center gap-1 text-xs font-medium px-2">
<span className="text-emerald-600 dark:text-emerald-400 font-bold">
{meta.page}
</span>
<span className="text-muted-foreground">/</span>
<span className="text-muted-foreground">{meta.totalPages}</span>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(meta.totalPages, p + 1))}
disabled={page >= meta.totalPages || isFetching}
className="rounded-xl h-8 px-3 text-xs cursor-pointer"
>
{t.users.pagination.next}
<ChevronRight className="h-3.5 w-3.5 ml-1" />
</Button>
</div>
</div>
)}
{/* Modals & Dialogs */}
{isCreateModalOpen && (
<UserCreateModal
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
/>
)}
{editingUser && (
<UserEditModal
isOpen={!!editingUser}
onClose={() => setEditingUser(null)}
user={editingUser}
currentUserId={currentUser?.id}
/>
)}
{rolesUser && (
<UserRolesModal
isOpen={!!rolesUser}
onClose={() => setRolesUser(null)}
user={rolesUser}
currentUserId={currentUser?.id}
/>
)}
{deletingUser && (
<UserDeleteDialog
isOpen={!!deletingUser}
onClose={() => setDeletingUser(null)}
user={deletingUser}
/>
)}
</div>
);
}
"use client";
import { useQuery } from "@tanstack/react-query";
import { permissionService } from "@/services/permission.service";
import { PermissionQueryParams } from "@/types/permission";
export const PERMISSIONS_QUERY_KEYS = {
all: ["permissions"] as const,
lists: (params?: PermissionQueryParams) =>
[...PERMISSIONS_QUERY_KEYS.all, "list", params] as const,
detail: (id: string) =>
[...PERMISSIONS_QUERY_KEYS.all, "detail", id] as const,
};
export function usePermissionsList(
params?: PermissionQueryParams,
options?: { enabled?: boolean }
) {
return useQuery({
queryKey: PERMISSIONS_QUERY_KEYS.lists(params),
queryFn: () => permissionService.getPermissions(params),
staleTime: 5 * 60 * 1000, // Permissions catalog rarely changes
enabled: options?.enabled !== undefined ? options.enabled : true,
});
}
export function usePermissionDetail(
id: string,
options?: { enabled?: boolean }
) {
return useQuery({
queryKey: PERMISSIONS_QUERY_KEYS.detail(id),
queryFn: () => permissionService.getPermissionById(id),
enabled: !!id && (options?.enabled !== undefined ? options.enabled : true),
});
}
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { roleService } from "@/services/role.service";
import {
CreateRoleDto,
UpdateRoleDto,
RoleQueryParams,
} from "@/types/role";
import { toast } from "sonner";
export const ROLES_QUERY_KEYS = {
all: ["roles"] as const,
lists: (params?: RoleQueryParams) =>
[...ROLES_QUERY_KEYS.all, "list", params] as const,
detail: (id: string) => [...ROLES_QUERY_KEYS.all, "detail", id] as const,
permissions: (roleId: string) =>
[...ROLES_QUERY_KEYS.all, "permissions", roleId] as const,
users: (roleId: string) =>
[...ROLES_QUERY_KEYS.all, "users", roleId] as const,
};
export function useRolesList(
params?: RoleQueryParams,
options?: { enabled?: boolean }
) {
return useQuery({
queryKey: ROLES_QUERY_KEYS.lists(params),
queryFn: () => roleService.getRoles(params),
staleTime: 30000,
enabled: options?.enabled !== undefined ? options.enabled : true,
});
}
export function useRoleDetail(id: string, options?: { enabled?: boolean }) {
return useQuery({
queryKey: ROLES_QUERY_KEYS.detail(id),
queryFn: () => roleService.getRoleById(id),
enabled: !!id && (options?.enabled !== undefined ? options.enabled : true),
});
}
export function useCreateRole() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (dto: CreateRoleDto) => roleService.createRole(dto),
onSuccess: (newRole) => {
queryClient.invalidateQueries({ queryKey: ROLES_QUERY_KEYS.all });
toast.success(`Tạo vai trò "${newRole.name}" thành công!`);
},
onError: (error: unknown) => {
const message =
error instanceof Error ? error.message : "Tạo vai trò thất bại";
toast.error(message);
},
});
}
export function useUpdateRole() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, dto }: { id: string; dto: UpdateRoleDto }) =>
roleService.updateRole(id, dto),
onSuccess: (updatedRole) => {
queryClient.invalidateQueries({ queryKey: ROLES_QUERY_KEYS.all });
toast.success(`Cập nhật vai trò "${updatedRole.name}" thành công!`);
},
onError: (error: unknown) => {
const message =
error instanceof Error ? error.message : "Cập nhật vai trò thất bại";
toast.error(message);
},
});
}
export function useDeleteRole() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => roleService.deleteRole(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ROLES_QUERY_KEYS.all });
toast.success("Đã xóa vai trò thành công!");
},
onError: (error: unknown) => {
const message =
error instanceof Error ? error.message : "Không thể xóa vai trò này";
toast.error(message);
},
});
}
export function useRolePermissions(
roleId: string,
options?: { enabled?: boolean }
) {
return useQuery({
queryKey: ROLES_QUERY_KEYS.permissions(roleId),
queryFn: () => roleService.getRolePermissions(roleId),
enabled:
!!roleId && (options?.enabled !== undefined ? options.enabled : true),
});
}
export function useSetRolePermissions() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
roleId,
permissionIds,
}: {
roleId: string;
permissionIds: string[];
}) => roleService.setRolePermissions(roleId, permissionIds),
onSuccess: (role) => {
queryClient.invalidateQueries({
queryKey: ROLES_QUERY_KEYS.permissions(role.id),
});
queryClient.invalidateQueries({ queryKey: ROLES_QUERY_KEYS.all });
// Invalidate current user auth profile to update permissions immediately
queryClient.invalidateQueries({ queryKey: ["auth", "me"] });
toast.success(`Đã lưu ma trận phân quyền cho vai trò "${role.name}"!`);
},
onError: (error: unknown) => {
const message =
error instanceof Error
? error.message
: "Lưu ma trận phân quyền thất bại";
toast.error(message);
},
});
}
export function useRoleUsers(roleId: string, options?: { enabled?: boolean }) {
return useQuery({
queryKey: ROLES_QUERY_KEYS.users(roleId),
queryFn: () => roleService.getRoleUsers(roleId),
enabled:
!!roleId && (options?.enabled !== undefined ? options.enabled : true),
});
}
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { userService } from "@/services/user.service";
import {
CreateUserDto,
UpdateUserDto,
UserQueryParams,
} from "@/types/user";
import { toast } from "sonner";
export const USERS_QUERY_KEYS = {
all: ["users"] as const,
lists: (params?: UserQueryParams) =>
[...USERS_QUERY_KEYS.all, "list", params] as const,
detail: (id: string) => [...USERS_QUERY_KEYS.all, "detail", id] as const,
roles: (userId: string) => [...USERS_QUERY_KEYS.all, "roles", userId] as const,
};
export function useUsersList(
params?: UserQueryParams,
options?: { enabled?: boolean }
) {
return useQuery({
queryKey: USERS_QUERY_KEYS.lists(params),
queryFn: () => userService.getUsers(params),
staleTime: 15000,
enabled: options?.enabled !== undefined ? options.enabled : true,
});
}
export function useUserDetail(id: string) {
return useQuery({
queryKey: USERS_QUERY_KEYS.detail(id),
queryFn: () => userService.getUserById(id),
enabled: !!id,
});
}
export function useUserRoles(userId: string, enabled = true) {
return useQuery({
queryKey: USERS_QUERY_KEYS.roles(userId),
queryFn: () => userService.getUserRoles(userId),
enabled: !!userId && enabled,
staleTime: 10000,
});
}
export function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (dto: CreateUserDto) => userService.createUser(dto),
onSuccess: (newUser) => {
toast.success(`Đã thêm người dùng ${newUser.email} thành công!`);
queryClient.invalidateQueries({ queryKey: USERS_QUERY_KEYS.all });
},
onError: (error: Error) => {
toast.error(`Không thể tạo người dùng: ${error.message}`);
},
});
}
export function useUpdateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, dto }: { id: string; dto: UpdateUserDto }) =>
userService.updateUser(id, dto),
onSuccess: (updatedUser, { id }) => {
toast.success(`Cập nhật tài khoản ${updatedUser.email} thành công!`);
queryClient.invalidateQueries({ queryKey: USERS_QUERY_KEYS.all });
queryClient.invalidateQueries({ queryKey: USERS_QUERY_KEYS.detail(id) });
},
onError: (error: Error) => {
toast.error(`Không thể cập nhật người dùng: ${error.message}`);
},
});
}
export function useDeleteUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => userService.deleteUser(id),
onSuccess: () => {
toast.success("Đã xóa vĩnh viễn người dùng khỏi cơ sở dữ liệu!");
queryClient.invalidateQueries({ queryKey: USERS_QUERY_KEYS.all });
},
onError: (error: Error) => {
toast.error(`Không thể xóa người dùng: ${error.message}`);
},
});
}
export function useAssignUserRoles() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
userId,
roleIds,
}: {
userId: string;
roleIds: string[];
}) => userService.assignUserRoles(userId, roleIds),
onSuccess: (_, { userId }) => {
toast.success("Đồng bộ danh sách vai trò cho người dùng thành công!");
queryClient.invalidateQueries({ queryKey: USERS_QUERY_KEYS.all });
queryClient.invalidateQueries({ queryKey: USERS_QUERY_KEYS.roles(userId) });
queryClient.invalidateQueries({ queryKey: USERS_QUERY_KEYS.detail(userId) });
},
onError: (error: Error) => {
toast.error(`Không thể cập nhật vai trò: ${error.message}`);
},
});
}
export function useAssignSingleRole() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
userId,
roleId,
}: {
userId: string;
roleId: string;
}) => userService.assignSingleRole(userId, roleId),
onSuccess: (_, { userId }) => {
toast.success("Gán vai trò thành công!");
queryClient.invalidateQueries({ queryKey: USERS_QUERY_KEYS.roles(userId) });
queryClient.invalidateQueries({ queryKey: USERS_QUERY_KEYS.all });
},
onError: (error: Error) => {
toast.error(`Không thể gán vai trò: ${error.message}`);
},
});
}
export function useRevokeSingleRole() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
userId,
roleId,
}: {
userId: string;
roleId: string;
}) => userService.revokeSingleRole(userId, roleId),
onSuccess: (_, { userId }) => {
toast.success("Thu hồi vai trò thành công!");
queryClient.invalidateQueries({ queryKey: USERS_QUERY_KEYS.roles(userId) });
queryClient.invalidateQueries({ queryKey: USERS_QUERY_KEYS.all });
},
onError: (error: Error) => {
toast.error(`Không thể thu hồi vai trò: ${error.message}`);
},
});
}
......@@ -11,6 +11,7 @@ export const translations = {
templates: "Mẫu Bóc Tách",
exports: "Xuất Dữ Liệu",
storage: "Kho Dữ Liệu",
users: "Quản Lý Người Dùng",
statusActive: "Động cơ Crawler Đang Chạy",
reload: "Tải lại trang",
copyright: "Data Crawler © 2026 - Code by @hnihTyoB",
......@@ -804,6 +805,236 @@ export const translations = {
successMsg: "Đã gửi yêu cầu hủy kích hoạt tài khoản. Vui lòng kiểm tra email của bạn để xác nhận.",
},
},
// Users Management
users: {
title: "Quản Lý Người Dùng",
subtitle: "Quản trị danh sách thành viên, phân quyền vai trò và kiểm soát trạng thái hoạt động tài khoản.",
createBtn: "Thêm Người Dùng",
stats: {
total: "Tổng Người Dùng",
totalDesc: "Tài khoản trong hệ thống",
active: "Đang Hoạt Động",
activeDesc: "Tài khoản mở khóa bình thường",
locked: "Đã Bị Khóa",
lockedDesc: "Tài khoản bị vô hiệu hóa",
admins: "Quản Trị Viên",
adminsDesc: "Tài khoản có quyền quản trị",
},
filters: {
searchPlaceholder: "Tìm theo tên hoặc địa chỉ email...",
allStatus: "Tất cả trạng thái",
statusActive: "Đang hoạt động",
statusLocked: "Đã bị khóa",
allRoles: "Tất cả vai trò",
reset: "Đặt lại bộ lọc",
},
table: {
user: "Thành viên",
email: "Email",
status: "Trạng thái",
roles: "Vai trò đang giữ",
createdAt: "Ngày tạo",
actions: "Thao tác",
activeBadge: "Hoạt động",
lockedBadge: "Bị khóa",
noRoles: "Chưa phân vai trò",
emptyTitle: "Chưa có người dùng nào",
emptyDesc: "Không tìm thấy người dùng phù hợp với tiêu chí tìm kiếm hoặc chưa có dữ liệu.",
emptyBtn: "Thêm người dùng mới",
errorTitle: "Không thể tải danh sách người dùng",
retry: "Thử lại",
},
pagination: {
showing: "Hiển thị",
of: "trong tổng số",
users: "người dùng",
rowsPerPage: "Dòng mỗi trang",
prev: "Trước",
next: "Sau",
},
modalCreate: {
title: "Thêm Người Dùng Mới",
desc: "Khởi tạo tài khoản người dùng mới cùng mật khẩu và vai trò ban đầu.",
fullName: "Họ và tên",
fullNamePlaceholder: "Ví dụ: Nguyễn Văn An",
email: "Địa chỉ Email",
emailPlaceholder: "nguyenvana@example.com",
password: "Mật khẩu khởi tạo",
passwordPlaceholder: "Tối thiểu 8 ký tự",
role: "Vai trò ban đầu",
advanced: "Thiết lập hạn mức tài nguyên",
maxPages: "Giới hạn số trang tối đa mỗi tác vụ",
maxJobsPerDay: "Giới hạn số tác vụ tối đa mỗi ngày",
maxConcurrentJobs: "Số tác vụ tối đa chạy cùng lúc",
cancel: "Hủy bỏ",
submit: "Tạo Người Dùng",
submitting: "Đang tạo...",
},
modalEdit: {
title: "Chỉnh Sửa Thông Tin & Trạng Thái",
desc: "Cập nhật thông tin cơ bản và điều khiển khóa hoặc mở khóa tài khoản người dùng.",
fullName: "Họ và tên",
fullNamePlaceholder: "Ví dụ: Nguyễn Văn An",
email: "Địa chỉ Email",
emailPlaceholder: "nguyenvana@example.com",
statusTitle: "Trạng thái hoạt động tài khoản",
statusActiveDesc: "Tài khoản đang được phép đăng nhập và sử dụng dịch vụ cào dữ liệu bình thường.",
statusLockedDesc: "Tài khoản bị khóa. Người dùng sẽ bị từ chối truy cập và đăng nhập vào hệ thống.",
statusSwitchActive: "Hoạt động",
statusSwitchLocked: "Bị khóa",
role: "Vai trò chính",
quotaTitle: "Hạn mức tài nguyên",
cancel: "Hủy bỏ",
submit: "Lưu Thay Đổi",
submitting: "Đang lưu...",
},
modalRoles: {
title: "Quản Lý Vai Trò Thành Viên",
desc: "Phân quyền các vai trò hệ thống và vai trò tùy chỉnh cho thành viên.",
userLabel: "Người dùng đang gán:",
systemRoleBadge: "Hệ thống",
customRoleBadge: "Tùy biến",
activeBadge: "Kích hoạt",
selectedCount: "Đã chọn:",
roles: "vai trò",
noRolesAvailable: "Không có vai trò nào trong hệ thống",
cancel: "Hủy bỏ",
save: "Lưu Vai Trò",
saving: "Đang lưu...",
},
dialogDelete: {
title: "Xác Nhận Xóa Vĩnh Viễn Người Dùng",
desc: "Cảnh báo: Hành động này có tính chất phá hủy và không thể khôi phục. Toàn bộ thông tin tài khoản, phân quyền vai trò và dữ liệu cá nhân liên quan của thành viên sẽ bị xóa hoàn toàn khỏi cơ sở dữ liệu.",
targetUser: "Tài khoản mục tiêu:",
cancel: "Hủy Bỏ",
confirm: "Xóa Vĩnh Viễn",
deleting: "Đang xóa...",
},
actions: {
edit: "Chỉnh sửa thông tin",
assignRoles: "Phân vai trò",
lock: "Khóa tài khoản",
unlock: "Mở khóa tài khoản",
delete: "Xóa người dùng",
},
},
// Roles & Permission Matrix Module
roles: {
title: "Vai Trò & Ma Trận Phân Quyền",
subtitle: "Thiết lập các vai trò người dùng và phân quyền chi tiết theo từng phân hệ chức năng trong hệ thống.",
createBtn: "Tạo Vai Trò Mới",
stats: {
total: "Tổng Vai Trò",
totalDesc: "Vai trò trong hệ thống",
system: "Vai Trò Hệ Thống",
systemDesc: "Mặc định được bảo vệ",
custom: "Vai Trò Tùy Biến",
customDesc: "Tạo thêm theo nghiệp vụ",
permissions: "Tổng Quyền Hạn",
permissionsDesc: "Quyền chức năng khả dụng",
},
filters: {
searchPlaceholder: "Tìm kiếm vai trò theo tên hoặc mã định danh (slug)...",
allStatus: "Tất cả trạng thái",
statusActive: "Đang hoạt động",
statusInactive: "Tạm dừng",
allTypes: "Tất cả loại vai trò",
typeSystem: "Vai trò hệ thống",
typeCustom: "Vai trò tùy biến",
viewCards: "Thẻ trực quan",
viewTable: "Bảng dữ liệu",
},
table: {
name: "Tên vai trò",
slug: "Mã định danh",
description: "Mô tả",
type: "Loại vai trò",
permissionsCount: "Quyền hạn",
usersCount: "Thành viên",
status: "Trạng thái",
actions: "Thao tác",
systemBadge: "Hệ thống",
customBadge: "Tùy biến",
activeBadge: "Hoạt động",
inactiveBadge: "Tạm dừng",
emptyTitle: "Chưa có vai trò nào",
emptyDesc: "Không tìm thấy vai trò phù hợp với tiêu chí lọc hoặc chưa có dữ liệu.",
emptyBtn: "Tạo vai trò mới",
errorTitle: "Không thể tải danh sách vai trò",
retry: "Thử lại",
permissionsUnit: "quyền",
membersUnit: "thành viên",
},
card: {
permissionsTooltip: "Bấm để mở Ma trận phân quyền",
usersTooltip: "Bấm để xem danh sách thành viên",
systemProtected: "Vai trò mặc định của hệ thống được bảo vệ",
},
modalForm: {
createTitle: "Tạo Vai Trò Mới",
createDesc: "Định nghĩa tên hiển thị, mã định danh duy nhất và mô tả cho vai trò mới.",
editTitle: "Chỉnh Sửa Vai Trò",
editDesc: "Cập nhật tên hiển thị, mô tả hoặc trạng thái hoạt động của vai trò.",
nameLabel: "Tên hiển thị",
namePlaceholder: "Ví dụ: Chuyên Viên Bóc Tách Dữ Liệu",
slugLabel: "Mã định danh (Slug)",
slugPlaceholder: "Ví dụ: content_scraper hoặc CRAWLER_OPERATOR",
slugHelper: "Dùng để kiểm tra quyền hạn qua API/Code. Chỉ chấp nhận chữ cái, số, gạch dưới và gạch ngang.",
descLabel: "Mô tả chi tiết",
descPlaceholder: "Nêu rõ trách nhiệm và phạm vi hoạt động của vai trò này...",
activeLabel: "Kích hoạt vai trò này",
cancel: "Hủy bỏ",
save: "Lưu Vai Trò",
saving: "Đang lưu...",
},
matrix: {
title: "Ma Trận Phân Quyền",
subtitle: "Cấu hình quyền truy cập chi tiết cho vai trò:",
selectAll: "Chọn tất cả",
deselectAll: "Bỏ chọn tất cả",
selectedSummary: "Đã cấp quyền:",
searchPlaceholder: "Lọc nhanh quyền theo tên hoặc mã permission...",
saveBtn: "Lưu Thay Đổi",
saving: "Đang lưu phân quyền...",
cancel: "Đóng",
modules: {
crawlJobs: "Tác Vụ Crawl",
crawlSchedules: "Lập Lịch Crawl",
extractionTemplates: "Mẫu Bóc Tách",
exports: "Xuất Dữ Liệu",
webhooks: "Webhooks",
apiKeys: "Khóa API",
users: "Quản Lý Người Dùng",
roles: "Vai Trò & Phân Quyền",
auditLogs: "Nhật Ký Hệ Thống",
dashboard: "Bảng Điều Khiển",
},
},
modalUsers: {
title: "Danh Sách Thành Viên Thuộc Vai Trò",
subtitle: "Các tài khoản người dùng hiện đang được gán vai trò này:",
empty: "Chưa có người dùng nào được gán vai trò này.",
userCol: "Thành viên",
emailCol: "Email",
statusCol: "Trạng thái",
close: "Đóng",
},
dialogDelete: {
title: "Xác Nhận Xóa Vai Trò",
desc: "Bạn có chắc chắn muốn xóa vai trò này khỏi hệ thống không? Hành động này không thể hoàn tác.",
hasUsersWarning: "Không thể xóa vai trò này vì đang có thành viên giữ vai trò. Vui lòng chuyển các thành viên sang vai trò khác trước.",
isSystemWarning: "Đây là vai trò mặc định của hệ thống và không được phép xóa.",
cancel: "Hủy bỏ",
confirm: "Xác Nhận Xóa",
deleting: "Đang xóa...",
},
actions: {
edit: "Chỉnh sửa",
matrix: "Phân quyền",
viewUsers: "Xem thành viên",
delete: "Xóa vai trò",
},
},
},
en: {
// Navigation & Common
......@@ -815,6 +1046,7 @@ export const translations = {
templates: "Extraction Templates",
exports: "Exports Center",
storage: "Data Lake",
users: "Users Management",
statusActive: "Crawler Engine Active",
reload: "Reload page",
copyright: "Data Crawler © 2026 - Code by @hnihTyoB",
......@@ -1608,6 +1840,236 @@ export const translations = {
successMsg: "Deactivation request sent. Please check your email inbox to confirm.",
},
},
// Users Management
users: {
title: "Users Management",
subtitle: "Manage member accounts, RBAC role permissions, and control user access statuses across the Data Crawler system.",
createBtn: "Add New User",
stats: {
total: "Total Users",
totalDesc: "Registered accounts in system",
active: "Active Accounts",
activeDesc: "Unlocked and operating normally",
locked: "Locked Accounts",
lockedDesc: "Disabled from accessing system",
admins: "System Administrators",
adminsDesc: "Accounts with admin privileges",
},
filters: {
searchPlaceholder: "Search by name or email address...",
allStatus: "All Statuses",
statusActive: "Active",
statusLocked: "Locked",
allRoles: "All Roles",
reset: "Reset Filters",
},
table: {
user: "Member",
email: "Email",
status: "Status",
roles: "Assigned Roles",
createdAt: "Created Date",
actions: "Actions",
activeBadge: "Active",
lockedBadge: "Locked",
noRoles: "No roles assigned",
emptyTitle: "No users found",
emptyDesc: "No users match your search criteria or no data available.",
emptyBtn: "Add First User",
errorTitle: "Failed to load users list",
retry: "Retry",
},
pagination: {
showing: "Showing",
of: "of",
users: "users",
rowsPerPage: "Rows per page",
prev: "Previous",
next: "Next",
},
modalCreate: {
title: "Add New User",
desc: "Provision a new user account with initial password and assigned role.",
fullName: "Full Name",
fullNamePlaceholder: "e.g., John Doe",
email: "Email Address",
emailPlaceholder: "johndoe@example.com",
password: "Initial Password",
passwordPlaceholder: "At least 8 characters",
role: "Initial Role",
advanced: "Resource Quota Limits",
maxPages: "Pages per crawl task",
maxJobsPerDay: "Crawl jobs per day",
maxConcurrentJobs: "Concurrent running jobs",
cancel: "Cancel",
submit: "Create User",
submitting: "Creating...",
},
modalEdit: {
title: "Edit User & Status",
desc: "Update user profile details and switch account lock/unlock status.",
fullName: "Full Name",
fullNamePlaceholder: "e.g., John Doe",
email: "Email Address",
emailPlaceholder: "johndoe@example.com",
statusTitle: "Account Activity Status",
statusActiveDesc: "Account is active and permitted to sign in and execute crawl jobs.",
statusLockedDesc: "Account is locked. User will be blocked from logging into the platform.",
statusSwitchActive: "Active",
statusSwitchLocked: "Locked",
role: "Primary Role",
quotaTitle: "Resource Quota Limits",
cancel: "Cancel",
submit: "Save Changes",
submitting: "Saving...",
},
modalRoles: {
title: "Manage Member Roles",
desc: "Assign system and custom RBAC roles to this user.",
userLabel: "Target User:",
systemRoleBadge: "System",
customRoleBadge: "Custom",
activeBadge: "Active",
selectedCount: "Selected:",
roles: "roles",
noRolesAvailable: "No roles found in system",
cancel: "Cancel",
save: "Save Roles",
saving: "Saving...",
},
dialogDelete: {
title: "Confirm Permanent User Deletion",
desc: "Warning: This action is destructive and irreversible. The user account, assigned roles, and associated data will be permanently purged from the database.",
targetUser: "Target User:",
cancel: "Cancel",
confirm: "Delete Permanently",
deleting: "Deleting...",
},
actions: {
edit: "Edit Profile & Status",
assignRoles: "Assign Roles",
lock: "Lock Account",
unlock: "Unlock Account",
delete: "Delete User",
},
},
// Roles & Permission Matrix Module
roles: {
title: "Roles & Permission Matrix",
subtitle: "Define system user roles and configure granular permissions across all operational modules.",
createBtn: "Create New Role",
stats: {
total: "Total Roles",
totalDesc: "Roles in system",
system: "System Roles",
systemDesc: "Default protected roles",
custom: "Custom Roles",
customDesc: "User-defined business roles",
permissions: "Total Permissions",
permissionsDesc: "Available system permissions",
},
filters: {
searchPlaceholder: "Search roles by display name or slug...",
allStatus: "All Statuses",
statusActive: "Active",
statusInactive: "Inactive",
allTypes: "All Role Types",
typeSystem: "System Role",
typeCustom: "Custom Role",
viewCards: "Visual Cards",
viewTable: "Data Table",
},
table: {
name: "Role Name",
slug: "Identifier (Slug)",
description: "Description",
type: "Role Type",
permissionsCount: "Permissions",
usersCount: "Members",
status: "Status",
actions: "Actions",
systemBadge: "System",
customBadge: "Custom",
activeBadge: "Active",
inactiveBadge: "Inactive",
emptyTitle: "No roles found",
emptyDesc: "No roles matched your filter criteria or no roles created yet.",
emptyBtn: "Create new role",
errorTitle: "Failed to load roles list",
retry: "Retry",
permissionsUnit: "permissions",
membersUnit: "members",
},
card: {
permissionsTooltip: "Click to open Permission Matrix",
usersTooltip: "Click to view assigned members",
systemProtected: "Default system role protected from deletion",
},
modalForm: {
createTitle: "Create New Role",
createDesc: "Define a display name, unique identifier slug, and description for the new role.",
editTitle: "Edit Role",
editDesc: "Update display name, description, or operational status of this role.",
nameLabel: "Display Name",
namePlaceholder: "E.g., Data Extraction Specialist",
slugLabel: "Identifier (Slug)",
slugPlaceholder: "E.g., content_scraper or CRAWLER_OPERATOR",
slugHelper: "Used for code/API permission verification. Letters, numbers, underscores, and hyphens only.",
descLabel: "Detailed Description",
descPlaceholder: "Describe responsibilities and scope of this role...",
activeLabel: "Enable this role immediately",
cancel: "Cancel",
save: "Save Role",
saving: "Saving...",
},
matrix: {
title: "Permission Matrix",
subtitle: "Configure granular access permissions for role:",
selectAll: "Select All",
deselectAll: "Deselect All",
selectedSummary: "Granted permissions:",
searchPlaceholder: "Filter permissions by name or slug...",
saveBtn: "Save Changes",
saving: "Saving permissions...",
cancel: "Close",
modules: {
crawlJobs: "Crawl Jobs",
crawlSchedules: "Crawl Schedules",
extractionTemplates: "Extraction Templates",
exports: "Data Exports",
webhooks: "Webhooks",
apiKeys: "API Keys",
users: "User Management",
roles: "Roles & Permissions",
auditLogs: "Audit Logs",
dashboard: "Dashboard",
},
},
modalUsers: {
title: "Role Members",
subtitle: "User accounts currently assigned to this role:",
empty: "No users currently assigned to this role.",
userCol: "User",
emailCol: "Email",
statusCol: "Status",
close: "Close",
},
dialogDelete: {
title: "Confirm Role Deletion",
desc: "Are you sure you want to delete this role from the system? This action cannot be undone.",
hasUsersWarning: "Cannot delete this role because it is currently assigned to members. Please reassign those members first.",
isSystemWarning: "This is a default system role and cannot be deleted.",
cancel: "Cancel",
confirm: "Confirm Delete",
deleting: "Deleting...",
},
actions: {
edit: "Edit",
matrix: "Permissions",
viewUsers: "View Members",
delete: "Delete Role",
},
},
},
};
......
......@@ -16,6 +16,8 @@ const ADMIN_ROUTES = [
"/users",
"/audit-logs",
"/roles",
"/settings/developer/users",
"/settings/developer/roles",
];
// Open public pages that do NOT require login (e.g. 403 forbidden, 404 not-found)
......
......@@ -22,10 +22,15 @@ export const AUTH_QUERY_KEYS = {
export interface AuthContextType {
user: User | null;
role: UserRole | null;
roles: string[];
permissions: string[];
isAuthenticated: boolean;
isLoading: boolean;
isAdmin: boolean;
hasRole: (allowedRoles: UserRole[]) => boolean;
hasRole: (allowedRoles: (UserRole | string)[]) => boolean;
hasPermission: (permission: string) => boolean;
hasAnyPermission: (permissions: string[]) => boolean;
hasAllPermissions: (permissions: string[]) => boolean;
login: (input: LoginInput, rememberMe?: boolean) => Promise<BffLoginResponse>;
register: (input: RegisterInput) => Promise<User>;
logout: () => Promise<void>;
......@@ -54,14 +59,49 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const isAuthenticated = !!user && !isError;
const role: UserRole | null = (user?.role as UserRole) || null;
const isAdmin = role === "ADMIN";
const roles = user?.roles || (role ? [role.toLowerCase()] : []);
const permissions = user?.permissions || [];
const isAdmin = role === "ADMIN" || roles.includes("admin") || roles.includes("super_admin");
const hasPermission = useCallback(
(permission: string): boolean => {
// Super admin or admin bypass
if (isAdmin) {
return true;
}
return permissions.includes(permission);
},
[isAdmin, permissions]
);
const hasAnyPermission = useCallback(
(requiredPermissions: string[]): boolean => {
if (isAdmin) {
return true;
}
return requiredPermissions.some((p) => permissions.includes(p));
},
[isAdmin, permissions]
);
const hasAllPermissions = useCallback(
(requiredPermissions: string[]): boolean => {
if (isAdmin) {
return true;
}
return requiredPermissions.every((p) => permissions.includes(p));
},
[isAdmin, permissions]
);
const hasRole = useCallback(
(allowedRoles: UserRole[]): boolean => {
if (!role) return false;
return allowedRoles.includes(role);
(allowedRoles: (UserRole | string)[]): boolean => {
if (!role && roles.length === 0) return false;
return allowedRoles.some(
(r) => r === role || roles.includes(r.toLowerCase()) || roles.includes(r)
);
},
[role]
[role, roles]
);
const login = async (
......@@ -132,10 +172,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const contextValue: AuthContextType = {
user: user || null,
role,
roles,
permissions,
isAuthenticated,
isLoading,
isAdmin,
hasRole,
hasPermission,
hasAnyPermission,
hasAllPermissions,
login,
register,
logout,
......
......@@ -6,3 +6,5 @@ export * from "./export.schema";
export * from "./crawler.schema";
export * from "./developer.schema";
export * from "./profile.schema";
export * from "./user.schema";
export * from "./role.schema";
import { z } from "zod";
export const createRoleSchema = z.object({
name: z
.string()
.min(2, "Tên vai trò phải có ít nhất 2 ký tự")
.max(50, "Tên vai trò không được vượt quá 50 ký tự"),
slug: z
.string()
.min(2, "Mã vai trò phải có ít nhất 2 ký tự")
.max(50, "Mã vai trò không được vượt quá 50 ký tự")
.regex(
/^[a-zA-Z0-9_-]+$/,
"Mã vai trò chỉ được chứa chữ cái, số, dấu gạch dưới và gạch ngang"
),
description: z
.string()
.max(200, "Mô tả không được vượt quá 200 ký tự")
.optional()
.or(z.literal("")),
permissionIds: z.array(z.string()).optional(),
});
export type CreateRoleInput = z.infer<typeof createRoleSchema>;
export const updateRoleSchema = z.object({
name: z
.string()
.min(2, "Tên vai trò phải có ít nhất 2 ký tự")
.max(50, "Tên vai trò không được vượt quá 50 ký tự"),
description: z
.string()
.max(200, "Mô tả không được vượt quá 200 ký tự")
.optional()
.or(z.literal("")),
isActive: z.boolean().optional(),
});
export type UpdateRoleInput = z.infer<typeof updateRoleSchema>;
export const assignRolePermissionsSchema = z.object({
permissionIds: z.array(z.string()),
});
export type AssignRolePermissionsInput = z.infer<
typeof assignRolePermissionsSchema
>;
import { z } from "zod";
export const createUserSchema = z.object({
email: z
.string()
.min(1, "Vui lòng nhập địa chỉ email.")
.email("Email không đúng định dạng."),
password: z
.string()
.min(8, "Mật khẩu phải có ít nhất 8 ký tự.")
.max(128, "Mật khẩu không được quá 128 ký tự."),
fullName: z
.string()
.max(100, "Họ và tên không được quá 100 ký tự.")
.optional()
.or(z.literal("")),
role: z.enum(["ADMIN", "CRAWLER_USER", "VIEWER"]),
maxPagesLimit: z
.number()
.int()
.min(1, "Giới hạn trang tối thiểu là 1.")
.max(100000, "Giới hạn trang tối đa là 100,000.")
.optional(),
maxJobsPerDayLimit: z
.number()
.int()
.min(1, "Giới hạn job/ngày tối thiểu là 1.")
.max(10000, "Giới hạn job/ngày tối đa là 10,000.")
.optional(),
maxConcurrentJobsLimit: z
.number()
.int()
.min(1, "Giới hạn chạy đồng thời tối thiểu là 1.")
.max(100, "Giới hạn chạy đồng thời tối đa là 100.")
.optional(),
});
export type CreateUserInput = z.infer<typeof createUserSchema>;
export const updateUserSchema = z.object({
email: z
.string()
.email("Email không đúng định dạng.")
.optional()
.or(z.literal("")),
fullName: z
.string()
.max(100, "Họ và tên không được quá 100 ký tự.")
.optional()
.or(z.literal("")),
isActive: z.boolean(),
role: z.enum(["ADMIN", "CRAWLER_USER", "VIEWER"]).optional(),
maxPagesLimit: z
.number()
.int()
.min(1, "Giới hạn trang tối thiểu là 1.")
.max(100000, "Giới hạn trang tối đa là 100,000.")
.optional(),
maxJobsPerDayLimit: z
.number()
.int()
.min(1, "Giới hạn job/ngày tối thiểu là 1.")
.max(10000, "Giới hạn job/ngày tối đa là 10,000.")
.optional(),
maxConcurrentJobsLimit: z
.number()
.int()
.min(1, "Giới hạn chạy đồng thời tối thiểu là 1.")
.max(100, "Giới hạn chạy đồng thời tối đa là 100.")
.optional(),
});
export type UpdateUserInput = z.infer<typeof updateUserSchema>;
export const assignRolesSchema = z.object({
roleIds: z.array(z.string().uuid("Định dạng ID vai trò không hợp lệ.")),
});
export type AssignRolesInput = z.infer<typeof assignRolesSchema>;
import apiClient from "@/lib/api-client";
import { ApiResponse } from "@/types/api";
import { PermissionItem, PermissionQueryParams } from "@/types/permission";
export class PermissionService {
/**
* 9. GET /permissions - Lấy danh mục toàn bộ quyền hạn
*/
async getPermissions(
params?: PermissionQueryParams
): Promise<PermissionItem[]> {
const response = await apiClient.get<ApiResponse<PermissionItem[]>>(
"/permissions",
{ params }
);
if (response.data?.data && Array.isArray(response.data.data)) {
return response.data.data;
}
return [];
}
/**
* 10. GET /permissions/:id - Lấy chi tiết quyền hạn
*/
async getPermissionById(id: string): Promise<PermissionItem> {
const response = await apiClient.get<ApiResponse<PermissionItem>>(
`/permissions/${id}`
);
if (response.data?.data) {
return response.data.data;
}
throw new Error("Không tìm thấy quyền hạn");
}
}
export const permissionService = new PermissionService();
export default permissionService;
import apiClient from "@/lib/api-client";
import { ApiResponse } from "@/types/api";
import {
RoleItem,
RolesListResponse,
CreateRoleDto,
UpdateRoleDto,
RoleQueryParams,
RolePermissionItem,
RoleUserItem,
} from "@/types/role";
export class RoleService {
/**
* 1. GET /roles - Lấy danh sách vai trò
*/
async getRoles(params?: RoleQueryParams): Promise<RolesListResponse> {
const response = await apiClient.get<ApiResponse<RolesListResponse>>("/roles", {
params,
});
if (response.data?.data) {
return response.data.data;
}
return {
items: [],
meta: { total: 0, page: 1, limit: 100, totalPages: 0 },
};
}
/**
* 2. GET /roles/:id - Lấy chi tiết vai trò
*/
async getRoleById(id: string): Promise<RoleItem> {
const response = await apiClient.get<ApiResponse<RoleItem>>(`/roles/${id}`);
if (response.data?.data) {
return response.data.data;
}
throw new Error("Không tìm thấy vai trò");
}
/**
* 3. POST /roles - Tạo vai trò mới
*/
async createRole(dto: CreateRoleDto): Promise<RoleItem> {
const response = await apiClient.post<ApiResponse<RoleItem>>("/roles", dto);
if (response.data?.data) {
return response.data.data;
}
throw new Error("Không thể tạo vai trò mới");
}
/**
* 4. PATCH /roles/:id - Cập nhật vai trò
*/
async updateRole(id: string, dto: UpdateRoleDto): Promise<RoleItem> {
const response = await apiClient.patch<ApiResponse<RoleItem>>(`/roles/${id}`, dto);
if (response.data?.data) {
return response.data.data;
}
throw new Error("Không thể cập nhật vai trò");
}
/**
* 5. DELETE /roles/:id - Xóa vai trò
*/
async deleteRole(id: string): Promise<{ success: boolean; message: string }> {
const response = await apiClient.delete<ApiResponse<null>>(`/roles/${id}`);
return {
success: response.data?.success ?? true,
message: response.data?.message || "Đã xóa vai trò thành công",
};
}
/**
* 6. GET /roles/:id/permissions - Lấy danh sách quyền hạn của vai trò
*/
async getRolePermissions(roleId: string): Promise<RolePermissionItem[]> {
const response = await apiClient.get<ApiResponse<RolePermissionItem[]>>(
`/roles/${roleId}/permissions`
);
if (response.data?.data && Array.isArray(response.data.data)) {
return response.data.data;
}
return [];
}
/**
* 7. PUT /roles/:id/permissions - Lưu gán quyền cho vai trò
*/
async setRolePermissions(
roleId: string,
permissionIds: string[]
): Promise<RoleItem> {
const response = await apiClient.put<ApiResponse<RoleItem>>(
`/roles/${roleId}/permissions`,
{ permissionIds }
);
if (response.data?.data) {
return response.data.data;
}
throw new Error("Không thể lưu ma trận phân quyền");
}
/**
* 8. GET /roles/:id/users - Lấy danh sách người dùng thuộc vai trò
*/
async getRoleUsers(roleId: string): Promise<RoleUserItem[]> {
const response = await apiClient.get<ApiResponse<RoleUserItem[]>>(
`/roles/${roleId}/users`
);
if (response.data?.data && Array.isArray(response.data.data)) {
return response.data.data;
}
return [];
}
}
export const roleService = new RoleService();
export default roleService;
import apiClient from "@/lib/api-client";
import { ApiResponse } from "@/types/api";
import {
CreateUserDto,
UpdateUserDto,
UserAssignedRole,
UserItem,
UserQueryParams,
UsersListResponse,
} from "@/types/user";
export class UserService {
/**
* 1. GET /users - Lấy danh sách người dùng kèm phân trang và bộ lọc
*/
async getUsers(params?: UserQueryParams): Promise<UsersListResponse> {
const response = await apiClient.get<ApiResponse<UsersListResponse>>("/users", {
params,
});
if (response.data?.data) {
return response.data.data;
}
return {
items: [],
meta: { total: 0, page: 1, limit: 20, totalPages: 0 },
};
}
/**
* 2. GET /users/:id - Lấy thông tin chi tiết một người dùng
*/
async getUserById(id: string): Promise<UserItem> {
const response = await apiClient.get<ApiResponse<UserItem>>(`/users/${id}`);
if (response.data?.data) {
return response.data.data;
}
throw new Error("Không tìm thấy thông tin người dùng");
}
/**
* 3. POST /users - Tạo mới người dùng
*/
async createUser(dto: CreateUserDto): Promise<UserItem> {
const response = await apiClient.post<ApiResponse<UserItem>>("/users", dto);
if (response.data?.data) {
return response.data.data;
}
throw new Error("Không thể tạo người dùng mới");
}
/**
* 4. PUT /users/:id - Cập nhật thông tin và trạng thái (khóa/mở khóa)
*/
async updateUser(id: string, dto: UpdateUserDto): Promise<UserItem> {
const response = await apiClient.put<ApiResponse<UserItem>>(`/users/${id}`, dto);
if (response.data?.data) {
return response.data.data;
}
throw new Error("Không thể cập nhật thông tin người dùng");
}
/**
* 5. DELETE /users/:id - Xóa người dùng vĩnh viễn
*/
async deleteUser(id: string): Promise<{ success: boolean; message: string }> {
const response = await apiClient.delete<ApiResponse<null>>(`/users/${id}`);
return {
success: response.data?.success ?? true,
message: response.data?.message || "Đã xóa người dùng thành công",
};
}
/**
* 6. GET /users/:id/roles - Lấy danh sách các vai trò đang gán cho người dùng
*/
async getUserRoles(userId: string): Promise<UserAssignedRole[]> {
const response = await apiClient.get<ApiResponse<UserAssignedRole[]>>(
`/users/${userId}/roles`
);
if (response.data?.data && Array.isArray(response.data.data)) {
return response.data.data;
}
return [];
}
/**
* 7. PUT /users/:id/roles - Đồng bộ toàn bộ danh sách vai trò cho người dùng
*/
async assignUserRoles(
userId: string,
roleIds: string[]
): Promise<UserAssignedRole[]> {
const response = await apiClient.put<ApiResponse<UserAssignedRole[]>>(
`/users/${userId}/roles`,
{ roleIds }
);
if (response.data?.data && Array.isArray(response.data.data)) {
return response.data.data;
}
return [];
}
/**
* 8. POST /users/:id/roles/:roleId - Gán lẻ một vai trò
*/
async assignSingleRole(
userId: string,
roleId: string
): Promise<UserAssignedRole> {
const response = await apiClient.post<ApiResponse<UserAssignedRole>>(
`/users/${userId}/roles/${roleId}`
);
if (response.data?.data) {
return response.data.data;
}
throw new Error("Không thể gán vai trò");
}
/**
* 9. DELETE /users/:id/roles/:roleId - Thu hồi lẻ một vai trò
*/
async revokeSingleRole(
userId: string,
roleId: string
): Promise<UserAssignedRole> {
const response = await apiClient.delete<ApiResponse<UserAssignedRole>>(
`/users/${userId}/roles/${roleId}`
);
if (response.data?.data) {
return response.data.data;
}
throw new Error("Không thể thu hồi vai trò");
}
}
export const userService = new UserService();
export default userService;
......@@ -6,6 +6,8 @@ export interface User {
fullName: string | null;
avatarUrl: string | null;
role: UserRole;
roles?: string[];
permissions?: string[];
isActive: boolean;
maxPagesLimit: number;
maxJobsPerDayLimit: number;
......@@ -25,6 +27,8 @@ export interface AuthUserSummary {
fullName: string | null;
avatarUrl?: string | null;
role: string;
roles?: string[];
permissions?: string[];
}
export interface LoginResponse {
......@@ -39,6 +43,8 @@ export interface MeDto {
fullName: string | null;
avatarUrl: string | null;
role: string;
roles?: string[];
permissions?: string[];
isActive: boolean;
createdAt: string;
}
......
......@@ -8,3 +8,6 @@ export * from "./dashboard";
export * from "./crawler";
export * from "./system";
export * from "./developer";
export * from "./user";
export * from "./role";
export * from "./permission";
export interface PermissionItem {
id: string;
name: string;
slug: string;
description: string | null;
resource: string;
action: string;
isSystem: boolean;
createdAt: string;
updatedAt: string;
}
export interface PermissionQueryParams {
resource?: string;
search?: string;
}
export interface PermissionGroup {
resource: string;
name: string;
nameEn: string;
description: string;
permissions: PermissionItem[];
}
export interface RolePermissionItem {
id: string;
name: string;
slug: string;
resource: string;
action: string;
}
export interface RoleUserItem {
id: string;
email: string;
fullName: string | null;
avatarUrl: string | null;
isActive: boolean;
}
export interface RoleItem {
id: string;
name: string;
slug: string;
description: string | null;
isSystem: boolean;
isActive: boolean;
createdAt: string;
updatedAt: string;
permissions?: RolePermissionItem[];
userCount?: number;
}
export interface CreateRoleDto {
name: string;
slug: string;
description?: string;
permissionIds?: string[];
}
export interface UpdateRoleDto {
name?: string;
description?: string;
isActive?: boolean;
}
export interface AssignRolePermissionsDto {
permissionIds: string[];
}
export interface RoleQueryParams {
search?: string;
isSystem?: boolean | "true" | "false";
isActive?: boolean | "true" | "false";
page?: number;
limit?: number;
}
export interface RolesListResponse {
items: RoleItem[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}
export interface UserAssignedRole {
id: string;
name: string;
slug: string;
description: string | null;
isSystem: boolean;
isActive: boolean;
assignedAt?: string;
assignedBy?: string | null;
}
export interface UserItem {
id: string;
email: string;
fullName: string | null;
avatarUrl: string | null;
role: string;
isActive: boolean;
maxPagesLimit: number;
maxJobsPerDayLimit: number;
maxConcurrentJobsLimit: number;
createdAt: string;
updatedAt: string;
roles?: UserAssignedRole[];
}
export interface CreateUserDto {
email: string;
password: string;
fullName?: string;
avatarUrl?: string;
role?: string;
maxPagesLimit?: number;
maxJobsPerDayLimit?: number;
maxConcurrentJobsLimit?: number;
}
export interface UpdateUserDto {
email?: string;
fullName?: string;
avatarUrl?: string | null;
isActive?: boolean;
role?: string;
maxPagesLimit?: number;
maxJobsPerDayLimit?: number;
maxConcurrentJobsLimit?: number;
}
export interface UserQueryParams {
search?: string;
role?: string;
isActive?: boolean | "true" | "false";
page?: number;
limit?: number;
sortBy?: string;
order?: "asc" | "desc";
}
export interface PaginationMeta {
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface UsersListResponse {
items: UserItem[];
meta: PaginationMeta;
}
export type { RoleItem, RolesListResponse } from "./role";
export interface AssignUserRolesDto {
roleIds: string[];
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment