From 046c4951db7d7da1ef8f868ac92beed8a2dbc53a Mon Sep 17 00:00:00 2001 From: Yashas Date: Mon, 17 Aug 2026 18:09:47 +0530 Subject: [PATCH] fix: resolve roles from the server, and fail open when they are unknown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every action button vanished on every file. The header read "No role" for a user who has one. CAUSE: the JWT carries no roles claim — only user_id / name / email / sub / exp / iat. Roles live in the login RESPONSE BODY and at GET /usr/me, nowhere else. The provider restored a session by decoding the token, so any page refresh produced a user with an empty role list, and actionsFor() filtered every action away. Two fixes, and the second matters more than the first: * ZinoProvider now fetches /usr/me whenever it has a token but no roles, so a restored session recovers the real permission set. A fresh login already has them from the login response, so this costs no extra round trip there. * actionsFor() FAILS OPEN. With no roles known it returns every action the stage allows and lets the server refuse what it must. There is a real window where the console cannot know permissions — a restored session before /usr/me answers, or that call failing — and filtering on an empty list in that window hid every button and made a working app look broken. The server's refusal is safe and legible ("user 29533 does not have permission for activity ..."), and the form shows it verbatim. Hiding a button the user needs is the worse failure, because nothing on screen explains it. Co-Authored-By: Claude Opus 5 (1M context) --- src/api/client.ts | 21 ++++++++++++++++++++- src/api/config.ts | 17 +++++++++++++++++ src/api/provider.tsx | 29 +++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) 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,