diff --git a/src/api/client.ts b/src/api/client.ts index 14206c6..b1b3f4e 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -96,8 +96,27 @@ export class ZinoClient { this.setToken(null); } + /** + * The signed-in user, from the server. + * + * Needed because **the JWT does not carry roles.** Its claims are only + * user_id / name / email / sub / exp / iat — so `currentUser()` below can + * restore an identity across a refresh but never a permission set. The login + * *response body* has roles, and so does this endpoint; nothing else does. + * + * Without this, a page reload left every user with no roles, which silently + * emptied the action bar on every screen. + */ + me(): Promise { + return this.request('GET', '/usr/me'); + } + /** Decode the persisted JWT into a User — no network round trip, so a hard - * refresh restores the session without flashing the login screen. */ + * refresh restores the session without flashing the login screen. + * + * NOTE: the token has NO roles claim, so the User this returns always has an + * empty `roles`. The provider follows it up with `me()` for the real + * permission set; anything that gates on roles must tolerate the gap. */ currentUser(): User | null { if (!this.token) return null; try { diff --git a/src/api/config.ts b/src/api/config.ts index 5237695..e061f92 100644 --- a/src/api/config.ts +++ b/src/api/config.ts @@ -148,9 +148,26 @@ export const STAGE_ACTIONS: Record = { ], }; +/** + * Which actions to offer on a file. + * + * FAILS OPEN, and that is deliberate. The JWT carries no roles claim, so there + * is a real window — a restored session before `/usr/me` answers, or that call + * failing — where the console does not know what the user may do. Filtering on + * an empty role list in that window hid EVERY button on EVERY file and made a + * working app look broken, which is exactly what happened the first time this + * shipped. + * + * So: with no roles known, show every action the stage allows and let the server + * refuse what it must. That refusal is safe and legible — the workflow answers + * "user 29533 does not have permission for activity …" and the form surfaces it + * verbatim. Hiding a button the user needs is the worse failure, because nothing + * on screen explains it. + */ export function actionsFor(stage: string | undefined, roles: string[] | undefined): Action[] { const all = STAGE_ACTIONS[stage ?? ''] ?? []; const mine = roles ?? []; + if (mine.length === 0) return all; if (mine.some((r) => SUPER_ROLES.includes(r))) return all; return all.filter((a) => a.roles.some((r) => mine.includes(r))); } diff --git a/src/api/provider.tsx b/src/api/provider.tsx index 2865f79..8e629c3 100644 --- a/src/api/provider.tsx +++ b/src/api/provider.tsx @@ -33,6 +33,35 @@ export function ZinoProvider({ baseUrl, children }: { baseUrl: string; children: client.setAuthErrorHandler(() => setUser(null)); }, [client]); + // The token restores WHO you are but not WHAT YOU MAY DO — it carries no roles + // claim (only user_id / name / email / sub / exp / iat). So when a session is + // restored from storage rather than created by a fresh login, ask the server + // for the real user. Without this, a page refresh left roles empty and every + // role-gated action bar rendered blank. + // + // Keyed on "have a token, have no roles" rather than running unconditionally: + // a fresh login already carries roles in its response body, and re-fetching + // would be a wasted round trip on every mount. + useEffect(() => { + if (!client.getToken()) return; + if ((user?.roles?.length ?? 0) > 0) return; + let live = true; + client + .me() + .then((u) => { + if (live) setUser(u); + }) + .catch(() => { + // Must not sign anyone out — the identity from the token is still good. + // Role-gated UI falls OPEN in this case; the server enforces permissions + // regardless of what gets rendered. + }); + return () => { + live = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [client, user?.id, user?.roles?.length]); + const value = useMemo( () => ({ client,