[CLAUDE] Phase1.2: CRUD Master + Permission Matrix + FE admin pages
Backend:
- Domain/Master: Supplier (+ SupplierType 5 loai), Project, Department (AuditableEntity)
- Domain/Identity: MenuItem, Permission, MenuKeys const (12 menu)
- EF Configurations voi unique Code + query filter IsDeleted
- DbSets + IApplicationDbContext interface update
- Application: PagedResult + PagedRequest generic
- Application/Master CQRS CRUD 3 entity (Create/Update/Delete/Get/List voi paging search sort)
- Application/Permissions: GetMyMenuTree (union OR role, filter tree), ListMenuItems, ListPermissionsByRole, UpsertPermission (guard admin khong tu giam quyen), ListRoles
- Api/Authorization: MenuPermissionRequirement + Handler (Admin bypass, query DB)
- Program.cs: register 48 policy {menu}.{action} tu MenuKeys x Actions
- Api/Controllers: Suppliers, Projects, Departments, Menus, Roles, Permissions
- DbInitializer: seed 12 menu + admin full CRUD permissions
- Migration AddMasterData + AddPermissions
Frontend (fe-admin):
- Types: menuKeys.ts const, menu.ts (MenuNode/Role/Permission), master.ts (Supplier/Project/Department + SupplierType const-object)
- AuthContext: load menu from /menus/me, cache localStorage, refreshMenu()
- usePermission hook + PermissionGuard component (wrap button)
- UI kit them: Dialog (modal overlay), Textarea, Select
- Generic: DataTable (column config, sortable, loading, empty) + Pagination
- PageHeader component
- apiError helper extract message tu ProblemDetails
- Layout rewrite: render menu dong tu AuthContext.menu (MenuGroup collapsible + NavLink + lucide icon map)
- Pages: master/Suppliers, master/Projects, master/Departments (CRUD + search + sort + paging + Dialog form)
- Page system/Permissions: ma tran Role x MenuKey x CRUD checkbox (tick tu dong PUT upsert)
- App.tsx them 4 route moi
Bug fix:
- MenuPermissionHandler: EF expression tree khong support switch expression -> tach switch ra ngoai AnyAsync
- TS erasableSyntaxOnly khong cho enum -> SupplierType const-object pattern (typeof[keyof])
E2E verified via Vite proxy:
- GET /menus/me -> 6 root + 6 child nodes (12 menus)
- GET /roles -> 12 roles
- POST/GET/PUT/DELETE /suppliers -> full CRUD, soft delete OK
- tsc -b fe-admin pass
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
129
fe-admin/src/pages/system/PermissionsPage.tsx
Normal file
129
fe-admin/src/pages/system/PermissionsPage.tsx
Normal file
@ -0,0 +1,129 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { api } from '@/lib/api'
|
||||
import { getErrorMessage } from '@/lib/apiError'
|
||||
import type { MenuItem, Permission, Role } from '@/types/menu'
|
||||
|
||||
type CrudKey = 'canRead' | 'canCreate' | 'canUpdate' | 'canDelete'
|
||||
const CRUD_COLS: { key: CrudKey; label: string }[] = [
|
||||
{ key: 'canRead', label: 'Xem' },
|
||||
{ key: 'canCreate', label: 'Tạo' },
|
||||
{ key: 'canUpdate', label: 'Sửa' },
|
||||
{ key: 'canDelete', label: 'Xóa' },
|
||||
]
|
||||
|
||||
export function PermissionsPage() {
|
||||
const qc = useQueryClient()
|
||||
const [roleId, setRoleId] = useState<string>('')
|
||||
|
||||
const roles = useQuery({
|
||||
queryKey: ['roles'],
|
||||
queryFn: async () => (await api.get<Role[]>('/roles')).data,
|
||||
})
|
||||
|
||||
const menus = useQuery({
|
||||
queryKey: ['menus', 'all'],
|
||||
queryFn: async () => (await api.get<MenuItem[]>('/menus')).data,
|
||||
})
|
||||
|
||||
const perms = useQuery({
|
||||
queryKey: ['permissions', roleId],
|
||||
queryFn: async () => (await api.get<Permission[]>(`/permissions/by-role/${roleId}`)).data,
|
||||
enabled: !!roleId,
|
||||
})
|
||||
|
||||
const upsert = useMutation({
|
||||
mutationFn: async (p: { menuKey: string; canRead: boolean; canCreate: boolean; canUpdate: boolean; canDelete: boolean }) => {
|
||||
await api.put('/permissions', { roleId, ...p })
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['permissions', roleId] })
|
||||
},
|
||||
onError: err => toast.error(getErrorMessage(err)),
|
||||
})
|
||||
|
||||
const permMap = useMemo(() => {
|
||||
const map = new Map<string, Permission>()
|
||||
for (const p of perms.data ?? []) map.set(p.menuKey, p)
|
||||
return map
|
||||
}, [perms.data])
|
||||
|
||||
function currentFlags(menuKey: string) {
|
||||
const p = permMap.get(menuKey)
|
||||
return {
|
||||
canRead: p?.canRead ?? false,
|
||||
canCreate: p?.canCreate ?? false,
|
||||
canUpdate: p?.canUpdate ?? false,
|
||||
canDelete: p?.canDelete ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
function toggle(menuKey: string, field: CrudKey) {
|
||||
const flags = currentFlags(menuKey)
|
||||
const next = { ...flags, [field]: !flags[field] }
|
||||
upsert.mutate({ menuKey, ...next })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageHeader
|
||||
title="Ma trận phân quyền"
|
||||
description="Tick để gán quyền Xem / Tạo / Sửa / Xóa cho từng menu theo vai trò. Thay đổi lưu tự động."
|
||||
/>
|
||||
|
||||
<div className="mb-4 max-w-sm">
|
||||
<Select value={roleId} onChange={e => setRoleId(e.target.value)}>
|
||||
<option value="">-- Chọn vai trò --</option>
|
||||
{roles.data?.map(r => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{roleId && (
|
||||
<div className="overflow-auto rounded-md border border-slate-200 bg-white">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 text-slate-700">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium">Menu</th>
|
||||
{CRUD_COLS.map(c => (
|
||||
<th key={c.key} className="px-3 py-2 text-center font-medium w-20">{c.label}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{menus.data?.map(m => {
|
||||
const flags = currentFlags(m.key)
|
||||
const depth = m.parentKey ? 1 : 0
|
||||
return (
|
||||
<tr key={m.key} className="border-t border-slate-100">
|
||||
<td className="px-3 py-2" style={{ paddingLeft: `${0.75 + depth * 1.5}rem` }}>
|
||||
<span className="font-medium text-slate-800">{m.label}</span>
|
||||
<span className="ml-2 font-mono text-xs text-slate-400">{m.key}</span>
|
||||
</td>
|
||||
{CRUD_COLS.map(c => (
|
||||
<td key={c.key} className="px-3 py-2 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 cursor-pointer accent-brand-600"
|
||||
checked={flags[c.key]}
|
||||
disabled={upsert.isPending}
|
||||
onChange={() => toggle(m.key, c.key)}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user