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>;
}
This diff is collapsed.
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>
);
}
This diff is collapsed.
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>
);
}
This diff is collapsed.
"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>
);
}
This diff is collapsed.
This diff is collapsed.
"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>
);
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
......@@ -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)
......
This diff is collapsed.
......@@ -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";
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
......@@ -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";
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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