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:
parent
0b4c5576e4
commit
046c4951db
@ -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<User> {
|
||||
return this.request<User>('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 {
|
||||
|
||||
@ -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[] {
|
||||
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)));
|
||||
}
|
||||
|
||||
@ -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<ZinoContextValue>(
|
||||
() => ({
|
||||
client,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user