|
| 1 | +import { useMemo, type ReactNode } from 'react'; |
| 2 | +import { useUserInfoQuery, type UserInfo } from '@/api/user'; |
| 3 | + |
| 4 | +/** |
| 5 | + * Central permission policy. |
| 6 | + * |
| 7 | + * The backend ships **both** structures on `userInfo`: |
| 8 | + * - `roles: string[]` — coarse, RBAC-style |
| 9 | + * - `permissions: Record<string, string[]>` — optional fine-grained overlay |
| 10 | + * |
| 11 | + * Most flags are computed from roles alone. A handful of "give *this* |
| 12 | + * user a specific extra capability" overrides come from `permissions`. |
| 13 | + * Pages and components only consume the resolved booleans returned by |
| 14 | + * `defineAccess` / `useAccess` — they never touch the raw structures. |
| 15 | + * |
| 16 | + * When the backend renames a role or restructures permissions, the |
| 17 | + * fix is one edit to this file. When a new feature needs a gate, add |
| 18 | + * a flag here and consume it via `useAccess()` from any component. |
| 19 | + * |
| 20 | + * `userInfo` is `undefined` while the first fetch is in flight — every |
| 21 | + * flag defaults to `false` (fail closed) so sensitive UI never flashes |
| 22 | + * visible before permissions resolve. |
| 23 | + */ |
| 24 | +export function defineAccess(userInfo: UserInfo | undefined) { |
| 25 | + const roles = userInfo?.roles ?? []; |
| 26 | + const perms = userInfo?.permissions ?? {}; |
| 27 | + |
| 28 | + /** Has at least one of the listed roles. */ |
| 29 | + const is = (...roleKeys: string[]): boolean => |
| 30 | + roleKeys.some((r) => roles.includes(r)); |
| 31 | + |
| 32 | + /** Has `action` on `resource` (or the wildcard `'*'`) via permissions overlay. */ |
| 33 | + const can = (resource: string, action: string): boolean => { |
| 34 | + const granted = perms[resource]; |
| 35 | + if (!granted) return false; |
| 36 | + return granted.includes('*') || granted.includes(action); |
| 37 | + }; |
| 38 | + |
| 39 | + return { |
| 40 | + // ─── Role-based (mirrors the mock's 'admin' / 'user' seed) ───── |
| 41 | + isAdmin: is('admin'), |
| 42 | + |
| 43 | + // ─── Feature gates (role OR explicit permission grant) ───────── |
| 44 | + // Most apps lean almost entirely on roles and sprinkle `can(...)` |
| 45 | + // overrides only when a single user needs a capability outside |
| 46 | + // their role. |
| 47 | + canViewMonitor: is('admin') || can('menu.dashboard.monitor', 'write'), |
| 48 | + canViewAnalytics: |
| 49 | + is('admin', 'analyst') || can('menu.visualization.dataAnalysis', 'read'), |
| 50 | + canViewMultiDim: |
| 51 | + is('admin') || |
| 52 | + can('menu.visualization.dataAnalysis', 'write') || |
| 53 | + can('menu.visualization.multiDimensionDataAnalysis', 'write'), |
| 54 | + canUseGroupForm: is('admin') || can('menu.form.group', 'write'), |
| 55 | + canUseStepForm: is('admin', 'user') || can('menu.form.step', 'read'), |
| 56 | + |
| 57 | + // ─── Element-level (used by buttons inside pages) ────────────── |
| 58 | + canViewLists: |
| 59 | + is('admin', 'user', 'editor') || |
| 60 | + can('menu.list.searchTable', 'read') || |
| 61 | + can('menu.list.cardList', 'read'), |
| 62 | + canManageContent: is('admin', 'editor') || can('menu.list.searchTable', 'write'), |
| 63 | + }; |
| 64 | +} |
| 65 | + |
| 66 | +export type AccessMap = ReturnType<typeof defineAccess>; |
| 67 | + |
| 68 | +/** |
| 69 | + * Pre-computed permission flags for the current user. Pages read these |
| 70 | + * via `access.canX` and pass them to `<Access>` or use them directly |
| 71 | + * for non-rendering logic (disabled state, tab visibility, etc.). |
| 72 | + * |
| 73 | + * The underlying `useUserInfoQuery` is cached by TanStack Query so |
| 74 | + * calling `useAccess()` in many components is cheap — they all share |
| 75 | + * the one userInfo fetch. |
| 76 | + */ |
| 77 | +export function useAccess(): AccessMap { |
| 78 | + const { data: userInfo } = useUserInfoQuery(); |
| 79 | + return useMemo(() => defineAccess(userInfo), [userInfo]); |
| 80 | +} |
| 81 | + |
| 82 | +interface AccessProps { |
| 83 | + /** Boolean flag, usually `access.canX` from `useAccess()`. */ |
| 84 | + accessible: boolean; |
| 85 | + /** |
| 86 | + * Rendered when `accessible` is false. Default: `null` (hide). |
| 87 | + * Pass a disabled-but-visible variant (Tooltip + disabled Button) |
| 88 | + * when "no permission" needs to be discoverable rather than silent. |
| 89 | + */ |
| 90 | + fallback?: ReactNode; |
| 91 | + children: ReactNode; |
| 92 | +} |
| 93 | + |
| 94 | +/** |
| 95 | + * Declarative permission gate. Mirrors ant-design-pro's `<Access>` API |
| 96 | + * so the muscle memory transfers. |
| 97 | + * |
| 98 | + * ```tsx |
| 99 | + * const access = useAccess(); |
| 100 | + * return ( |
| 101 | + * <Access accessible={access.canEditUser}> |
| 102 | + * <Button onClick={onEdit}>Edit</Button> |
| 103 | + * </Access> |
| 104 | + * ); |
| 105 | + * ``` |
| 106 | + * |
| 107 | + * For route-level gating use `<ProtectedRoute requireAccess={…}>` |
| 108 | + * instead — it can redirect to `/exception/403` rather than just |
| 109 | + * hiding the children. |
| 110 | + */ |
| 111 | +export function Access({ accessible, fallback = null, children }: AccessProps) { |
| 112 | + return <>{accessible ? children : fallback}</>; |
| 113 | +} |
0 commit comments