/** * scaffold-frontend-auth/generate.ts — Emits the canonical auth primitives. * * Two files, both under the project's `web/{appCode}-web/src/` tree : * - `business/auth/useAuth.ts` — thin ADAPTER over the package's * `useAuth()` (`@atlashub/smartstack` AuthContext) adding the house * `hasPermission()` matching. The package is the SINGLE auth source — * no parallel store, no duplicate `/api/auth/me` bootstrap. * - `components/auth/PermissionGuard.tsx` — wraps children, returns null * while auth is loading or when the permission is missing. * * WHY an adapter and not the package hook directly: the package's * `hasPermission` (utils/permissions.ts) does exact + `prefix.*` wildcard * matching ONLY. Generated pages pass the page-side 3-/4-segment path WITHOUT * the appCode prefix (`{module}.{section}.{action}` — the BA convention), * while seeded grants are canonical 4-/5-segment (`{app}.…`). Without the * strip-leading-segment pass below, every PermissionGuard/RowActionsMenu * check fails for role-based users (only `*` super-admins pass) — invisible * in dev, fatal in UAT. */ import type { GeneratedFile, ScaffoldFrontendAuthInput } from './types.js'; const HEADER = '/* generated by scaffold-frontend-auth — overwrite-safe ; add the line `// @customised` at the top to lock against regeneration */\n'; export function generate(spec: ScaffoldFrontendAuthInput): GeneratedFile[] { const webRoot = `web/${spec.appCode}-web/src`; return [ { path: `${webRoot}/business/auth/useAuth.ts`, content: useAuthHook() }, { path: `${webRoot}/components/auth/PermissionGuard.tsx`, content: permissionGuard() }, ]; } function useAuthHook(): string { return `${HEADER}import { useCallback } from 'react'; import { useAuth as useSmartStackAuth } from '@atlashub/smartstack'; type PackageAuth = ReturnType; export interface UseAuthReturn { user: PackageAuth['user']; /** Full permission paths granted to the current user, verbatim from the * package AuthContext (\`user.permissions\`). Canonical shape: * \`{app}.{module}.{section}[.{resource}].{action}\` (4 or 5 segments). */ permissions: string[]; isAuthenticated: boolean; loading: boolean; /** * True when the user holds the permission. Four match modes, tried in * order (first hit wins) : * * 1. **Super-admin wildcard** — \`permissions\` contains \`"*"\`. Always true. * Mirrors SmartStack's \`WILDCARD_PERMISSION\` convention. * 2. **Fast strict match** — caller passed a fully-qualified path that * matches a store entry verbatim (4-/5-seg with appCode). * 3. **Subtree wildcard** — an entry like \`testv2.budgets.*\` or * \`budgets.*\` covers any path under that prefix (canonical or stripped * form, so the wildcard also matches the page-side 3-/4-seg input). * 4. **Strip-leading-segment match** — the BA convention emits page-side * paths without the appCode segment (3-seg section / 4-seg resource). * Strip the leading segment of each entry and compare strictly. * * The package's own \`hasPermission\` covers modes 1-3 only — mode 4 is the * reason this adapter exists (pages stay app-agnostic; grants stay * canonical). Case-sensitive across all passes. */ hasPermission: (path: string) => boolean; /** Force a permissions re-fetch (package \`refreshPermissions\`). Useful * after a role change in the admin UI. */ refresh: () => Promise; } /** * House auth hook — the package's \`useAuth()\` (single source: the * \`AuthProvider\` already mounted by the app shell) with the page-side * permission matching layered on top. Import THIS from generated pages and * primitives; import the package hook directly only when you need the raw * strict matcher. */ export function useAuth(): UseAuthReturn { const { user, isAuthenticated, isLoading, refreshPermissions } = useSmartStackAuth(); const permissions = user?.permissions ?? []; const hasPermission = useCallback((path: string): boolean => { if (!path) return false; const perms = user?.permissions ?? []; // 1. Super-admin shortcut. SmartStack convention : \`*\` = all permissions. // Without this branch, a platform admin seeded with [\`"*"\`] sees every // PermissionGuard return null and the UI renders blank. if (perms.includes('*')) return true; // 2. Fast strict match : caller already passed a fully-qualified // 4-/5-seg path that matches an entry verbatim. if (perms.includes(path)) return true; // 3. & 4. Per-entry inspection — covers subtree wildcards // (\`testv2.budgets.*\`) and the BA convention (page emits 3-seg // section / 4-seg resource without the appCode prefix, the grant // carries the canonical 4-/5-seg form). return perms.some((entry) => { // 3. Subtree wildcard. Match against both the canonical prefix // (\`testv2.budgets.\`) and the page-side stripped prefix // (\`budgets.\`) so the same entry covers both shapes of input. if (entry.endsWith('.*')) { const prefix = entry.slice(0, -1); // keeps the trailing dot if (path.startsWith(prefix)) return true; const dot = prefix.indexOf('.'); if (dot >= 0) { const strippedPrefix = prefix.slice(dot + 1); if (strippedPrefix && path.startsWith(strippedPrefix)) return true; } return false; } // 4. Strip leading appCode segment, compare strictly with input. const dot = entry.indexOf('.'); if (dot < 0) return false; return entry.slice(dot + 1) === path; }); }, [user]); const refresh = useCallback(async (): Promise => { await refreshPermissions(); }, [refreshPermissions]); return { user, permissions, isAuthenticated, loading: isLoading, hasPermission, refresh, }; } `; } function permissionGuard(): string { return `${HEADER}import type { ReactNode } from 'react'; import { useAuth } from '@/business/auth/useAuth'; interface PermissionGuardProps { /** * Permission path the user must hold to see the children. **Page-side** * shape (no appCode prefix) : * - section-scoped : 3 segments \`{module}.{section}.{action}\` * - resource-scoped : 4 segments \`{module}.{section}.{resource}.{action}\` * * The leading appCode segment is carried by the granted entries (the * canonical \`permission.Path\` from the DB) and stripped at * \`hasPermission()\` time. Pages stay app-agnostic ; the grants stay * canonical. */ permission: string; children: ReactNode; /** * Optional placeholder rendered when the user lacks the permission. * Default: nothing (the gated block disappears entirely). */ fallback?: ReactNode; /** * PAGE-level guard: when denied, render a visible "access denied" block * (carrying \`data-testid="permission-denied"\`) instead of a blank page — * a stable, locale-independent signal for automated UAT and a clearer UX. * Leave false for INLINE guards (row buttons, toolbar actions) so a denied * affordance simply disappears. */ page?: boolean; } /** * Hides children when : * - the auth bootstrap is still loading (no flash of forbidden UI), * - the user is not authenticated, * - or hasPermission(permission) returns false. * * This component does ONE thing on purpose. Business logic that * conditionally enables actions per row / per column / per state lives * in the consuming component, not here. */ export function PermissionGuard({ permission, children, fallback = null, page = false, }: PermissionGuardProps): ReactNode { const { loading, isAuthenticated, hasPermission } = useAuth(); if (loading) return null; if (!isAuthenticated) return fallback; if (!hasPermission(permission)) { if (page) { return (
{fallback ?? 'Accès refusé'}
); } return fallback; } return children; } export default PermissionGuard; `; }