Skip to content

Commit a2ab44a

Browse files
iammukeshmclaude
andcommitted
docs(frontend): dashboard permissions are fetched from the API, not the JWT
The dashboard JWT carries only role names; auth-context.tsx fetches the effective permission list from GET /api/v1/identity/permissions, caches it, and exposes permissionsHydrated. Update the agent rules/skills to match the shipped behavior, and align the Playwright auth-seed helper: permissions in the fake JWT are inert (gated specs mock GET /identity/permissions instead). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent be91d4b commit a2ab44a

4 files changed

Lines changed: 15 additions & 7 deletions

File tree

.agents/rules/frontend/dashboard.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ Tenant-facing application. Read `frontend/shared.md` first; this file is only th
1010

1111
The dashboard does **not** depend on react-hook-form or zod. Use controlled inputs + local state. Don't add those deps to match admin.
1212

13-
## Permissions — straight from the JWT
13+
## Permissions — fetched, not in the JWT
1414

15-
`auth-context.tsx` reads `claims.permissions` off the decoded JWT — no separate fetch, no `permissionsHydrated`, no permissions cache key. `ProtectedRoute` is **auth-only** (no permission gating). Don't add `RouteGuard`-style gating here.
15+
The JWT carries **only role names**. `auth-context.tsx` fetches the effective permission list from `GET /api/v1/identity/permissions` (`getMyPermissions()` in `src/api/identity.ts`), caches it in `tokenStore` under `fsh.dashboard.permissions`, and exposes a `permissionsHydrated` flag so gated UI doesn't flash while the fetch is in flight (re-fetched on login/impersonation swaps; `refreshPermissions()` for role changes). Nav items are gated via `perm`/`anyPerm` in `src/components/layout/nav-data.ts`. `ProtectedRoute` is still **auth-only** (no per-route permission gating) — don't add `RouteGuard`-style gating here.
1616

1717
## Routing & realtime/SSE
1818

.agents/skills/add-permission/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ So `RouteGuard` passes on first paint, add the new permission to the test seed s
6161

6262
## Dashboard
6363

64-
No mirror, no `RouteGuard`. The permission rides in the JWT (`claims.permissions`) and the server enforces it; a missing permission yields a 403 the UI surfaces. Nothing to add client-side beyond consuming the gated endpoint.
64+
No mirror, no `RouteGuard`. The JWT carries only role names — the app fetches the permission list from `GET /api/v1/identity/permissions` at hydration and the server enforces access; a missing permission yields a 403 the UI surfaces. Routes aren't permission-gated; to hide a nav entry, set `perm`/`anyPerm` on the item in `src/components/layout/nav-data.ts`. Permission-gated specs mock `GET /identity/permissions` with the grants they need (shell mocks stub it to `[]`).
6565

6666
## Checklist
6767

.agents/skills/add-react-page/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ The frontend slice. Read `.agents/rules/frontend/shared.md` plus the app file (`
1717
| Forms | **react-hook-form + zod** | **hand-rolled** controlled inputs (no RHF/zod) |
1818
| List + create | separate routed pages (`list.tsx`, `create.tsx`) | one file with `<Dialog>` editors |
1919
| Route wrapper | `<RouteGuard perms={[…]}>` | `withSuspense(<X/>)` (no permission gate) |
20-
| Permissions | mirror in `src/lib/permissions.ts` | none — JWT claims + server 403 |
20+
| Permissions | mirror in `src/lib/permissions.ts` | fetched from `GET /identity/permissions` (not JWT); nav gating via `perm`/`anyPerm` in `nav-data.ts`; no route guard — server 403 backstops |
2121

2222
Shared everywhere: types are **hand-written** (no codegen); `apiFetch<T>` from `@/lib/api-client`; `cn()` from `@/lib/cn`; `env.apiBase` from runtime `/config.json`; CVA `components/ui` + `components/list` primitives; Tailwind v4 CSS-first (tokens in `src/styles/globals.css`); `toast` from `sonner`; pages are **named exports**; `placeholderData: keepPreviousData` (v5).
2323

clients/dashboard/tests/helpers/auth-seed.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,23 @@ import type { Page } from "@playwright/test";
77
* We populate the same localStorage keys the runtime tokenStore writes
88
* (see clients/dashboard/src/auth/token-store.ts). The token value is
99
* a JWT-shaped string that decodes to the supplied user — useAuth's
10-
* decoder reads sub/email/given_name/family_name/tenant/permissions
11-
* out of the payload.
10+
* decoder reads sub/email/given_name/family_name/tenant out of the
11+
* payload. Permissions are NOT read from the JWT: at runtime the app
12+
* fetches them from GET /api/v1/identity/permissions (installShellMocks
13+
* stubs that route to []; permission-gated specs re-mock it with the
14+
* grants they need — see tests/system/trash.spec.ts).
1215
*/
1316
export type SeededUser = {
1417
sub: string;
1518
email: string;
1619
firstName: string;
1720
lastName: string;
1821
tenant: string;
22+
/**
23+
* Inert — written into the fake JWT payload but ignored by the app.
24+
* To grant permissions in a spec, mock GET /identity/permissions
25+
* instead.
26+
*/
1927
permissions?: string[];
2028
};
2129

@@ -36,7 +44,7 @@ function fakeJwt(payload: Record<string, unknown>): string {
3644

3745
export async function seedAuthedSession(page: Page, user: SeededUser) {
3846
// Build the JWT-shaped payload. Claim names match what useAuth's decoder
39-
// looks for in the runtime path.
47+
// looks for in the runtime path (permissions excepted — inert, see SeededUser).
4048
const payload = {
4149
sub: user.sub,
4250
email: user.email,

0 commit comments

Comments
 (0)