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