The role store I added in the moderation work is a module-level singleton seeded ONCE at import. `goto()` is a client-side navigation, so leaving and re-joining in the same tab re-imports no module and re-runs no onMount — the previous user's role simply stayed resident. Nothing reset it: not join, recover, admin login, "Event verlassen", `clearAuth`, nor the api.ts 401 auto-clear. So a host who left, followed by a guest joining on the same phone, left that guest with `isStaff === true` and a "🚫 Beitrag entfernen" action on other people's photos. The backend 403s the delete, so this was a false affordance rather than a privilege escalation — but `/feed` never fetched `/me/context`, so unlike every other route it never self-corrected either. It survived until a hard reload. The mirror case was equally broken and easier to overlook: a guest who recovered into a host account got NO host affordances. `clearAuth` already had a hook registry for exactly this shape of problem, with a comment explaining it exists to avoid circular imports. Add the missing mirror, `onSetAuth`, fired by both `setAuth` and `setAdminAuth` after the new token is resident, and have the role store register on both sides: clear to null on logout, re-seed from the new token on login. That also gives `syncRoleFromToken` — dead code with zero callers since I introduced it — its intended purpose. Seeding from the claim fixes the reported bug, but the claim is frozen for the token's 30-day life, so a promotion or demotion still wouldn't reach the feed. `/feed` now calls the existing `refreshEventState()` on mount, which fetches `/me/context` and applies both the authoritative role and the lock/release state in one request. The feed is the one route gating a destructive action on the role, so it should not be the only route running on a stale claim. Tests: 04-host/role-identity-reset drives the real flows. The first asserts the host DOES see the action before asserting the newcomer does not — a negative assertion alone would pass against a build that shipped no moderation at all. The second covers the mirror, promoting a guest server-side while their resident token still claims `role: guest`, so a fix that only cleared the role would fail it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
46 lines
2.2 KiB
TypeScript
46 lines
2.2 KiB
TypeScript
import { derived, writable } from 'svelte/store';
|
|
import { getRole, onClearAuth, onSetAuth } from './auth';
|
|
|
|
export type Role = 'guest' | 'host' | 'admin';
|
|
|
|
/**
|
|
* The viewer's LIVE role.
|
|
*
|
|
* The JWT is never reissued — the backend slides the session row forward instead and
|
|
* deliberately ignores the token's own role claim (`auth/middleware.rs`: "the live user row
|
|
* is authoritative"). So `getRole()`, which decodes the claim, is frozen for the lifetime of
|
|
* the token: up to 30 days for a guest. A guest promoted to host saw no Host-Dashboard until
|
|
* they signed out and back in, even though `/me/context` had already told the client their
|
|
* real role on the very next page load — it was fetched and the `role` field dropped on the
|
|
* floor in 4 of its 6 call sites.
|
|
*
|
|
* This store is seeded from the claim (so there is no flash of the wrong nav on boot) and
|
|
* corrected by every `/me/context` response via `setRole`. Read this instead of calling
|
|
* `getRole()` ad hoc.
|
|
*/
|
|
export const role = writable<Role | null>(getRole());
|
|
|
|
/** True for host and admin — the "can moderate" predicate used across the UI. */
|
|
export const isStaff = derived(role, ($role) => $role === 'host' || $role === 'admin');
|
|
|
|
/** Apply the authoritative role from a `/me/context` response. */
|
|
export function setRole(next: Role | null): void {
|
|
role.set(next);
|
|
}
|
|
|
|
/** Re-seed from the token, e.g. straight after a login/join that minted a new one. */
|
|
export function syncRoleFromToken(): void {
|
|
role.set(getRole());
|
|
}
|
|
|
|
// Bind the store to the identity lifecycle. The store is a module-level singleton seeded
|
|
// ONCE at import, and `goto()` navigations re-import nothing — so without these hooks the
|
|
// previous user's role survives a logout→login in the same tab. A host who left and a guest
|
|
// who then joined kept `isStaff === true` and were offered "Beitrag entfernen" on other
|
|
// people's photos (the backend 403s it, so it was a false affordance rather than an
|
|
// escalation — but the feed never fetches /me/context, so it never self-corrected either).
|
|
// The mirror case is just as wrong: a guest recovering into a host account got no host
|
|
// affordances at all.
|
|
onClearAuth(() => role.set(null));
|
|
onSetAuth(syncRoleFromToken);
|