Commit 68db3996 authored by ThinhNC's avatar ThinhNC

feat(rbac): implement dynamic permission gates, role matrix ui, and centralize constants

parent b3935c3d
......@@ -78,6 +78,12 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th
- Module giao dịch tự động định kỳ (Upgrade 6) có frontend tại `/recurring-transactions`, kết nối
CRUD, pause/resume, preview và history API; hỗ trợ tạo lịch trực tiếp hoặc chuyển từ subscription,
có loading/error/empty state và bản dịch Việt/Anh.
- **Dynamic RBAC & Phân quyền Frontend (Upgrade 7)**:
- Phân quyền dựa trên danh sách `user.permissions` động từ API `/auth/me` và JWT token.
- Sử dụng `<PermissionGate permission={PERMISSIONS.*} fallback={<AccessDenied />}>` để bảo vệ routes và ẩn/hiện action buttons trên UI.
- QUY TẮC BẮT BUỘC: Tất cả tên quyền (Permission names) PHẢI được import từ `@/common/constants/permission.constant.ts` (hoặc `@/common/constants`), TUYỆT ĐỐI KHÔNG hardcode chuỗi string permission rải rác trong code.
- Các vai trò hệ thống mặc định/bất biến được định nghĩa tập trung qua `SYSTEM_ROLES` trong `src/common/constants/system-role.constant.ts`.
- Màn hình quản lý phân quyền Claymorphism tại `/roles``/admin/roles` gồm: Danh sách Roles, Ma trận phân quyền theo Resource/Action, Thêm/Sửa/Xóa vai trò tùy chỉnh và Nhật ký kiểm toán (Audit Logs).
## Khi cập nhật file này
......
export * from './permission.constant';
export * from './system-role.constant';
export const PERMISSIONS = {
// USER
USER_READ: 'USER_READ',
USER_CREATE: 'USER_CREATE',
USER_UPDATE: 'USER_UPDATE',
USER_DELETE: 'USER_DELETE',
// ROLE
ROLE_READ: 'ROLE_READ',
ROLE_CREATE: 'ROLE_CREATE',
ROLE_UPDATE: 'ROLE_UPDATE',
ROLE_DELETE: 'ROLE_DELETE',
// PERMISSION
PERMISSION_READ: 'PERMISSION_READ',
ROLE_PERMISSION_ASSIGN: 'ROLE_PERMISSION_ASSIGN',
// AUDIT LOG
AUDIT_LOG_READ: 'AUDIT_LOG_READ',
// WALLET
WALLET_READ: 'WALLET_READ',
WALLET_CREATE: 'WALLET_CREATE',
WALLET_UPDATE: 'WALLET_UPDATE',
WALLET_DELETE: 'WALLET_DELETE',
// TRANSACTION
TRANSACTION_READ: 'TRANSACTION_READ',
TRANSACTION_CREATE: 'TRANSACTION_CREATE',
TRANSACTION_UPDATE: 'TRANSACTION_UPDATE',
TRANSACTION_DELETE: 'TRANSACTION_DELETE',
// TRANSFER
TRANSFER_READ: 'TRANSFER_READ',
TRANSFER_CREATE: 'TRANSFER_CREATE',
TRANSFER_DELETE: 'TRANSFER_DELETE',
// CATEGORY
CATEGORY_READ: 'CATEGORY_READ',
CATEGORY_CREATE: 'CATEGORY_CREATE',
CATEGORY_UPDATE: 'CATEGORY_UPDATE',
CATEGORY_DELETE: 'CATEGORY_DELETE',
// BUDGET
BUDGET_READ: 'BUDGET_READ',
BUDGET_CREATE: 'BUDGET_CREATE',
BUDGET_UPDATE: 'BUDGET_UPDATE',
BUDGET_DELETE: 'BUDGET_DELETE',
// SAVING GOAL
SAVING_GOAL_READ: 'SAVING_GOAL_READ',
SAVING_GOAL_CREATE: 'SAVING_GOAL_CREATE',
SAVING_GOAL_UPDATE: 'SAVING_GOAL_UPDATE',
SAVING_GOAL_DELETE: 'SAVING_GOAL_DELETE',
// REPORT
REPORT_READ: 'REPORT_READ',
// FORECAST
FORECAST_READ: 'FORECAST_READ',
// SIMULATION
SIMULATION_READ: 'SIMULATION_READ',
SIMULATION_EXECUTE: 'SIMULATION_EXECUTE',
// ANOMALY
ANOMALY_READ: 'ANOMALY_READ',
ANOMALY_EVALUATE: 'ANOMALY_EVALUATE',
// SUBSCRIPTION
SUBSCRIPTION_READ: 'SUBSCRIPTION_READ',
SUBSCRIPTION_MANAGE: 'SUBSCRIPTION_MANAGE',
// QUERY
QUERY_EXECUTE: 'QUERY_EXECUTE',
// RECURRING TRANSACTION
RECURRING_TRANSACTION_READ: 'RECURRING_TRANSACTION_READ',
RECURRING_TRANSACTION_CREATE: 'RECURRING_TRANSACTION_CREATE',
RECURRING_TRANSACTION_UPDATE: 'RECURRING_TRANSACTION_UPDATE',
RECURRING_TRANSACTION_DELETE: 'RECURRING_TRANSACTION_DELETE',
// NOTIFICATION
NOTIFICATION_READ: 'NOTIFICATION_READ',
NOTIFICATION_UPDATE: 'NOTIFICATION_UPDATE',
// REMINDER
REMINDER_READ: 'REMINDER_READ',
REMINDER_CREATE: 'REMINDER_CREATE',
REMINDER_UPDATE: 'REMINDER_UPDATE',
REMINDER_DELETE: 'REMINDER_DELETE',
// AI ASSISTANT
AI_ASSISTANT_USE: 'AI_ASSISTANT_USE',
// UPLOAD
UPLOAD_FILE: 'UPLOAD_FILE',
} as const;
export type PermissionName = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
export const SYSTEM_ROLES = {
ADMIN: 'ADMIN',
USER: 'USER',
MANAGER: 'MANAGER',
} as const;
export type SystemRole = (typeof SYSTEM_ROLES)[keyof typeof SYSTEM_ROLES];
......@@ -43,6 +43,10 @@ import AnomaliesPage from "@/pages/anomalies/index";
import SubscriptionsPage from "@/pages/subscriptions/index";
import QueryPage from "@/pages/query/index";
import RecurringTransactionsPage from "@/pages/recurring-transactions/index";
import RolesPage from "@/pages/admin/roles/index";
import { PermissionGate } from "@/components/shared/PermissionGate";
import { AccessDenied } from "@/components/shared/AccessDenied";
import { PERMISSIONS } from "@/common/constants";
const AuthInitializer: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { setAuth, clearAuth, setInitialized } = useAuthStore();
......@@ -57,7 +61,7 @@ const AuthInitializer: React.FC<{ children: React.ReactNode }> = ({ children })
} else {
clearAuth();
}
} catch (error) {
} catch {
clearAuth();
} finally {
setInitialized(true);
......@@ -65,12 +69,12 @@ const AuthInitializer: React.FC<{ children: React.ReactNode }> = ({ children })
};
initAuth();
}, [setAuth, clearAuth, setInitialized]);
}, [clearAuth, setAuth, setInitialized]);
return <>{children}</>;
};
const Layout = () => {
export const Layout = () => {
const theme = useThemeStore((state) => state.theme);
useEffect(() => {
......@@ -79,12 +83,12 @@ const Layout = () => {
return (
<I18nProvider>
<App theme={theme}>
<App>
<QueryClientProvider client={queryClient}>
<SnackbarProvider>
<ZMPRouter>
<DocumentTitle />
<div className="finwise-header-controls fixed z-[1001] flex items-center gap-2">
<div className="clay-theme-trigger-container">
<NotificationShortcut />
<ThemeControl />
<LanguageSwitcher />
......@@ -100,24 +104,26 @@ const Layout = () => {
{/* Private Protected Routes */}
<Route path="/" element={<AuthGuard><HomePage /></AuthGuard>}></Route>
<Route path="/profile" element={<AuthGuard><ProfilePage /></AuthGuard>}></Route>
<Route path="/wallets" element={<AuthGuard><WalletsPage /></AuthGuard>}></Route>
<Route path="/wallets/:id" element={<AuthGuard><WalletDetailPage /></AuthGuard>}></Route>
<Route path="/categories" element={<AuthGuard><CategoriesPage /></AuthGuard>}></Route>
<Route path="/transactions" element={<AuthGuard><TransactionsPage /></AuthGuard>}></Route>
<Route path="/transfers" element={<AuthGuard><TransfersPage /></AuthGuard>}></Route>
<Route path="/budgets" element={<AuthGuard><BudgetsPage /></AuthGuard>}></Route>
<Route path="/budgets/:id" element={<AuthGuard><BudgetDetailPage /></AuthGuard>}></Route>
<Route path="/saving-goals" element={<AuthGuard><SavingGoalsPage /></AuthGuard>}></Route>
<Route path="/saving-goals/:id" element={<AuthGuard><SavingGoalDetailPage /></AuthGuard>}></Route>
<Route path="/reports" element={<AuthGuard><ReportsPage /></AuthGuard>}></Route>
<Route path="/forecast" element={<AuthGuard><ForecastPage /></AuthGuard>}></Route>
<Route path="/simulations" element={<AuthGuard><SimulationsPage /></AuthGuard>}></Route>
<Route path="/anomalies" element={<AuthGuard><AnomaliesPage /></AuthGuard>}></Route>
<Route path="/subscriptions" element={<AuthGuard><SubscriptionsPage /></AuthGuard>}></Route>
<Route path="/recurring-transactions" element={<AuthGuard><RecurringTransactionsPage /></AuthGuard>}></Route>
<Route path="/query" element={<AuthGuard><QueryPage /></AuthGuard>}></Route>
<Route path="/notifications" element={<AuthGuard><NotificationsPage /></AuthGuard>}></Route>
<Route path="/ai-assistant" element={<AuthGuard><AIAssistantPage /></AuthGuard>}></Route>
<Route path="/wallets" element={<AuthGuard><PermissionGate permission={PERMISSIONS.WALLET_READ} fallback={<AccessDenied />}><WalletsPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/wallets/:id" element={<AuthGuard><PermissionGate permission={PERMISSIONS.WALLET_READ} fallback={<AccessDenied />}><WalletDetailPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/categories" element={<AuthGuard><PermissionGate permission={PERMISSIONS.CATEGORY_READ} fallback={<AccessDenied />}><CategoriesPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/transactions" element={<AuthGuard><PermissionGate permission={PERMISSIONS.TRANSACTION_READ} fallback={<AccessDenied />}><TransactionsPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/transfers" element={<AuthGuard><PermissionGate permission={PERMISSIONS.TRANSFER_READ} fallback={<AccessDenied />}><TransfersPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/budgets" element={<AuthGuard><PermissionGate permission={PERMISSIONS.BUDGET_READ} fallback={<AccessDenied />}><BudgetsPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/budgets/:id" element={<AuthGuard><PermissionGate permission={PERMISSIONS.BUDGET_READ} fallback={<AccessDenied />}><BudgetDetailPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/saving-goals" element={<AuthGuard><PermissionGate permission={PERMISSIONS.SAVING_GOAL_READ} fallback={<AccessDenied />}><SavingGoalsPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/saving-goals/:id" element={<AuthGuard><PermissionGate permission={PERMISSIONS.SAVING_GOAL_READ} fallback={<AccessDenied />}><SavingGoalDetailPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/reports" element={<AuthGuard><PermissionGate permission={PERMISSIONS.REPORT_READ} fallback={<AccessDenied />}><ReportsPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/forecast" element={<AuthGuard><PermissionGate permission={PERMISSIONS.FORECAST_READ} fallback={<AccessDenied />}><ForecastPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/simulations" element={<AuthGuard><PermissionGate permission={PERMISSIONS.SIMULATION_READ} fallback={<AccessDenied />}><SimulationsPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/anomalies" element={<AuthGuard><PermissionGate permission={PERMISSIONS.ANOMALY_READ} fallback={<AccessDenied />}><AnomaliesPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/subscriptions" element={<AuthGuard><PermissionGate permission={PERMISSIONS.SUBSCRIPTION_READ} fallback={<AccessDenied />}><SubscriptionsPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/recurring-transactions" element={<AuthGuard><PermissionGate permission={PERMISSIONS.RECURRING_TRANSACTION_READ} fallback={<AccessDenied />}><RecurringTransactionsPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/query" element={<AuthGuard><PermissionGate permission={PERMISSIONS.QUERY_EXECUTE} fallback={<AccessDenied />}><QueryPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/notifications" element={<AuthGuard><PermissionGate permission={PERMISSIONS.NOTIFICATION_READ} fallback={<AccessDenied />}><NotificationsPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/roles" element={<AuthGuard><PermissionGate permission={PERMISSIONS.ROLE_READ} fallback={<AccessDenied />}><RolesPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/admin/roles" element={<AuthGuard><PermissionGate permission={PERMISSIONS.ROLE_READ} fallback={<AccessDenied />}><RolesPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/ai-assistant" element={<AuthGuard><PermissionGate permission={PERMISSIONS.AI_ASSISTANT_USE} fallback={<AccessDenied />}><AIAssistantPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/style-guide" element={<AuthGuard><StyleGuidePage /></AuthGuard>}></Route>
</AnimationRoutes>
</AuthInitializer>
......
import React from 'react';
import { Page, Header, useNavigate } from 'zmp-ui';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { LockIcon, IconGradients } from '@/components/ui/icons';
import { useI18n } from '@/i18n';
export interface AccessDeniedProps {
title?: string;
description?: string;
}
export const AccessDenied: React.FC<AccessDeniedProps> = ({
title,
description,
}) => {
const navigate = useNavigate();
const { t } = useI18n();
return (
<Page className="page">
<Header title={t('common.error') || 'Truy cập'} showBackIcon onBackClick={() => navigate('/')} />
<IconGradients />
<div className="flex-1 flex flex-col items-center justify-center p-6 text-center">
<Card className="w-full max-w-sm flex flex-col items-center gap-4 py-8 px-6">
<div className="w-16 h-16 rounded-full bg-clay-expense/15 text-clay-expense flex items-center justify-center shadow-clay-pressed">
<LockIcon size={32} />
</div>
<div>
<h2 className="clay-title-h2 text-clay-expense mb-1">
{title || t('rbac.accessDenied') || 'Không có quyền truy cập'}
</h2>
<p className="clay-caption text-xs leading-relaxed">
{description ||
t('rbac.accessDeniedDesc') ||
'Bạn không có quyền hạn để truy cập hoặc thực hiện thao tác này. Vui lòng liên hệ Quản trị viên để được cấp quyền.'}
</p>
</div>
<Button
variant="primary"
fullWidth
onClick={() => navigate('/')}
className="mt-2"
>
{t('common.backToHome') || 'Về Trang chủ'}
</Button>
</Card>
</div>
</Page>
);
};
......@@ -31,6 +31,7 @@ const PAGE_TITLES: ReadonlyArray<{
{ matches: (pathname) => pathname === "/recurring-transactions", key: "document.recurringTransactions" },
{ matches: (pathname) => pathname === "/query", key: "document.query" },
{ matches: (pathname) => pathname === "/notifications", key: "document.notifications" },
{ matches: (pathname) => pathname === "/roles" || pathname === "/admin/roles", key: "document.roles" },
{ matches: (pathname) => pathname === "/ai-assistant", key: "document.aiAssistant" },
{ matches: (pathname) => pathname === "/style-guide", key: "document.styleGuide" },
];
......
import React from 'react';
import { PermissionName } from '@/common/constants';
import { usePermission } from '@/hooks/use-permission';
export interface PermissionGateProps {
permission?: PermissionName | string;
permissions?: (PermissionName | string)[];
mode?: 'all' | 'any';
fallback?: React.ReactNode;
children: React.ReactNode;
}
export const PermissionGate: React.FC<PermissionGateProps> = ({
permission,
permissions,
mode = 'all',
fallback = null,
children,
}) => {
const { hasPermission, hasAnyPermission, hasAllPermissions } = usePermission();
let isAllowed = false;
if (permission) {
isAllowed = hasPermission(permission);
} else if (permissions && permissions.length > 0) {
isAllowed =
mode === 'any' ? hasAnyPermission(permissions) : hasAllPermissions(permissions);
} else {
// If no permission specified, allow by default
isAllowed = true;
}
if (!isAllowed) {
return <>{fallback}</>;
}
return <>{children}</>;
};
......@@ -225,3 +225,41 @@ export const TrashIcon: React.FC<IconProps> = ({ size = 20, ...props }) => (
<path d="M4 7h16M9 7V4h6v3M7 7l1 14h8l1-14M10 11v6M14 11v6" />
</svg>
);
// Shield (Security / RBAC)
export const ShieldIcon: React.FC<IconProps> = ({ size = 24, ...props }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" filter="url(#clay-3d-shadow)" {...props}>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" fill="url(#clay-grad-primary)" stroke="rgb(var(--color-clay-primary-dark))" />
</svg>
);
// Lock (Security / Protected)
export const LockIcon: React.FC<IconProps> = ({ size = 20, ...props }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" {...props}>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" fill="url(#clay-grad-warning)" fillOpacity="0.3" stroke="rgb(var(--color-clay-warning))" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
);
// Key (Permission / Authorization)
export const KeyIcon: React.FC<IconProps> = ({ size = 20, ...props }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" {...props}>
<path d="M21 2l-2 2m-1.5 1.5L14 9l-1.5-1.5L11 9l1.5 1.5-1.5 1.5L9.5 10.5 8 12l2 2-6.5 6.5a3 3 0 1 1-4.24-4.24L5.76 9.76a7 7 0 1 1 9.9 0L17.5 7.9" />
</svg>
);
// Check (Active / Granted)
export const CheckIcon: React.FC<IconProps> = ({ size = 16, ...props }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" {...props}>
<polyline points="20 6 9 17 4 12" />
</svg>
);
// Edit
export const EditIcon: React.FC<IconProps> = ({ size = 18, ...props }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" {...props}>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
);
export * from '@/common/constants/permission.constant';
import { useMemo, useCallback } from 'react';
import { useAuthStore } from '@/stores/auth-store';
import { PERMISSIONS, PermissionName } from '@/common/constants';
export function usePermission() {
const user = useAuthStore((state) => state.user);
const permissions = useMemo(() => {
return user?.permissions || [];
}, [user?.permissions]);
const hasPermission = useCallback(
(permission: PermissionName | string): boolean => {
if (!user) return false;
return permissions.includes(permission);
},
[user, permissions]
);
const hasAnyPermission = useCallback(
(perms: (PermissionName | string)[]): boolean => {
if (!user) return false;
return perms.some((p) => permissions.includes(p));
},
[user, permissions]
);
const hasAllPermissions = useCallback(
(perms: (PermissionName | string)[]): boolean => {
if (!user) return false;
return perms.every((p) => permissions.includes(p));
},
[user, permissions]
);
const isAdmin = useMemo(() => {
return hasPermission(PERMISSIONS.ROLE_PERMISSION_ASSIGN);
}, [hasPermission]);
return {
user,
permissions,
hasPermission,
hasAnyPermission,
hasAllPermissions,
isAdmin,
};
}
......@@ -19,7 +19,8 @@
"archived": "Archived",
"default": "Default",
"results": "{{count}} results",
"pageOf": "{{page}} / {{total}}"
"pageOf": "{{page}} / {{total}}",
"backToHome": "Back to Home"
},
"language": {
"label": "Language",
......@@ -61,6 +62,7 @@
"recurringTransactions": "Automated Recurring Transactions",
"query": "Smart Natural Language Query DSL",
"notifications": "Notifications & Reminders",
"roles": "Roles & Permissions",
"aiAssistant": "AI Financial Assistant"
},
"validation": {
......@@ -201,17 +203,26 @@
"account": "Account:",
"authTitle": "System authentication",
"authDescription": "You are signed in. Upcoming API requests will be authenticated automatically using secure cookies and JWT.",
"aiAssistant": "AI Financial Assistant",
"smartQuery": "Smart Natural Language Query",
"advancedFeatures": "Advanced Intelligence Features",
"forecast": "Cash Flow Forecast",
"simulations": "What-if Simulation",
"anomalies": "Anomaly Detection",
"subscriptions": "Recurring Subscriptions",
"coreModules": "Core Financial Modules",
"wallets": "Manage wallets",
"categories": "Manage categories",
"profile": "My profile",
"styleGuide": "Explore Style Guide",
"transactions": "Manage transactions",
"recurringTransactions": "Automated Recurring Transactions",
"transfers": "Transfer between wallets",
"budgets": "Manage spending budgets",
"savingGoals": "Manage saving goals",
"reports": "Financial reports & analytics",
"notifications": "Notifications & reminders",
"aiAssistant": "AI Financial Assistant"
"rolesManagement": "Roles & RBAC Management",
"profile": "My profile",
"styleGuide": "Explore Style Guide"
},
"profile": {
"header": "Account",
......@@ -1637,5 +1648,41 @@
"tx": "transactions",
"breakdownTitle": "Grouped Breakdown",
"detailedItemsTitle": "Detailed Matching Transactions"
},
"rbac": {
"header": "Dynamic RBAC & Roles",
"title": "Dynamic RBAC Management",
"subtitle": "Role-Based Access Control and Permission Matrix Management",
"searchRolePlaceholder": "Search roles...",
"createRole": "Create Role",
"createRoleTitle": "Create New Role",
"editRoleTitle": "Edit Role",
"deleteRoleTitle": "Confirm Role Deletion",
"matrixTitle": "Permission Matrix",
"roleNameLabel": "Role Name (UPPERCASE)",
"roleDescLabel": "Role Description",
"systemRole": "System",
"customRole": "Custom",
"usersCount": "{{count}} users",
"permsCount": "{{count}} permissions",
"noDescription": "No description available for this role.",
"editMatrix": "Permission Matrix",
"noRolesFound": "No matching roles found.",
"noAuditLogs": "No audit records found.",
"target": "Target",
"selectedCount": "Selected: {{count}} permissions",
"checkAll": "Select All",
"uncheckAll": "Deselect All",
"createSuccess": "Role created successfully.",
"updateSuccess": "Role updated successfully.",
"deleteSuccess": "Role deleted successfully.",
"assignSuccess": "Permission matrix updated successfully.",
"deleteConfirm": "Are you sure you want to delete role \"{{name}}\"? This action cannot be undone.",
"tabs": {
"roles": "Roles & Permissions",
"audit": "Audit Logs"
},
"accessDenied": "Access Denied",
"accessDeniedDesc": "You do not have the required permissions to access or perform actions on this resource. Please contact your administrator."
}
}
......@@ -19,7 +19,8 @@
"archived": "Đã lưu trữ",
"default": "Mặc định",
"results": "{{count}} kết quả",
"pageOf": "{{page}} / {{total}}"
"pageOf": "{{page}} / {{total}}",
"backToHome": "Về Trang chủ"
},
"language": {
"label": "Ngôn ngữ",
......@@ -61,6 +62,7 @@
"recurringTransactions": "Giao dịch Tự động Định kỳ",
"query": "Truy vấn Thông minh DSL",
"notifications": "Thông báo & nhắc nhở",
"roles": "Phân quyền & Vai trò",
"aiAssistant": "Trợ lý tài chính AI"
},
"validation": {
......@@ -201,17 +203,26 @@
"account": "Tài khoản:",
"authTitle": "Xác thực hệ thống",
"authDescription": "Bạn đã đăng nhập thành công. Các API tiếp theo sẽ tự động được xác thực nhờ cookie an toàn và cơ chế JWT.",
"aiAssistant": "Trợ lý tài chính AI",
"smartQuery": "Truy vấn thông minh DSL",
"advancedFeatures": "Tính năng Phân tích Nâng cao",
"forecast": "Dự báo dòng tiền",
"simulations": "Mô phỏng kịch bản",
"anomalies": "Cảnh báo bất thường",
"subscriptions": "Hóa đơn định kỳ",
"coreModules": "Các mô-đun tài chính cốt lõi",
"wallets": "Quản lý ví",
"categories": "Quản lý danh mục thu chi",
"profile": "Trang cá nhân của tôi",
"styleGuide": "Khám phá Style Guide",
"transactions": "Quản lý giao dịch thu chi",
"recurringTransactions": "Giao dịch tự động định kỳ",
"transfers": "Chuyển tiền giữa các ví",
"budgets": "Quản lý ngân sách chi tiêu",
"savingGoals": "Quản lý mục tiêu tiết kiệm",
"reports": "Báo cáo & phân tích tài chính",
"notifications": "Thông báo & nhắc nhở",
"aiAssistant": "Trợ lý tài chính AI"
"rolesManagement": "Phân quyền & Vai trò",
"profile": "Trang cá nhân của tôi",
"styleGuide": "Khám phá Style Guide"
},
"profile": {
"header": "Tài Khoản",
......@@ -1738,5 +1749,41 @@
"tx": "giao dịch",
"breakdownTitle": "Phân tích theo Nhóm",
"detailedItemsTitle": "Danh sách Giao dịch Chi tiết"
},
"rbac": {
"header": "Phân quyền & Vai trò",
"title": "Quản lý Dynamic RBAC",
"subtitle": "Kiểm soát truy cập dựa trên vai trò & ma trận quyền hạn",
"searchRolePlaceholder": "Tìm kiếm vai trò...",
"createRole": "Thêm vai trò",
"createRoleTitle": "Thêm vai trò mới",
"editRoleTitle": "Chỉnh sửa vai trò",
"deleteRoleTitle": "Xác nhận xóa vai trò",
"matrixTitle": "Ma trận quyền hạn",
"roleNameLabel": "Tên vai trò (IN HOA)",
"roleDescLabel": "Mô tả vai trò",
"systemRole": "Hệ thống",
"customRole": "Tùy chỉnh",
"usersCount": "{{count}} người dùng",
"permsCount": "{{count}} quyền",
"noDescription": "Chưa có mô tả cho vai trò này.",
"editMatrix": "Ma trận quyền",
"noRolesFound": "Không tìm thấy vai trò phù hợp.",
"noAuditLogs": "Chưa có bản ghi kiểm toán nào.",
"target": "Đối tượng",
"selectedCount": "Đã chọn: {{count}} quyền",
"checkAll": "Chọn tất cả",
"uncheckAll": "Bỏ chọn tất cả",
"createSuccess": "Tạo vai trò thành công.",
"updateSuccess": "Cập nhật vai trò thành công.",
"deleteSuccess": "Xóa vai trò thành công.",
"assignSuccess": "Cập nhật ma trận quyền thành công.",
"deleteConfirm": "Bạn có chắc chắn muốn xóa vai trò \"{{name}}\" không? Thao tác này không thể hoàn tác.",
"tabs": {
"roles": "Vai trò & Quyền",
"audit": "Nhật ký kiểm toán"
},
"accessDenied": "Không có quyền truy cập",
"accessDeniedDesc": "Bạn không có quyền hạn để truy cập hoặc thực hiện thao tác này. Vui lòng liên hệ Quản trị viên để được cấp quyền."
}
}
import React, { useState, useMemo } from 'react';
import { Page, Header, useNavigate, useSnackbar } from 'zmp-ui';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { rbacService } from '@/services/rbac.service';
import { Role, Permission, CreateRoleRequest, UpdateRoleRequest } from '@/types/auth';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Input } from '@/components/ui/Input';
import { Badge } from '@/components/ui/Badge';
import { Modal } from '@/components/ui/Modal';
import { Tabs } from '@/components/ui/Tabs';
import {
ShieldIcon,
LockIcon,
KeyIcon,
PlusIcon,
TrashIcon,
EditIcon,
CheckIcon,
IconGradients,
} from '@/components/ui/icons';
import { usePermission } from '@/hooks/use-permission';
import { useI18n } from '@/i18n';
import { getErrorMessage } from '@/lib/error-message';
import { authService } from '@/services/auth.service';
import { useAuthStore } from '@/stores/auth-store';
import { PERMISSIONS } from '@/common/constants';
const ACTION_COLORS: Record<string, string> = {
READ: 'bg-clay-info/15 text-clay-info border-clay-info/30',
CREATE: 'bg-clay-income/15 text-clay-income border-clay-income/30',
UPDATE: 'bg-clay-warning/15 text-clay-warning border-clay-warning/30',
DELETE: 'bg-clay-expense/15 text-clay-expense border-clay-expense/30',
EXECUTE: 'bg-clay-primary/15 text-clay-primary border-clay-primary/30',
ASSIGN: 'bg-purple-500/15 text-purple-600 border-purple-500/30',
USE: 'bg-indigo-500/15 text-indigo-600 border-indigo-500/30',
MANAGE: 'bg-teal-500/15 text-teal-600 border-teal-500/30',
EVALUATE: 'bg-amber-500/15 text-amber-600 border-amber-500/30',
};
const RolesPage: React.FC = () => {
const navigate = useNavigate();
const { openSnackbar } = useSnackbar();
const queryClient = useQueryClient();
const { t, formatDate } = useI18n();
const { hasPermission } = usePermission();
const canCreateRole = hasPermission(PERMISSIONS.ROLE_CREATE);
const canUpdateRole = hasPermission(PERMISSIONS.ROLE_UPDATE);
const canDeleteRole = hasPermission(PERMISSIONS.ROLE_DELETE);
const canAssignPermissions = hasPermission(PERMISSIONS.ROLE_PERMISSION_ASSIGN);
const canReadAuditLogs = hasPermission(PERMISSIONS.AUDIT_LOG_READ);
const [activeTab, setActiveTab] = useState('roles');
const [selectedRole, setSelectedRole] = useState<Role | null>(null);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isMatrixModalOpen, setIsMatrixModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
// Forms state
const [newRoleName, setNewRoleName] = useState('');
const [newRoleDesc, setNewRoleDesc] = useState('');
const [editRoleName, setEditRoleName] = useState('');
const [editRoleDesc, setEditRoleDesc] = useState('');
const [selectedPermissionIds, setSelectedPermissionIds] = useState<string[]>([]);
const [searchRoleQuery, setSearchRoleQuery] = useState('');
// Queries
const { data: rolesResponse, isLoading: rolesLoading, error: rolesError } = useQuery({
queryKey: ['rbac-roles'],
queryFn: () => rbacService.getRoles({ limit: 100 }),
});
const { data: permissionsResponse, isLoading: permissionsLoading } = useQuery({
queryKey: ['rbac-permissions'],
queryFn: () => rbacService.getPermissions(),
});
const { data: auditLogsResponse, isLoading: auditLoading } = useQuery({
queryKey: ['rbac-audit-logs'],
queryFn: () => rbacService.getAuditLogs({ limit: 50 }),
enabled: activeTab === 'audit',
});
const roles = useMemo(() => rolesResponse?.data || [], [rolesResponse]);
const permissions = useMemo(() => permissionsResponse?.data || [], [permissionsResponse]);
// Group permissions by resource
const permissionsByResource = useMemo(() => {
const map = new Map<string, Permission[]>();
for (const p of permissions) {
const list = map.get(p.resource) || [];
list.push(p);
map.set(p.resource, list);
}
return map;
}, [permissions]);
const filteredRoles = useMemo(() => {
if (!searchRoleQuery.trim()) return roles;
const q = searchRoleQuery.toLowerCase();
return roles.filter(
(r) => r.name.toLowerCase().includes(q) || (r.description && r.description.toLowerCase().includes(q))
);
}, [roles, searchRoleQuery]);
// Mutations
const createRoleMutation = useMutation({
mutationFn: (data: CreateRoleRequest) => rbacService.createRole(data),
onSuccess: () => {
openSnackbar({ type: 'success', text: t('rbac.createSuccess') || 'Tạo vai trò thành công' });
queryClient.invalidateQueries({ queryKey: ['rbac-roles'] });
setIsCreateModalOpen(false);
setNewRoleName('');
setNewRoleDesc('');
},
onError: (err) => {
openSnackbar({ type: 'error', text: getErrorMessage(err, 'Không thể tạo vai trò') });
},
});
const updateRoleMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateRoleRequest }) =>
rbacService.updateRole(id, data),
onSuccess: () => {
openSnackbar({ type: 'success', text: t('rbac.updateSuccess') || 'Cập nhật vai trò thành công' });
queryClient.invalidateQueries({ queryKey: ['rbac-roles'] });
setIsEditModalOpen(false);
},
onError: (err) => {
openSnackbar({ type: 'error', text: getErrorMessage(err, 'Không thể cập nhật vai trò') });
},
});
const deleteRoleMutation = useMutation({
mutationFn: (id: string) => rbacService.deleteRole(id),
onSuccess: () => {
openSnackbar({ type: 'success', text: t('rbac.deleteSuccess') || 'Xóa vai trò thành công' });
queryClient.invalidateQueries({ queryKey: ['rbac-roles'] });
setIsDeleteModalOpen(false);
setSelectedRole(null);
},
onError: (err) => {
openSnackbar({ type: 'error', text: getErrorMessage(err, 'Không thể xóa vai trò') });
},
});
const assignPermissionsMutation = useMutation({
mutationFn: ({ roleId, permissionIds }: { roleId: string; permissionIds: string[] }) =>
rbacService.assignRolePermissions(roleId, { permissionIds }),
onSuccess: async () => {
openSnackbar({
type: 'success',
text: t('rbac.assignSuccess') || 'Cập nhật ma trận quyền thành công',
});
queryClient.invalidateQueries({ queryKey: ['rbac-roles'] });
try {
const meRes = await authService.getMe();
if (meRes?.success && meRes?.data) {
useAuthStore.getState().setUser(meRes.data);
}
} catch {
// ignore
}
setIsMatrixModalOpen(false);
},
onError: (err) => {
openSnackbar({ type: 'error', text: getErrorMessage(err, 'Không thể gán quyền hạn') });
},
});
// Handlers
const handleOpenMatrix = (role: Role) => {
setSelectedRole(role);
const existingPermIds = (role.rolePermissions || []).map((rp) => rp.permissionId || rp.permission?.id || '');
setSelectedPermissionIds(existingPermIds.filter(Boolean));
setIsMatrixModalOpen(true);
};
const handleOpenEdit = (role: Role) => {
setSelectedRole(role);
setEditRoleName(role.name);
setEditRoleDesc(role.description || '');
setIsEditModalOpen(true);
};
const handleOpenDelete = (role: Role) => {
setSelectedRole(role);
setIsDeleteModalOpen(true);
};
const togglePermission = (id: string) => {
setSelectedPermissionIds((prev) =>
prev.includes(id) ? prev.filter((p) => p !== id) : [...prev, id]
);
};
const toggleAllResourcePermissions = (resourcePermissions: Permission[]) => {
const resIds = resourcePermissions.map((p) => p.id);
const allSelected = resIds.every((id) => selectedPermissionIds.includes(id));
if (allSelected) {
setSelectedPermissionIds((prev) => prev.filter((id) => !resIds.includes(id)));
} else {
setSelectedPermissionIds((prev) => Array.from(new Set([...prev, ...resIds])));
}
};
const handleSaveMatrix = () => {
if (!selectedRole) return;
assignPermissionsMutation.mutate({
roleId: selectedRole.id,
permissionIds: selectedPermissionIds,
});
};
const handleCreateRole = (e: React.FormEvent) => {
e.preventDefault();
if (!newRoleName.trim()) return;
createRoleMutation.mutate({
name: newRoleName.trim().toUpperCase(),
description: newRoleDesc.trim() || undefined,
});
};
const handleUpdateRole = (e: React.FormEvent) => {
e.preventDefault();
if (!selectedRole) return;
updateRoleMutation.mutate({
id: selectedRole.id,
data: {
name: editRoleName.trim().toUpperCase() || undefined,
description: editRoleDesc.trim() || undefined,
},
});
};
const tabs = [
{ key: 'roles', label: t('rbac.tabs.roles') || 'Vai trò & Quyền' },
...(canReadAuditLogs ? [{ key: 'audit', label: t('rbac.tabs.audit') || 'Nhật ký kiểm toán' }] : []),
];
return (
<Page className="page">
<Header
title={t('rbac.header') || 'Phân quyền & Vai trò'}
showBackIcon={true}
onBackClick={() => navigate('/')}
/>
<IconGradients />
<div className="flex flex-col gap-5 mt-4 pb-20">
{/* Banner Header Card */}
<Card className="flex items-center gap-4 py-4 bg-clay-surface border border-clay-highlight/60 shadow-clay-raised">
<div className="p-3 bg-clay-primary/10 rounded-full text-clay-primary flex-none">
<ShieldIcon size={32} />
</div>
<div className="flex-1 min-w-0">
<h1 className="clay-title-h2 text-clay-primary truncate">
{t('rbac.title') || 'Quản lý Dynamic RBAC'}
</h1>
<p className="clay-caption">
{t('rbac.subtitle') || 'Kiểm soát truy cập dựa trên vai trò & ma trận quyền hạn'}
</p>
</div>
</Card>
{/* Navigation Tabs */}
{tabs.length > 1 && (
<Tabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} />
)}
{/* ======================================================== */}
{/* TAB 1: ROLES MANAGEMENT & PERMISSION MATRIX */}
{/* ======================================================== */}
{activeTab === 'roles' && (
<div className="flex flex-col gap-4">
{/* Action Bar: Search & Create */}
<div className="flex items-center gap-3">
<div className="flex-1">
<Input
placeholder={t('rbac.searchRolePlaceholder') || 'Tìm kiếm vai trò...'}
value={searchRoleQuery}
onChange={(e) => setSearchRoleQuery(e.target.value)}
className="w-full"
/>
</div>
{canCreateRole && (
<Button
variant="primary"
onClick={() => setIsCreateModalOpen(true)}
className="flex items-center gap-1.5 flex-none"
>
<PlusIcon size={18} />
<span>{t('rbac.createRole') || 'Thêm vai trò'}</span>
</Button>
)}
</div>
{/* Loading state */}
{rolesLoading && (
<div className="flex flex-col gap-3">
{[1, 2, 3].map((i) => (
<div key={i} className="animate-pulse bg-clay-bg h-28 w-full rounded-clay-card border border-clay-highlight/30"></div>
))}
</div>
)}
{/* Error state */}
{rolesError && (
<Card className="text-center py-6">
<p className="text-clay-expense font-bold mb-2">
{t('rbac.loadFailed') || 'Không thể tải danh sách vai trò'}
</p>
<Button variant="secondary" onClick={() => queryClient.invalidateQueries({ queryKey: ['rbac-roles'] })}>
{t('common.retry') || 'Thử lại'}
</Button>
</Card>
)}
{/* Roles List Cards */}
{!rolesLoading && !rolesError && (
<div className="flex flex-col gap-3.5">
{filteredRoles.map((role) => {
const permCount = role._count?.rolePermissions ?? role.rolePermissions?.length ?? 0;
const userCount = role._count?.users ?? 0;
return (
<Card
key={role.id}
className="flex flex-col gap-3 p-4 bg-clay-surface border border-clay-highlight/50 shadow-clay-card hover:shadow-clay-hover transition-all"
>
{/* Top row: Role Name, System Badge & Counts */}
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-baloo font-bold text-lg text-clay-text">
{role.name}
</h3>
{role.isSystem ? (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-clay-warning/15 text-clay-warning border border-clay-warning/30">
<LockIcon size={12} />
{t('rbac.systemRole') || 'Hệ thống'}
</span>
) : (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-clay-info/15 text-clay-info border border-clay-info/30">
{t('rbac.customRole') || 'Tùy chỉnh'}
</span>
)}
</div>
{/* Counts badges */}
<div className="flex items-center gap-1.5 flex-none">
<Badge type="info">
{t('rbac.usersCount', { count: userCount }) || `${userCount} người dùng`}
</Badge>
<Badge type="primary">
{t('rbac.permsCount', { count: permCount }) || `${permCount} quyền`}
</Badge>
</div>
</div>
{/* Description */}
<p className="clay-caption text-xs text-clay-text-muted">
{role.description || t('rbac.noDescription') || 'Chưa có mô tả cho vai trò này.'}
</p>
{/* Action buttons */}
<div className="flex items-center justify-between gap-2 pt-2 border-t border-clay-text-muted/10 flex-wrap">
<Button
variant="secondary"
onClick={() => handleOpenMatrix(role)}
className="text-xs py-1.5 px-3 flex items-center gap-1.5 bg-clay-primary/10 text-clay-primary"
>
<KeyIcon size={14} />
<span>{t('rbac.editMatrix') || 'Ma trận quyền'}</span>
</Button>
<div className="flex items-center gap-2">
{canUpdateRole && !role.isSystem && (
<button
type="button"
onClick={() => handleOpenEdit(role)}
aria-label={t('common.edit') || 'Chỉnh sửa'}
className="p-1.5 text-clay-text-muted hover:text-clay-primary transition-colors rounded-full hover:bg-clay-bg"
>
<EditIcon size={16} />
</button>
)}
{canDeleteRole && !role.isSystem && (
<button
type="button"
onClick={() => handleOpenDelete(role)}
aria-label={t('common.delete') || 'Xóa'}
className="p-1.5 text-clay-text-muted hover:text-clay-expense transition-colors rounded-full hover:bg-clay-bg"
>
<TrashIcon size={16} />
</button>
)}
</div>
</div>
</Card>
);
})}
{filteredRoles.length === 0 && (
<Card className="text-center py-8">
<p className="clay-caption">{t('rbac.noRolesFound') || 'Không tìm thấy vai trò phù hợp.'}</p>
</Card>
)}
</div>
)}
</div>
)}
{/* ======================================================== */}
{/* TAB 2: AUDIT LOGS VIEW */}
{/* ======================================================== */}
{activeTab === 'audit' && canReadAuditLogs && (
<div className="flex flex-col gap-3">
{auditLoading && (
<div className="flex flex-col gap-3">
{[1, 2, 3].map((i) => (
<div key={i} className="animate-pulse bg-clay-bg h-20 w-full rounded-clay-card"></div>
))}
</div>
)}
{auditLogsResponse?.data && (
<div className="flex flex-col gap-3">
{auditLogsResponse.data.map((log) => (
<Card key={log.id} className="p-3.5 flex flex-col gap-1.5 border border-clay-highlight/40">
<div className="flex items-center justify-between gap-2">
<span className="font-bold text-xs px-2 py-0.5 rounded bg-clay-primary/10 text-clay-primary border border-clay-primary/20">
{log.action}
</span>
<span className="clay-caption text-[11px]">
{formatDate(log.createdAt, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</span>
</div>
<div className="text-xs text-clay-text">
<span className="text-clay-text-muted">{t('rbac.target') || 'Đối tượng'}:</span>{' '}
<span className="font-semibold">{log.targetType}</span> ({log.targetId || 'N/A'})
</div>
{log.ipAddress && (
<div className="clay-caption text-[11px]">
IP: {log.ipAddress}
</div>
)}
</Card>
))}
{auditLogsResponse.data.length === 0 && (
<Card className="text-center py-8">
<p className="clay-caption">{t('rbac.noAuditLogs') || 'Chưa có bản ghi kiểm toán nào.'}</p>
</Card>
)}
</div>
)}
</div>
)}
</div>
{/* ======================================================== */}
{/* MODAL: PERMISSION MATRIX EDITOR */}
{/* ======================================================== */}
<Modal
isOpen={isMatrixModalOpen}
onClose={() => setIsMatrixModalOpen(false)}
title={`${t('rbac.matrixTitle') || 'Ma trận quyền hạn'}: ${selectedRole?.name || ''}`}
footer={
<div className="flex items-center justify-between w-full">
<span className="clay-caption text-xs">
{t('rbac.selectedCount', { count: selectedPermissionIds.length }) ||
`Đã chọn: ${selectedPermissionIds.length} quyền`}
</span>
<div className="flex gap-2">
<Button variant="secondary" onClick={() => setIsMatrixModalOpen(false)}>
{t('common.cancel') || 'Hủy'}
</Button>
{canAssignPermissions && (
<Button
variant="primary"
onClick={handleSaveMatrix}
disabled={assignPermissionsMutation.isPending}
>
{assignPermissionsMutation.isPending
? t('common.saving') || 'Đang lưu...'
: t('common.save') || 'Lưu thay đổi'}
</Button>
)}
</div>
</div>
}
>
<div className="flex flex-col gap-5 py-2 max-h-[60vh] overflow-y-auto pr-1">
{permissionsLoading && (
<p className="clay-caption text-center">{t('common.loading') || 'Đang tải quyền hạn...'}</p>
)}
{Array.from(permissionsByResource.entries()).map(([resource, resPermissions]) => {
const allChecked = resPermissions.every((p) => selectedPermissionIds.includes(p.id));
const someChecked = resPermissions.some((p) => selectedPermissionIds.includes(p.id));
return (
<div
key={resource}
className="bg-clay-bg/60 rounded-clay-md p-3.5 border border-clay-highlight/50 flex flex-col gap-2.5"
>
{/* Resource Title & Bulk Check */}
<div className="flex items-center justify-between border-b border-clay-text-muted/10 pb-2">
<span className="font-baloo font-bold text-sm text-clay-primary">
{resource}
</span>
<button
type="button"
onClick={() => toggleAllResourcePermissions(resPermissions)}
className="text-[11px] font-bold text-clay-text-muted hover:text-clay-primary transition-colors"
>
{allChecked
? t('rbac.uncheckAll') || 'Bỏ chọn tất cả'
: t('rbac.checkAll') || 'Chọn tất cả'}
</button>
</div>
{/* Permissions Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{resPermissions.map((perm) => {
const isChecked = selectedPermissionIds.includes(perm.id);
const actionClass = ACTION_COLORS[perm.action] || 'bg-clay-bg text-clay-text border-clay-highlight';
return (
<button
key={perm.id}
type="button"
onClick={() => togglePermission(perm.id)}
className={`flex items-center gap-2.5 p-2 rounded-clay-sm border text-left transition-all ${
isChecked
? 'bg-clay-surface shadow-clay-pressed border-clay-primary'
: 'bg-clay-surface/50 border-clay-highlight/40 opacity-70 hover:opacity-100'
}`}
>
<div
className={`w-5 h-5 rounded flex-none flex items-center justify-center border transition-colors ${
isChecked
? 'bg-clay-primary text-clay-on-primary border-clay-primary'
: 'bg-clay-bg border-clay-text-muted/30'
}`}
>
{isChecked && <CheckIcon size={13} />}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className={`text-[10px] font-bold px-1.5 py-0.2 rounded border ${actionClass}`}>
{perm.action}
</span>
<span className="text-xs font-bold text-clay-text truncate">{perm.name}</span>
</div>
{perm.description && (
<p className="text-[11px] text-clay-text-muted line-clamp-1 mt-0.5">
{perm.description}
</p>
)}
</div>
</button>
);
})}
</div>
</div>
);
})}
</div>
</Modal>
{/* ======================================================== */}
{/* MODAL: CREATE ROLE */}
{/* ======================================================== */}
<Modal
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
title={t('rbac.createRoleTitle') || 'Thêm vai trò mới'}
>
<form onSubmit={handleCreateRole} className="flex flex-col gap-4 py-2">
<Input
label={t('rbac.roleNameLabel') || 'Tên vai trò (IN HOA)'}
placeholder="VD: AUDITOR, SUPPORT_LEAD"
value={newRoleName}
onChange={(e) => setNewRoleName(e.target.value.toUpperCase())}
required
/>
<Input
label={t('rbac.roleDescLabel') || 'Mô tả vai trò'}
placeholder="VD: Kiểm toán viên kiểm tra báo cáo và lịch sử giao dịch"
value={newRoleDesc}
onChange={(e) => setNewRoleDesc(e.target.value)}
/>
<div className="flex justify-end gap-2 pt-3">
<Button variant="secondary" type="button" onClick={() => setIsCreateModalOpen(false)}>
{t('common.cancel') || 'Hủy'}
</Button>
<Button
variant="primary"
type="submit"
disabled={createRoleMutation.isPending || !newRoleName.trim()}
>
{createRoleMutation.isPending
? t('common.creating') || 'Đang tạo...'
: t('common.create') || 'Tạo vai trò'}
</Button>
</div>
</form>
</Modal>
{/* ======================================================== */}
{/* MODAL: EDIT ROLE */}
{/* ======================================================== */}
<Modal
isOpen={isEditModalOpen}
onClose={() => setIsEditModalOpen(false)}
title={t('rbac.editRoleTitle') || 'Chỉnh sửa vai trò'}
>
<form onSubmit={handleUpdateRole} className="flex flex-col gap-4 py-2">
<Input
label={t('rbac.roleNameLabel') || 'Tên vai trò'}
value={editRoleName}
onChange={(e) => setEditRoleName(e.target.value.toUpperCase())}
disabled={selectedRole?.isSystem}
required
/>
<Input
label={t('rbac.roleDescLabel') || 'Mô tả vai trò'}
value={editRoleDesc}
onChange={(e) => setEditRoleDesc(e.target.value)}
/>
<div className="flex justify-end gap-2 pt-3">
<Button variant="secondary" type="button" onClick={() => setIsEditModalOpen(false)}>
{t('common.cancel') || 'Hủy'}
</Button>
<Button
variant="primary"
type="submit"
disabled={updateRoleMutation.isPending}
>
{updateRoleMutation.isPending
? t('common.saving') || 'Đang lưu...'
: t('common.save') || 'Lưu'}
</Button>
</div>
</form>
</Modal>
{/* ======================================================== */}
{/* MODAL: DELETE ROLE CONFIRMATION */}
{/* ======================================================== */}
<Modal
isOpen={isDeleteModalOpen}
onClose={() => setIsDeleteModalOpen(false)}
title={t('rbac.deleteRoleTitle') || 'Xác nhận xóa vai trò'}
>
<div className="flex flex-col gap-4 py-2">
<p className="text-sm text-clay-text">
{t('rbac.deleteConfirm', { name: selectedRole?.name || '' }) ||
`Bạn có chắc chắn muốn xóa vai trò "${selectedRole?.name}" không? Thao tác này không thể hoàn tác.`}
</p>
<div className="flex justify-end gap-2 pt-3">
<Button variant="secondary" onClick={() => setIsDeleteModalOpen(false)}>
{t('common.cancel') || 'Hủy'}
</Button>
<Button
variant="danger"
onClick={() => selectedRole && deleteRoleMutation.mutate(selectedRole.id)}
disabled={deleteRoleMutation.isPending}
>
{deleteRoleMutation.isPending
? t('common.deleting') || 'Đang xóa...'
: t('common.delete') || 'Xóa vai trò'}
</Button>
</div>
</div>
</Modal>
</Page>
);
};
export default RolesPage;
......@@ -11,7 +11,10 @@ import {
LightbulbIcon,
NotificationIcon,
ReportIcon,
ShieldIcon,
} from "@/components/ui/icons";
import { PermissionGate } from "@/components/shared/PermissionGate";
import { PERMISSIONS } from "@/common/constants";
import { useI18n } from "@/i18n";
function HomePage() {
......@@ -66,7 +69,6 @@ function HomePage() {
onClick={() => navigate("/ai-assistant")}
className="gap-2"
>
<AIAssistantIcon size={20} />
{t("home.aiAssistant")}
</Button>
......@@ -76,7 +78,6 @@ function HomePage() {
onClick={() => navigate("/query")}
className="gap-2 bg-clay-info-soft"
>
<LightbulbIcon size={20} />
{t("home.smartQuery")}
</Button>
......@@ -192,6 +193,16 @@ function HomePage() {
>
{t("home.notifications")}
</Button>
<PermissionGate permission={PERMISSIONS.ROLE_READ}>
<Button
variant="secondary"
fullWidth
onClick={() => navigate("/roles")}
className="gap-2 bg-clay-primary/10 text-clay-primary font-bold"
>
<span>{t("rbac.header")}</span>
</Button>
</PermissionGate>
<Button
variant="secondary"
fullWidth
......
......@@ -15,8 +15,9 @@ import { ThemeToggle } from "@/components/ui/ThemeToggle";
import { useAuthStore } from "@/stores/auth-store";
import { useThemeStore } from "@/stores/theme-store";
import { authService } from "@/services/auth.service";
import { uploadService } from "@/services/upload.service";
import { EyeIcon, EyeOffIcon, IconGradients } from "@/components/ui/icons";
import { EyeIcon, EyeOffIcon, IconGradients, ShieldIcon } from "@/components/ui/icons";
import { PermissionGate } from "@/components/shared/PermissionGate";
import { PERMISSIONS } from "@/common/constants";
import {
AvatarEditorModal,
AvatarPosition,
......@@ -590,6 +591,21 @@ const ProfilePage: React.FC = () => {
</div>
)}
{/* RBAC Management Entry Point */}
<PermissionGate permission={PERMISSIONS.ROLE_READ}>
<div className="px-4">
<Button
variant="secondary"
onClick={() => navigate("/roles")}
fullWidth
className="gap-2 bg-clay-primary/10 text-clay-primary font-bold"
>
<ShieldIcon size={18} />
{t("rbac.header") || "Phân quyền & Vai trò"}
</Button>
</div>
</PermissionGate>
{/* Global Logout Button */}
<div className="px-4">
<Button variant="secondary" onClick={handleLogout} fullWidth className="text-clay-expense">
......
import { apiClient } from '@/lib/api-client';
import {
ApiResponse,
Role,
Permission,
AuditLog,
CreateRoleRequest,
UpdateRoleRequest,
AssignRolePermissionsRequest,
} from '@/types/auth';
export interface RoleListResponse {
data: Role[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}
export interface AuditLogListResponse {
data: AuditLog[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}
export const rbacService = {
// Roles
async getRoles(params?: {
name?: string;
isSystem?: boolean;
page?: number;
limit?: number;
}): Promise<ApiResponse<Role[]> & { meta?: RoleListResponse['meta'] }> {
const response = await apiClient.get('/roles', { params });
return response.data;
},
async getRole(id: string): Promise<ApiResponse<Role>> {
const response = await apiClient.get(`/roles/${id}`);
return response.data;
},
async createRole(data: CreateRoleRequest): Promise<ApiResponse<Role>> {
const response = await apiClient.post('/roles', data);
return response.data;
},
async updateRole(id: string, data: UpdateRoleRequest): Promise<ApiResponse<Role>> {
const response = await apiClient.put(`/roles/${id}`, data);
return response.data;
},
async deleteRole(id: string): Promise<ApiResponse> {
const response = await apiClient.delete(`/roles/${id}`);
return response.data;
},
// Permissions
async getPermissions(params?: {
resource?: string;
action?: string;
search?: string;
}): Promise<ApiResponse<Permission[]>> {
const response = await apiClient.get('/permissions', { params });
return response.data;
},
async getRolePermissions(roleId: string): Promise<ApiResponse<Permission[]>> {
const response = await apiClient.get(`/roles/${roleId}/permissions`);
return response.data;
},
async assignRolePermissions(
roleId: string,
data: AssignRolePermissionsRequest
): Promise<ApiResponse<Role>> {
const response = await apiClient.put(`/roles/${roleId}/permissions`, data);
return response.data;
},
async removeRolePermission(roleId: string, permissionId: string): Promise<ApiResponse> {
const response = await apiClient.delete(`/roles/${roleId}/permissions/${permissionId}`);
return response.data;
},
// User Role Assignment
async updateUserRole(userId: string, roleId: string): Promise<ApiResponse> {
const response = await apiClient.put(`/users/${userId}`, { roleId });
return response.data;
},
// Audit Logs
async getAuditLogs(params?: {
actorId?: string;
action?: string;
targetType?: string;
page?: number;
limit?: number;
}): Promise<ApiResponse<AuditLog[]> & { meta?: AuditLogListResponse['meta'] }> {
const response = await apiClient.get('/audit-logs', { params });
return response.data;
},
};
export interface Permission {
id: string;
name: string;
description?: string | null;
resource: string;
action: string;
isSystem: boolean;
createdAt?: string;
updatedAt?: string;
}
export interface RolePermission {
id: string;
roleId: string;
permissionId: string;
permission?: Permission;
}
export interface Role {
id: string;
name: 'ADMIN' | 'MANAGER' | 'USER';
name: string;
description?: string | null;
isSystem?: boolean;
_count?: {
users: number;
rolePermissions: number;
};
rolePermissions?: RolePermission[];
createdAt?: string;
updatedAt?: string;
}
export interface User {
......@@ -13,11 +40,40 @@ export interface User {
phoneNumber?: string | null;
roleId: string;
role: Role;
permissions?: string[];
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface AuditLog {
id: string;
actorId?: string | null;
action: string;
targetType: string;
targetId?: string | null;
previousState?: any;
newState?: any;
ipAddress?: string | null;
userAgent?: string | null;
createdAt: string;
}
export interface CreateRoleRequest {
name: string;
description?: string;
permissionIds?: string[];
}
export interface UpdateRoleRequest {
name?: string;
description?: string;
}
export interface AssignRolePermissionsRequest {
permissionIds: string[];
}
export interface TokenPair {
accessToken: string;
refreshToken: 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