fix: resolve roles from the server, and fail open when they are unknown

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) <noreply@anthropic.com>
This commit is contained in:
Yashas 2026-08-17 18:09:47 +05:30
parent 0b4c5576e4
commit 046c4951db
3 changed files with 66 additions and 1 deletions

View File

@ -96,8 +96,27 @@ export class ZinoClient {
this.setToken(null); 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<User> {
return this.request<User>('GET', '/usr/me');
}
/** Decode the persisted JWT into a User no network round trip, so a hard /** 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 { currentUser(): User | null {
if (!this.token) return null; if (!this.token) return null;
try { try {

View File

@ -148,9 +148,26 @@ export const STAGE_ACTIONS: Record<string, Action[]> = {
], ],
}; };
/**
* 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[] { export function actionsFor(stage: string | undefined, roles: string[] | undefined): Action[] {
const all = STAGE_ACTIONS[stage ?? ''] ?? []; const all = STAGE_ACTIONS[stage ?? ''] ?? [];
const mine = roles ?? []; const mine = roles ?? [];
if (mine.length === 0) return all;
if (mine.some((r) => SUPER_ROLES.includes(r))) return all; if (mine.some((r) => SUPER_ROLES.includes(r))) return all;
return all.filter((a) => a.roles.some((r) => mine.includes(r))); return all.filter((a) => a.roles.some((r) => mine.includes(r)));
} }

View File

@ -33,6 +33,35 @@ export function ZinoProvider({ baseUrl, children }: { baseUrl: string; children:
client.setAuthErrorHandler(() => setUser(null)); client.setAuthErrorHandler(() => setUser(null));
}, [client]); }, [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<ZinoContextValue>( const value = useMemo<ZinoContextValue>(
() => ({ () => ({
client, client,