Commit 845ec367 authored by ThinhNC's avatar ThinhNC

feat(admin-portal): implement admin dashboard, user management, audit logs...

feat(admin-portal): implement admin dashboard, user management, audit logs viewer, and dynamic rbac matrix
parent 68db3996
......@@ -84,6 +84,8 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th
- 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).
- Admin Control Center tại `/admin` dùng TanStack Query và permission động, gồm dashboard KPI/hoạt động gần đây, danh sách người dùng có tìm kiếm-lọc-phân trang, tạo/đổi trạng thái/xóa mềm, chi tiết người dùng có đổi vai trò/khôi phục và lịch sử kiểm toán, cùng audit viewer lọc theo khoảng ngày Việt Nam/action/target và so sánh JSON trước-sau. Trang chủ chỉ hiện lối vào khi có `USER_READ`.
- Contract Backend hiện chưa cho list hoặc đọc user đã soft-delete (`GET /users``GET /users/:id` đều loại `deletedAt != null`), nên nút restore trên detail đã sẵn sàng theo type/permission nhưng chỉ khả dụng khi Backend trả được bản ghi đã xóa.
## Khi cập nhật file này
......
......@@ -4,6 +4,7 @@ export const PERMISSIONS = {
USER_CREATE: 'USER_CREATE',
USER_UPDATE: 'USER_UPDATE',
USER_DELETE: 'USER_DELETE',
USER_RESTORE: 'USER_RESTORE',
// ROLE
ROLE_READ: 'ROLE_READ',
......
......@@ -2,6 +2,7 @@ export const SYSTEM_ROLES = {
ADMIN: 'ADMIN',
USER: 'USER',
MANAGER: 'MANAGER',
SUPER_ADMIN: 'SUPER_ADMIN',
} as const;
export type SystemRole = (typeof SYSTEM_ROLES)[keyof typeof SYSTEM_ROLES];
......@@ -44,6 +44,10 @@ 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 AdminDashboardPage from "@/pages/admin/index";
import AdminUsersPage from "@/pages/admin/users/index";
import AdminUserDetailPage from "@/pages/admin/users/detail";
import AdminAuditLogsPage from "@/pages/admin/audit-logs/index";
import { PermissionGate } from "@/components/shared/PermissionGate";
import { AccessDenied } from "@/components/shared/AccessDenied";
import { PERMISSIONS } from "@/common/constants";
......@@ -88,7 +92,7 @@ export const Layout = () => {
<SnackbarProvider>
<ZMPRouter>
<DocumentTitle />
<div className="clay-theme-trigger-container">
<div className="finwise-header-controls fixed z-[1001] flex items-center gap-2">
<NotificationShortcut />
<ThemeControl />
<LanguageSwitcher />
......@@ -121,6 +125,11 @@ export const Layout = () => {
<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>
{/* Admin Control Center Routes */}
<Route path="/admin" element={<AuthGuard><PermissionGate permission={PERMISSIONS.USER_READ} fallback={<AccessDenied />}><AdminDashboardPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/admin/users" element={<AuthGuard><PermissionGate permission={PERMISSIONS.USER_READ} fallback={<AccessDenied />}><AdminUsersPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/admin/users/:id" element={<AuthGuard><PermissionGate permission={PERMISSIONS.USER_READ} fallback={<AccessDenied />}><AdminUserDetailPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/admin/audit-logs" element={<AuthGuard><PermissionGate permission={PERMISSIONS.AUDIT_LOG_READ} fallback={<AccessDenied />}><AdminAuditLogsPage /></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>
......
......@@ -31,6 +31,10 @@ 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 === "/admin", key: "document.admin" },
{ matches: (pathname) => pathname === "/admin/users", key: "document.adminUsers" },
{ matches: (pathname) => pathname.startsWith("/admin/users/"), key: "document.adminUserDetail" },
{ matches: (pathname) => pathname === "/admin/audit-logs", key: "document.adminAuditLogs" },
{ 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AuditLogQueryParams, rbacService } from '@/services/rbac.service';
import { userService } from '@/services/user.service';
import { CreateAdminUserInput, UpdateAdminUserInput, UserListParams } from '@/types/admin';
export const adminQueryKeys = {
root: ['admin'] as const,
stats: () => [...adminQueryKeys.root, 'stats'] as const,
users: () => [...adminQueryKeys.root, 'users'] as const,
userList: (params: UserListParams) => [...adminQueryKeys.users(), params] as const,
user: (id: string) => [...adminQueryKeys.users(), 'detail', id] as const,
roles: () => ['rbac-roles'] as const,
auditLogs: () => [...adminQueryKeys.root, 'audit-logs'] as const,
auditLogList: (params: AuditLogQueryParams) => [...adminQueryKeys.auditLogs(), params] as const,
};
export function useAdminStats() {
return useQuery({
queryKey: adminQueryKeys.stats(),
queryFn: () => userService.getAdminStats(),
staleTime: 60_000,
});
}
export function useAdminUsers(params: UserListParams) {
return useQuery({
queryKey: adminQueryKeys.userList(params),
queryFn: () => userService.getUsers(params),
placeholderData: (previousData) => previousData,
});
}
export function useAdminUser(id: string) {
return useQuery({
queryKey: adminQueryKeys.user(id),
queryFn: () => userService.getUserById(id),
enabled: Boolean(id),
});
}
export function useAdminRoles() {
return useQuery({
queryKey: adminQueryKeys.roles(),
queryFn: () => rbacService.getRoles({ limit: 100 }),
staleTime: 60_000,
});
}
export function useAdminAuditLogs(params: AuditLogQueryParams, enabled = true) {
return useQuery({
queryKey: adminQueryKeys.auditLogList(params),
queryFn: () => rbacService.getAuditLogs(params),
placeholderData: (previousData) => previousData,
enabled,
});
}
function useInvalidateAdminData() {
const queryClient = useQueryClient();
return async (userId?: string) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: adminQueryKeys.users() }),
queryClient.invalidateQueries({ queryKey: adminQueryKeys.stats() }),
queryClient.invalidateQueries({ queryKey: adminQueryKeys.auditLogs() }),
...(userId
? [queryClient.invalidateQueries({ queryKey: adminQueryKeys.user(userId) })]
: []),
]);
};
}
export function useCreateAdminUser() {
const invalidateAdminData = useInvalidateAdminData();
return useMutation({
mutationFn: (data: CreateAdminUserInput) => userService.createUser(data),
onSuccess: () => invalidateAdminData(),
});
}
export function useUpdateAdminUser() {
const invalidateAdminData = useInvalidateAdminData();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateAdminUserInput }) =>
userService.updateUser(id, data),
onSuccess: (_response, variables) => invalidateAdminData(variables.id),
});
}
export function useDeleteAdminUser() {
const invalidateAdminData = useInvalidateAdminData();
return useMutation({
mutationFn: (id: string) => userService.deleteUser(id),
onSuccess: (_response, id) => invalidateAdminData(id),
});
}
export function useRestoreAdminUser() {
const invalidateAdminData = useInvalidateAdminData();
return useMutation({
mutationFn: (id: string) => userService.restoreUser(id),
onSuccess: (_response, id) => invalidateAdminData(id),
});
}
......@@ -20,7 +20,14 @@
"default": "Default",
"results": "{{count}} results",
"pageOf": "{{page}} / {{total}}",
"backToHome": "Back to Home"
"backToHome": "Back to Home",
"all": "All",
"reset": "Reset",
"viewAll": "View all",
"viewDetails": "View details",
"confirm": "Confirm",
"create": "Create",
"pagination": "Pagination"
},
"language": {
"label": "Language",
......@@ -62,6 +69,10 @@
"recurringTransactions": "Automated Recurring Transactions",
"query": "Smart Natural Language Query DSL",
"notifications": "Notifications & Reminders",
"admin": "Admin Control Center",
"adminUsers": "User Management",
"adminUserDetail": "User Details",
"adminAuditLogs": "Audit Logs",
"roles": "Roles & Permissions",
"aiAssistant": "AI Financial Assistant"
},
......@@ -1649,6 +1660,119 @@
"breakdownTitle": "Grouped Breakdown",
"detailedItemsTitle": "Detailed Matching Transactions"
},
"admin": {
"controlCenter": "Admin Control Center",
"manageUsersAndRoles": "Manage users, roles, permissions, and audit activity",
"dashboard": {
"title": "Admin Control Center",
"overview": "System overview",
"quickActions": "Quick actions",
"recentActivity": "Recent activity"
},
"stats": {
"totalUsers": "Total users",
"activeUsers": "Active users",
"inactiveUsers": "Inactive users",
"totalRoles": "Total roles"
},
"actions": {
"users": "User management",
"roles": "Role management",
"auditLogs": "Audit logs"
},
"users": {
"title": "User Management",
"heading": "Users",
"description": "Search accounts, update access status, and manage assigned roles.",
"search": "Search users",
"searchPlaceholder": "Name or email address",
"roleFilter": "Role",
"statusFilter": "Status",
"active": "Active",
"inactive": "Inactive",
"deleted": "Deleted",
"list": "User list",
"empty": "No users match the current filters.",
"resultCount": "{{count}} users",
"joined": "Joined",
"unnamed": "Unnamed user",
"you": "You",
"activate": "Activate",
"deactivate": "Deactivate",
"statusTitle": "Change user status?",
"statusConfirm": "Are you sure you want to {{action}} {{name}}?",
"statusSuccess": "User status updated.",
"deleteTitle": "Delete user?",
"deleteConfirm": "{{name}} will be soft-deleted and signed out. Their financial history is preserved.",
"deleteSuccess": "User deleted.",
"create": "Create user",
"createTitle": "Create user",
"createSuccess": "User created successfully.",
"email": "Gmail address",
"emailError": "Enter a valid @gmail.com address.",
"password": "Temporary password",
"passwordError": "Use at least 8 characters with uppercase, lowercase, number, and special character.",
"role": "Assigned role",
"roleError": "Choose a role.",
"selectRole": "Choose a role"
},
"userDetail": {
"title": "User Details",
"phone": "Phone number",
"createdAt": "Created",
"updatedAt": "Last updated",
"access": "Access and role",
"accessDescription": "Role changes take effect after the server refreshes this user's permission cache.",
"roleSuccess": "Assigned role updated.",
"restore": "Restore user",
"restoreDescription": "Restore this account and reactivate access.",
"restoreSuccess": "User restored successfully.",
"history": "User audit history"
},
"audit": {
"title": "Audit Logs",
"heading": "Immutable audit history",
"description": "Review administrative actions and compare state before and after each change.",
"dateFrom": "From date",
"dateTo": "To date",
"invalidRange": "The end date must be on or after the start date.",
"action": "Action type",
"actions": {
"ROLE_CREATE": "Create role",
"ROLE_UPDATE": "Update role",
"ROLE_DELETE": "Delete role",
"ROLE_PERMISSIONS_ASSIGN": "Update role permissions",
"ROLE_PERMISSION_REMOVE": "Remove role permission",
"USER_ROLE_UPDATE": "Change user role",
"USER_ACTIVATE": "Activate user",
"USER_DEACTIVATE": "Deactivate user",
"USER_DELETE": "Delete user",
"USER_RESTORE": "Restore user"
},
"targetType": "Target entity",
"targets": { "user": "User", "role": "Role", "permission": "Permission" },
"empty": "No audit entries match the current filters.",
"resultCount": "{{count}} audit entries",
"target": "Target",
"viewChanges": "View state changes",
"previousState": "Previous state",
"newState": "New state",
"actor": "Actor",
"system": "System",
"ipAddress": "IP address",
"userAgent": "User agent"
},
"errors": {
"stats": "Could not load admin statistics.",
"users": "Could not load users.",
"userDetail": "This user does not exist or could not be loaded.",
"auditLogs": "Could not load audit logs.",
"createUser": "Could not create the user.",
"updateUser": "Could not update the user.",
"deleteUser": "Could not delete the user.",
"restoreUser": "Could not restore the user."
}
},
"rbac": {
"header": "Dynamic RBAC & Roles",
"title": "Dynamic RBAC Management",
......
......@@ -20,7 +20,14 @@
"default": "Mặc định",
"results": "{{count}} kết quả",
"pageOf": "{{page}} / {{total}}",
"backToHome": "Về Trang chủ"
"backToHome": "Về Trang chủ",
"all": "Tất cả",
"reset": "Đặt lại",
"viewAll": "Xem tất cả",
"viewDetails": "Xem chi tiết",
"confirm": "Xác nhận",
"create": "Tạo",
"pagination": "Phân trang"
},
"language": {
"label": "Ngôn ngữ",
......@@ -62,6 +69,10 @@
"recurringTransactions": "Giao dịch Tự động Định kỳ",
"query": "Truy vấn Thông minh DSL",
"notifications": "Thông báo & nhắc nhở",
"admin": "Trung tâm quản trị",
"adminUsers": "Quản lý người dùng",
"adminUserDetail": "Chi tiết người dùng",
"adminAuditLogs": "Nhật ký kiểm toán",
"roles": "Phân quyền & Vai trò",
"aiAssistant": "Trợ lý tài chính AI"
},
......@@ -1750,6 +1761,119 @@
"breakdownTitle": "Phân tích theo Nhóm",
"detailedItemsTitle": "Danh sách Giao dịch Chi tiết"
},
"admin": {
"controlCenter": "Trung tâm quản trị",
"manageUsersAndRoles": "Quản lý người dùng, vai trò, quyền hạn và hoạt động kiểm toán",
"dashboard": {
"title": "Trung tâm quản trị",
"overview": "Tổng quan hệ thống",
"quickActions": "Thao tác nhanh",
"recentActivity": "Hoạt động gần đây"
},
"stats": {
"totalUsers": "Tổng người dùng",
"activeUsers": "Đang hoạt động",
"inactiveUsers": "Ngừng hoạt động",
"totalRoles": "Tổng vai trò"
},
"actions": {
"users": "Quản lý người dùng",
"roles": "Quản lý vai trò",
"auditLogs": "Nhật ký kiểm toán"
},
"users": {
"title": "Quản lý người dùng",
"heading": "Người dùng",
"description": "Tìm kiếm tài khoản, cập nhật trạng thái truy cập và quản lý vai trò.",
"search": "Tìm người dùng",
"searchPlaceholder": "Tên hoặc địa chỉ email",
"roleFilter": "Vai trò",
"statusFilter": "Trạng thái",
"active": "Hoạt động",
"inactive": "Ngừng hoạt động",
"deleted": "Đã xóa",
"list": "Danh sách người dùng",
"empty": "Không có người dùng phù hợp với bộ lọc.",
"resultCount": "{{count}} người dùng",
"joined": "Tham gia",
"unnamed": "Người dùng chưa đặt tên",
"you": "Bạn",
"activate": "Kích hoạt",
"deactivate": "Vô hiệu hóa",
"statusTitle": "Thay đổi trạng thái người dùng?",
"statusConfirm": "Bạn có chắc muốn {{action}} {{name}} không?",
"statusSuccess": "Đã cập nhật trạng thái người dùng.",
"deleteTitle": "Xóa người dùng?",
"deleteConfirm": "{{name}} sẽ bị xóa mềm và đăng xuất. Lịch sử tài chính vẫn được giữ nguyên.",
"deleteSuccess": "Đã xóa người dùng.",
"create": "Tạo người dùng",
"createTitle": "Tạo người dùng",
"createSuccess": "Đã tạo người dùng thành công.",
"email": "Địa chỉ Gmail",
"emailError": "Nhập địa chỉ @gmail.com hợp lệ.",
"password": "Mật khẩu tạm thời",
"passwordError": "Dùng ít nhất 8 ký tự gồm chữ hoa, chữ thường, số và ký tự đặc biệt.",
"role": "Vai trò được gán",
"roleError": "Vui lòng chọn vai trò.",
"selectRole": "Chọn vai trò"
},
"userDetail": {
"title": "Chi tiết người dùng",
"phone": "Số điện thoại",
"createdAt": "Ngày tạo",
"updatedAt": "Cập nhật gần nhất",
"access": "Quyền truy cập và vai trò",
"accessDescription": "Thay đổi vai trò có hiệu lực sau khi máy chủ làm mới bộ nhớ đệm quyền của người dùng.",
"roleSuccess": "Đã cập nhật vai trò được gán.",
"restore": "Khôi phục người dùng",
"restoreDescription": "Khôi phục tài khoản này và kích hoạt lại quyền truy cập.",
"restoreSuccess": "Đã khôi phục người dùng thành công.",
"history": "Lịch sử kiểm toán người dùng"
},
"audit": {
"title": "Nhật ký kiểm toán",
"heading": "Lịch sử kiểm toán bất biến",
"description": "Xem các thao tác quản trị và so sánh trạng thái trước, sau mỗi thay đổi.",
"dateFrom": "Từ ngày",
"dateTo": "Đến ngày",
"invalidRange": "Ngày kết thúc phải bằng hoặc sau ngày bắt đầu.",
"action": "Loại hành động",
"actions": {
"ROLE_CREATE": "Tạo vai trò",
"ROLE_UPDATE": "Cập nhật vai trò",
"ROLE_DELETE": "Xóa vai trò",
"ROLE_PERMISSIONS_ASSIGN": "Cập nhật quyền của vai trò",
"ROLE_PERMISSION_REMOVE": "Gỡ quyền khỏi vai trò",
"USER_ROLE_UPDATE": "Đổi vai trò người dùng",
"USER_ACTIVATE": "Kích hoạt người dùng",
"USER_DEACTIVATE": "Vô hiệu hóa người dùng",
"USER_DELETE": "Xóa người dùng",
"USER_RESTORE": "Khôi phục người dùng"
},
"targetType": "Loại đối tượng",
"targets": { "user": "Người dùng", "role": "Vai trò", "permission": "Quyền hạn" },
"empty": "Không có bản ghi kiểm toán phù hợp với bộ lọc.",
"resultCount": "{{count}} bản ghi kiểm toán",
"target": "Đối tượng",
"viewChanges": "Xem thay đổi trạng thái",
"previousState": "Trạng thái trước",
"newState": "Trạng thái sau",
"actor": "Người thực hiện",
"system": "Hệ thống",
"ipAddress": "Địa chỉ IP",
"userAgent": "Thiết bị truy cập"
},
"errors": {
"stats": "Không thể tải thống kê quản trị.",
"users": "Không thể tải danh sách người dùng.",
"userDetail": "Người dùng không tồn tại hoặc không thể tải.",
"auditLogs": "Không thể tải nhật ký kiểm toán.",
"createUser": "Không thể tạo người dùng.",
"updateUser": "Không thể cập nhật người dùng.",
"deleteUser": "Không thể xóa người dùng.",
"restoreUser": "Không thể khôi phục người dùng."
}
},
"rbac": {
"header": "Phân quyền & Vai trò",
"title": "Quản lý Dynamic RBAC",
......
......@@ -59,8 +59,15 @@ apiClient.interceptors.response.use(
async (error) => {
const originalRequest = error.config;
// Check if the error is 401 and the request hasn't been retried yet
if (error.response?.status === 401 && !originalRequest._retry) {
const isAuthEndpoint =
originalRequest.url?.includes('/auth/login') ||
originalRequest.url?.includes('/auth/register') ||
originalRequest.url?.includes('/auth/refresh') ||
originalRequest.url?.includes('/auth/forgot-password') ||
originalRequest.url?.includes('/auth/reset-password');
// Check if the error is 401, not an auth endpoint, and the request hasn't been retried yet
if (error.response?.status === 401 && !originalRequest._retry && !isAuthEndpoint) {
if (isRefreshing) {
// If already refreshing, wait for it to finish and then retry
return new Promise((resolve, reject) => {
......
......@@ -4,7 +4,10 @@ export class LocalizedError extends Error {}
export function getErrorMessage(error: unknown, fallback: string): string {
if (axios.isAxiosError(error)) {
// Backend messages are not guaranteed to match the selected UI locale.
const serverMessage = (error.response?.data as { message?: string } | undefined)?.message;
if (typeof serverMessage === "string" && serverMessage.trim().length > 0) {
return serverMessage;
}
return fallback;
}
......
import React, { useMemo, useState } from 'react';
import { Header, Page, useNavigate } from 'zmp-ui';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Select } from '@/components/ui/Select';
import { LocalizedDateInput } from '@/components/shared/LocalizedDateInput';
import { addCalendarDays, businessDateStartIso } from '@/lib/business-time';
import { useAdminAuditLogs } from '@/hooks/use-admin';
import { useI18n } from '@/i18n';
import { AdminSkeleton } from '@/pages/admin/components/AdminSkeleton';
import { AuditLogCard } from '@/pages/admin/components/AuditLogCard';
const PAGE_SIZE = 10;
const AUDIT_ACTIONS = [
'ROLE_CREATE',
'ROLE_UPDATE',
'ROLE_DELETE',
'ROLE_PERMISSIONS_ASSIGN',
'ROLE_PERMISSION_REMOVE',
'USER_ROLE_UPDATE',
'USER_ACTIVATE',
'USER_DEACTIVATE',
'USER_DELETE',
'USER_RESTORE',
] as const;
function endOfBusinessDateIso(date: string): string {
const nextDayStart = businessDateStartIso(addCalendarDays(date, 1));
return new Date(new Date(nextDayStart).getTime() - 1).toISOString();
}
const AdminAuditLogsPage: React.FC = () => {
const navigate = useNavigate();
const { t, formatNumber } = useI18n();
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const [action, setAction] = useState('');
const [targetType, setTargetType] = useState('');
const [page, setPage] = useState(1);
const invalidRange = Boolean(dateFrom && dateTo && dateFrom > dateTo);
const params = useMemo(() => ({
dateFrom: dateFrom ? businessDateStartIso(dateFrom) : undefined,
dateTo: dateTo ? endOfBusinessDateIso(dateTo) : undefined,
action: action.trim() || undefined,
targetType: targetType || undefined,
page,
limit: PAGE_SIZE,
}), [action, dateFrom, dateTo, page, targetType]);
const auditQuery = useAdminAuditLogs(params, !invalidRange);
const meta = auditQuery.data?.meta;
const resetFilters = () => {
setDateFrom('');
setDateTo('');
setAction('');
setTargetType('');
setPage(1);
};
return (
<Page className="page min-h-screen bg-clay-bg pb-12">
<Header title={t('admin.audit.title')} showBackIcon onBackClick={() => navigate('/admin')} />
<main className="mx-auto w-full max-w-5xl space-y-5 px-4 pt-6">
<Card>
<h1 className="clay-title-h2">{t('admin.audit.heading')}</h1>
<p className="mt-1 text-sm text-clay-text-muted">{t('admin.audit.description')}</p>
<div className="mt-5 grid gap-4 sm:grid-cols-2">
<LocalizedDateInput
label={t('admin.audit.dateFrom')}
value={dateFrom}
max={dateTo || undefined}
onChange={(event) => { setDateFrom(event.target.value); setPage(1); }}
/>
<LocalizedDateInput
label={t('admin.audit.dateTo')}
value={dateTo}
min={dateFrom || undefined}
error={invalidRange ? t('admin.audit.invalidRange') : undefined}
onChange={(event) => { setDateTo(event.target.value); setPage(1); }}
/>
<Select
label={t('admin.audit.action')}
value={action}
options={[
{ value: '', label: t('common.all') },
...AUDIT_ACTIONS.map((value) => ({
value,
label: t(`admin.audit.actions.${value}`),
})),
]}
onChange={(event) => { setAction(event.target.value); setPage(1); }}
/>
<Select
label={t('admin.audit.targetType')}
value={targetType}
options={[
{ value: '', label: t('common.all') },
{ value: 'USER', label: t('admin.audit.targets.user') },
{ value: 'ROLE', label: t('admin.audit.targets.role') },
{ value: 'PERMISSION', label: t('admin.audit.targets.permission') },
]}
onChange={(event) => { setTargetType(event.target.value); setPage(1); }}
/>
</div>
<Button variant="ghost" className="mt-4 px-3 py-1 text-sm" onClick={resetFilters}>
{t('common.reset')}
</Button>
</Card>
{invalidRange ? null : auditQuery.isLoading ? (
<AdminSkeleton rows={3} />
) : auditQuery.isError ? (
<Card className="text-center">
<p className="text-clay-expense">{t('admin.errors.auditLogs')}</p>
<Button className="mt-4" onClick={() => auditQuery.refetch()}>{t('common.retry')}</Button>
</Card>
) : (auditQuery.data?.data.length ?? 0) === 0 ? (
<Card className="text-center text-clay-text-muted">{t('admin.audit.empty')}</Card>
) : (
<div className="space-y-3">
<p className="px-1 text-sm text-clay-text-muted">
{t('admin.audit.resultCount', { count: formatNumber(meta?.total ?? auditQuery.data?.data.length ?? 0) })}
</p>
{auditQuery.data?.data.map((log) => <AuditLogCard key={log.id} log={log} />)}
</div>
)}
{!invalidRange && (meta?.totalPages ?? 0) > 1 && (
<nav className="flex items-center justify-center gap-3" aria-label={t('common.pagination')}>
<Button variant="secondary" disabled={page <= 1} onClick={() => setPage((current) => current - 1)}>
{t('common.previous')}
</Button>
<span className="font-semibold text-clay-text">{page} / {meta?.totalPages}</span>
<Button variant="secondary" disabled={page >= (meta?.totalPages ?? 1)} onClick={() => setPage((current) => current + 1)}>
{t('common.next')}
</Button>
</nav>
)}
</main>
</Page>
);
};
export default AdminAuditLogsPage;
import React from 'react';
export const AdminSkeleton: React.FC<{ rows?: number }> = ({ rows = 3 }) => (
<div className="space-y-4" role="status" aria-live="polite">
{Array.from({ length: rows }, (_, index) => (
<div
key={index}
className="h-28 animate-pulse rounded-clay-lg bg-clay-surface shadow-clay-pressed"
/>
))}
</div>
);
import React from 'react';
import { AuditLog } from '@/types/auth';
import { Badge } from '@/components/ui/Badge';
import { Card } from '@/components/ui/Card';
import { useI18n } from '@/i18n';
interface AuditLogCardProps {
log: AuditLog;
compact?: boolean;
}
function normalizeState(value: unknown): unknown {
if (typeof value !== 'string') return value;
try {
return JSON.parse(value) as unknown;
} catch {
return value;
}
}
function formatState(value: unknown): string {
if (value === undefined || value === null) return '—';
const normalized = normalizeState(value);
if (typeof normalized === 'string') return normalized;
return JSON.stringify(normalized, null, 2) ?? String(normalized);
}
function humanizeCode(value: string): string {
return value.toLowerCase().split('_').map((part) =>
part ? `${part.charAt(0).toUpperCase()}${part.slice(1)}` : part,
).join(' ');
}
export const AuditLogCard: React.FC<AuditLogCardProps> = ({ log, compact = false }) => {
const { t, formatDate } = useI18n();
return (
<Card className={compact ? 'p-4' : 'p-5'}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<Badge type="info">{humanizeCode(log.action)}</Badge>
<Badge type="primary">{log.targetType}</Badge>
</div>
<p className="break-all text-xs text-clay-text-muted">
{t('admin.audit.target')}: {log.targetLabel || log.targetId || '—'}
</p>
</div>
<time className="text-xs font-semibold text-clay-text-muted" dateTime={log.createdAt}>
{formatDate(log.createdAt, {
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit',
})}
</time>
</div>
{!compact && (
<details className="mt-4 rounded-clay-sm bg-clay-bg p-3 shadow-clay-pressed">
<summary className="cursor-pointer select-none font-baloo font-semibold text-clay-primary">
{t('admin.audit.viewChanges')}
</summary>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<div>
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-clay-text-muted">
{t('admin.audit.previousState')}
</p>
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-all rounded-clay-sm bg-clay-surface p-3 text-xs text-clay-text">
{formatState(log.previousState)}
</pre>
</div>
<div>
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-clay-text-muted">
{t('admin.audit.newState')}
</p>
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-all rounded-clay-sm bg-clay-surface p-3 text-xs text-clay-text">
{formatState(log.newState)}
</pre>
</div>
</div>
<div className="mt-3 space-y-1 text-xs text-clay-text-muted">
<p>{t('admin.audit.actor')}: {log.actorEmail || log.actorId || t('admin.audit.system')}</p>
{log.ipAddress && <p>{t('admin.audit.ipAddress')}: {log.ipAddress}</p>}
{log.userAgent && <p className="break-all">{t('admin.audit.userAgent')}: {log.userAgent}</p>}
</div>
</details>
)}
</Card>
);
};
import React from 'react';
import { Header, Page, useNavigate } from 'zmp-ui';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { PermissionGate } from '@/components/shared/PermissionGate';
import {
ChevronRightIcon,
ClockIcon,
IconGradients,
KeyIcon,
ShieldIcon,
UserIcon,
} from '@/components/ui/icons';
import { PERMISSIONS } from '@/common/constants';
import { usePermission } from '@/hooks/use-permission';
import { useAdminAuditLogs, useAdminStats } from '@/hooks/use-admin';
import { useI18n } from '@/i18n';
import { AdminSkeleton } from '@/pages/admin/components/AdminSkeleton';
import { AuditLogCard } from '@/pages/admin/components/AuditLogCard';
const AdminDashboardPage: React.FC = () => {
const navigate = useNavigate();
const { t, formatNumber } = useI18n();
const { hasPermission } = usePermission();
const canReadAuditLogs = hasPermission(PERMISSIONS.AUDIT_LOG_READ);
const statsQuery = useAdminStats();
const auditQuery = useAdminAuditLogs({ limit: 5 }, canReadAuditLogs);
const stats = statsQuery.data?.data;
const kpis = stats ? [
{ label: t('admin.stats.totalUsers'), value: stats.totalUsers, tone: 'text-clay-primary' },
{ label: t('admin.stats.activeUsers'), value: stats.activeUsers, tone: 'text-clay-income' },
{ label: t('admin.stats.inactiveUsers'), value: stats.inactiveUsers, tone: 'text-clay-warning' },
{ label: t('admin.stats.totalRoles'), value: stats.totalRoles, tone: 'text-clay-info' },
] : [];
return (
<Page className="page min-h-screen bg-clay-bg pb-12">
<Header title={t('admin.dashboard.title')} showBackIcon onBackClick={() => navigate('/')} />
<IconGradients />
<main className="mx-auto w-full max-w-5xl space-y-6 px-4 pt-6">
<Card className="overflow-hidden bg-clay-primary/10">
<div className="flex items-center gap-4">
<div className="rounded-clay bg-clay-primary p-3 text-clay-on-primary shadow-clay-raised">
<ShieldIcon size={28} />
</div>
<div>
<h1 className="clay-title-h2 text-clay-text">{t('admin.controlCenter')}</h1>
<p className="text-sm text-clay-text-muted">{t('admin.manageUsersAndRoles')}</p>
</div>
</div>
</Card>
<section aria-labelledby="admin-kpi-heading">
<h2 id="admin-kpi-heading" className="clay-title-h3 mb-3">{t('admin.dashboard.overview')}</h2>
{statsQuery.isLoading ? (
<AdminSkeleton rows={2} />
) : statsQuery.isError || !stats ? (
<Card className="text-center">
<p className="text-clay-expense">{t('admin.errors.stats')}</p>
<Button className="mt-4" onClick={() => statsQuery.refetch()}>{t('common.retry')}</Button>
</Card>
) : (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{kpis.map((kpi) => (
<Card key={kpi.label} className="p-4 text-center">
<p className={`font-baloo text-3xl font-bold ${kpi.tone}`}>{formatNumber(kpi.value)}</p>
<p className="mt-1 text-xs font-semibold text-clay-text-muted">{kpi.label}</p>
</Card>
))}
</div>
)}
</section>
<section aria-labelledby="admin-actions-heading">
<h2 id="admin-actions-heading" className="clay-title-h3 mb-3">{t('admin.dashboard.quickActions')}</h2>
<div className="grid gap-3 sm:grid-cols-3">
<button
type="button"
onClick={() => navigate('/admin/users')}
className="flex items-center justify-between rounded-clay-lg border border-clay-highlight/40 bg-clay-surface p-5 text-left shadow-clay-raised transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-clay-hover active:translate-y-0 active:shadow-clay-pressed"
>
<span className="flex items-center gap-3"><UserIcon /><span className="font-baloo font-bold text-clay-text">{t('admin.actions.users')}</span></span>
<ChevronRightIcon />
</button>
<PermissionGate permission={PERMISSIONS.ROLE_READ}>
<button
type="button"
onClick={() => navigate('/admin/roles')}
className="flex w-full items-center justify-between rounded-clay-lg border border-clay-highlight/40 bg-clay-surface p-5 text-left shadow-clay-raised transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-clay-hover active:translate-y-0 active:shadow-clay-pressed"
>
<span className="flex items-center gap-3"><KeyIcon /><span className="font-baloo font-bold text-clay-text">{t('admin.actions.roles')}</span></span>
<ChevronRightIcon />
</button>
</PermissionGate>
<PermissionGate permission={PERMISSIONS.AUDIT_LOG_READ}>
<button
type="button"
onClick={() => navigate('/admin/audit-logs')}
className="flex w-full items-center justify-between rounded-clay-lg border border-clay-highlight/40 bg-clay-surface p-5 text-left shadow-clay-raised transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-clay-hover active:translate-y-0 active:shadow-clay-pressed"
>
<span className="flex items-center gap-3"><ClockIcon /><span className="font-baloo font-bold text-clay-text">{t('admin.actions.auditLogs')}</span></span>
<ChevronRightIcon />
</button>
</PermissionGate>
</div>
</section>
<PermissionGate permission={PERMISSIONS.AUDIT_LOG_READ}>
<section aria-labelledby="admin-activity-heading">
<div className="mb-3 flex items-center justify-between gap-3">
<h2 id="admin-activity-heading" className="clay-title-h3">{t('admin.dashboard.recentActivity')}</h2>
<Button variant="ghost" className="px-3 py-1 text-sm" onClick={() => navigate('/admin/audit-logs')}>
{t('common.viewAll')}
</Button>
</div>
{auditQuery.isLoading ? (
<AdminSkeleton rows={2} />
) : auditQuery.isError ? (
<Card className="text-center">
<p className="text-clay-expense">{t('admin.errors.auditLogs')}</p>
<Button className="mt-4" onClick={() => auditQuery.refetch()}>{t('common.retry')}</Button>
</Card>
) : (auditQuery.data?.data.length ?? 0) === 0 ? (
<Card className="text-center text-clay-text-muted">{t('admin.audit.empty')}</Card>
) : (
<div className="space-y-3">
{auditQuery.data?.data.map((log) => <AuditLogCard key={log.id} log={log} compact />)}
</div>
)}
</section>
</PermissionGate>
</main>
</Page>
);
};
export default AdminDashboardPage;
import React, { useState, useMemo } from 'react';
import { Page, Header, useNavigate, useSnackbar } from 'zmp-ui';
import { Page, Header, useLocation, 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';
......@@ -8,7 +8,6 @@ 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,
......@@ -40,18 +39,16 @@ const ACTION_COLORS: Record<string, string> = {
const RolesPage: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const { openSnackbar } = useSnackbar();
const queryClient = useQueryClient();
const { t, formatDate } = useI18n();
const { t } = 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);
......@@ -77,12 +74,6 @@ const RolesPage: React.FC = () => {
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]);
......@@ -235,17 +226,12 @@ const RolesPage: React.FC = () => {
});
};
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('/')}
onBackClick={() => navigate(location.pathname.startsWith('/admin/') ? '/admin' : '/')}
/>
<IconGradients />
......@@ -265,15 +251,6 @@ const RolesPage: React.FC = () => {
</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">
......@@ -410,60 +387,6 @@ const RolesPage: React.FC = () => {
</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>
{/* ======================================================== */}
......
import React, { useEffect, useState } from 'react';
import { Header, Page, useNavigate, useParams, useSnackbar } from 'zmp-ui';
import { Avatar } from '@/components/ui/Avatar';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Select } from '@/components/ui/Select';
import { PermissionGate } from '@/components/shared/PermissionGate';
import { PERMISSIONS } from '@/common/constants';
import {
useAdminAuditLogs,
useAdminRoles,
useAdminUser,
useRestoreAdminUser,
useUpdateAdminUser,
} from '@/hooks/use-admin';
import { usePermission } from '@/hooks/use-permission';
import { useI18n } from '@/i18n';
import { getErrorMessage } from '@/lib/error-message';
import { AdminSkeleton } from '@/pages/admin/components/AdminSkeleton';
import { AuditLogCard } from '@/pages/admin/components/AuditLogCard';
import { useAuthStore } from '@/stores/auth-store';
const AdminUserDetailPage: React.FC = () => {
const { id = '' } = useParams<{ id: string }>();
const navigate = useNavigate();
const { openSnackbar } = useSnackbar();
const { t, formatDate } = useI18n();
const currentUser = useAuthStore((state) => state.user);
const { hasPermission } = usePermission();
const canReadAuditLogs = hasPermission(PERMISSIONS.AUDIT_LOG_READ);
const userQuery = useAdminUser(id);
const rolesQuery = useAdminRoles();
const auditQuery = useAdminAuditLogs({ targetId: id, limit: 20 }, canReadAuditLogs && Boolean(id));
const updateMutation = useUpdateAdminUser();
const restoreMutation = useRestoreAdminUser();
const user = userQuery.data?.success ? userQuery.data.data : undefined;
const roles = rolesQuery.data?.success ? rolesQuery.data.data : [];
const [roleId, setRoleId] = useState('');
useEffect(() => {
if (user?.roleId) setRoleId(user.roleId);
}, [user?.roleId]);
const saveRole = async () => {
if (!user || !roleId || roleId === user.roleId) return;
try {
const response = await updateMutation.mutateAsync({ id: user.id, data: { roleId } });
if (!response.success) throw new Error(response.message);
openSnackbar({ type: 'success', text: t('admin.userDetail.roleSuccess') });
} catch (error) {
setRoleId(user.roleId);
openSnackbar({ type: 'error', text: getErrorMessage(error, t('admin.errors.updateUser')) });
}
};
const restoreUser = async () => {
if (!user) return;
try {
const response = await restoreMutation.mutateAsync(user.id);
if (!response.success) throw new Error(response.message);
openSnackbar({ type: 'success', text: t('admin.userDetail.restoreSuccess') });
} catch (error) {
openSnackbar({ type: 'error', text: getErrorMessage(error, t('admin.errors.restoreUser')) });
}
};
return (
<Page className="page min-h-screen bg-clay-bg pb-12">
<Header title={t('admin.userDetail.title')} showBackIcon onBackClick={() => navigate('/admin/users')} />
<main className="mx-auto w-full max-w-4xl space-y-5 px-4 pt-6">
{userQuery.isLoading ? (
<AdminSkeleton rows={3} />
) : userQuery.isError || !user ? (
<Card className="text-center">
<p className="text-clay-expense">{t('admin.errors.userDetail')}</p>
<div className="mt-4 flex justify-center gap-3">
<Button onClick={() => userQuery.refetch()}>{t('common.retry')}</Button>
<Button variant="secondary" onClick={() => navigate('/admin/users')}>{t('admin.users.title')}</Button>
</div>
</Card>
) : (
<>
<Card>
<div className="flex flex-col gap-5 sm:flex-row sm:items-center">
<Avatar
size="lg"
src={user.avatarUrl || ''}
positionX={user.avatarPositionX}
positionY={user.avatarPositionY}
/>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h1 className="clay-title-h2 truncate">{user.fullName || user.email || t('admin.users.unnamed')}</h1>
<Badge type={user.deletedAt ? 'expense' : user.isActive ? 'income' : 'warning'}>
{user.deletedAt ? t('admin.users.deleted') : user.isActive ? t('admin.users.active') : t('admin.users.inactive')}
</Badge>
</div>
<p className="mt-1 break-all text-clay-text-muted">{user.email || '—'}</p>
<dl className="mt-4 grid gap-3 text-sm sm:grid-cols-2">
<div><dt className="text-clay-text-muted">{t('admin.userDetail.phone')}</dt><dd className="font-semibold text-clay-text">{user.phoneNumber || '—'}</dd></div>
<div><dt className="text-clay-text-muted">{t('admin.userDetail.createdAt')}</dt><dd className="font-semibold text-clay-text">{formatDate(user.createdAt, { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })}</dd></div>
<div><dt className="text-clay-text-muted">{t('admin.userDetail.updatedAt')}</dt><dd className="font-semibold text-clay-text">{formatDate(user.updatedAt, { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })}</dd></div>
<div><dt className="text-clay-text-muted">ID</dt><dd className="break-all font-mono text-xs text-clay-text">{user.id}</dd></div>
</dl>
</div>
</div>
</Card>
<Card>
<h2 className="clay-title-h3">{t('admin.userDetail.access')}</h2>
<p className="mb-4 mt-1 text-sm text-clay-text-muted">{t('admin.userDetail.accessDescription')}</p>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
<Select
label={t('admin.users.role')}
value={roleId}
disabled={!hasPermission(PERMISSIONS.USER_UPDATE) || user.id === currentUser?.id || Boolean(user.deletedAt) || rolesQuery.isLoading}
options={roles.map((role) => ({ value: role.id, label: role.name }))}
onChange={(event) => setRoleId(event.target.value)}
/>
<PermissionGate permission={PERMISSIONS.USER_UPDATE}>
<Button
className="sm:mb-0.5"
disabled={!roleId || roleId === user.roleId || user.id === currentUser?.id || updateMutation.isPending || Boolean(user.deletedAt)}
onClick={saveRole}
>
{t('common.save')}
</Button>
</PermissionGate>
</div>
{user.deletedAt && (
<PermissionGate permission={PERMISSIONS.USER_RESTORE}>
<div className="mt-5 border-t border-clay-text-muted/10 pt-5">
<p className="mb-3 text-sm text-clay-text-muted">{t('admin.userDetail.restoreDescription')}</p>
<Button disabled={restoreMutation.isPending} onClick={restoreUser}>{t('admin.userDetail.restore')}</Button>
</div>
</PermissionGate>
)}
</Card>
<PermissionGate permission={PERMISSIONS.AUDIT_LOG_READ}>
<section aria-labelledby="user-audit-heading">
<h2 id="user-audit-heading" className="clay-title-h3 mb-3">{t('admin.userDetail.history')}</h2>
{auditQuery.isLoading ? (
<AdminSkeleton rows={2} />
) : auditQuery.isError ? (
<Card className="text-center">
<p className="text-clay-expense">{t('admin.errors.auditLogs')}</p>
<Button className="mt-4" onClick={() => auditQuery.refetch()}>{t('common.retry')}</Button>
</Card>
) : (auditQuery.data?.data.length ?? 0) === 0 ? (
<Card className="text-center text-clay-text-muted">{t('admin.audit.empty')}</Card>
) : (
<div className="space-y-3">{auditQuery.data?.data.map((log) => <AuditLogCard key={log.id} log={log} />)}</div>
)}
</section>
</PermissionGate>
</>
)}
</main>
</Page>
);
};
export default AdminUserDetailPage;
This diff is collapsed.
......@@ -6,6 +6,7 @@ import { Avatar } from "@/components/ui/Avatar";
import { useAuthStore } from "@/stores/auth-store";
import {
AIAssistantIcon,
ChevronRightIcon,
ClockIcon,
IconGradients,
LightbulbIcon,
......@@ -62,6 +63,25 @@ function HomePage() {
{/* Navigation CTA */}
<div className="px-4 w-full max-w-sm mx-auto flex flex-col gap-3">
<PermissionGate permission={PERMISSIONS.USER_READ}>
<button
type="button"
onClick={() => navigate('/admin')}
className="flex w-full items-center justify-between rounded-clay-lg border border-clay-highlight/40 bg-clay-primary/10 p-4 text-left shadow-clay-raised transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-clay-hover active:translate-y-0 active:shadow-clay-pressed"
>
<span className="flex items-center gap-3">
<span className="rounded-clay-sm bg-clay-primary p-2 text-clay-on-primary">
<ShieldIcon size={24} />
</span>
<span>
<span className="block font-baloo font-bold text-clay-text">{t('admin.controlCenter')}</span>
<span className="block text-xs text-clay-text-muted">{t('admin.manageUsersAndRoles')}</span>
</span>
</span>
<ChevronRightIcon />
</button>
</PermissionGate>
{/* Core AI & Query */}
<Button
variant="primary"
......@@ -193,16 +213,6 @@ 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,9 +15,7 @@ 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 { EyeIcon, EyeOffIcon, IconGradients, ShieldIcon } from "@/components/ui/icons";
import { PermissionGate } from "@/components/shared/PermissionGate";
import { PERMISSIONS } from "@/common/constants";
import { EyeIcon, EyeOffIcon, IconGradients } from "@/components/ui/icons";
import {
AvatarEditorModal,
AvatarPosition,
......@@ -591,21 +589,6 @@ 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">
......
......@@ -29,6 +29,17 @@ export interface AuditLogListResponse {
};
}
export interface AuditLogQueryParams {
actorId?: string;
action?: string;
targetType?: string;
targetId?: string;
dateFrom?: string;
dateTo?: string;
page?: number;
limit?: number;
}
export const rbacService = {
// Roles
async getRoles(params?: {
......@@ -96,13 +107,9 @@ export const rbacService = {
},
// Audit Logs
async getAuditLogs(params?: {
actorId?: string;
action?: string;
targetType?: string;
page?: number;
limit?: number;
}): Promise<ApiResponse<AuditLog[]> & { meta?: AuditLogListResponse['meta'] }> {
async getAuditLogs(
params?: AuditLogQueryParams
): Promise<ApiResponse<AuditLog[]> & { meta?: AuditLogListResponse['meta'] }> {
const response = await apiClient.get('/audit-logs', { params });
return response.data;
},
......
import { apiClient } from '@/lib/api-client';
import { ApiResponse } from '@/types/auth';
import {
AdminStats,
AdminActionResponse,
AdminUser,
CreateAdminUserInput,
PaginationMeta,
UpdateAdminUserInput,
UserListParams,
} from '@/types/admin';
export type UserListResponse = ApiResponse<AdminUser[]> & { meta: PaginationMeta };
export const userService = {
async getUsers(params?: UserListParams): Promise<UserListResponse> {
const response = await apiClient.get('/users', { params });
return response.data;
},
async getUserById(id: string): Promise<ApiResponse<AdminUser>> {
const response = await apiClient.get(`/users/${id}`);
return response.data;
},
async createUser(data: CreateAdminUserInput): Promise<ApiResponse<AdminUser>> {
const response = await apiClient.post('/users', data);
return response.data;
},
async updateUser(id: string, data: UpdateAdminUserInput): Promise<ApiResponse<AdminUser>> {
const response = await apiClient.put(`/users/${id}`, data);
return response.data;
},
async deleteUser(id: string): Promise<AdminActionResponse> {
const response = await apiClient.delete(`/users/${id}`);
return response.data;
},
async restoreUser(id: string): Promise<ApiResponse<AdminUser>> {
const response = await apiClient.post(`/users/${id}/restore`);
return response.data;
},
async getAdminStats(): Promise<ApiResponse<AdminStats>> {
const response = await apiClient.get('/users/admin/stats');
return response.data;
},
};
import { User } from '@/types/auth';
export interface AdminUser extends User {
deletedAt?: string | null;
deletedBy?: string | null;
}
export interface AdminStats {
totalUsers: number;
activeUsers: number;
inactiveUsers: number;
deletedUsers: number;
totalRoles: number;
}
export interface PaginationMeta {
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface UserListParams {
email?: string;
fullName?: string;
roleName?: string;
isActive?: boolean;
page?: number;
limit?: number;
}
export interface CreateAdminUserInput {
email: string;
password: string;
roleId: string;
fullName?: string;
}
export interface UpdateAdminUserInput {
isActive?: boolean;
roleId?: string;
}
export interface AdminActionResponse {
success: boolean;
message: string;
data?: unknown;
errors?: unknown[] | null;
}
......@@ -49,11 +49,13 @@ export interface User {
export interface AuditLog {
id: string;
actorId?: string | null;
actorEmail?: string | null;
action: string;
targetType: string;
targetId?: string | null;
previousState?: any;
newState?: any;
targetLabel?: string | null;
previousState?: unknown;
newState?: unknown;
ipAddress?: string | null;
userAgent?: string | null;
createdAt: 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