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>
184 lines
6.4 KiB
TypeScript
184 lines
6.4 KiB
TypeScript
import { writable } from 'svelte/store';
|
|
import { browser } from '$app/environment';
|
|
|
|
const TOKEN_KEY = 'eventsnap_jwt';
|
|
const PIN_KEY = 'eventsnap_pin';
|
|
const USER_ID_KEY = 'eventsnap_user_id';
|
|
const DISPLAY_NAME_KEY = 'eventsnap_display_name';
|
|
|
|
export const isAuthenticated = writable(false);
|
|
|
|
/**
|
|
* Reactive mirror of `localStorage[PIN_KEY]`. Subscribers (the My Account page) see
|
|
* the PIN change immediately when an SSE `pin-reset` event invalidates it from any
|
|
* route — keeps the displayed PIN consistent with the server hash.
|
|
*/
|
|
export const currentPin = writable<string | null>(null);
|
|
|
|
// Guest auth lives in localStorage (persists across sessions — a guest returning to the
|
|
// event days later stays signed in). The ADMIN token lives in sessionStorage instead
|
|
// (USER_JOURNEYS §11.1): its tighter 1-day lifetime is meant to bound exposure on a shared
|
|
// "event laptop", and sessionStorage clears on tab/browser close, which localStorage defeats.
|
|
// Reads check sessionStorage FIRST so an active admin token wins over any leftover guest
|
|
// token on the same device.
|
|
function readAuth(key: string): string | null {
|
|
if (!browser) return null;
|
|
return sessionStorage.getItem(key) ?? localStorage.getItem(key);
|
|
}
|
|
|
|
export function getToken(): string | null {
|
|
return readAuth(TOKEN_KEY);
|
|
}
|
|
|
|
export function getPin(): string | null {
|
|
if (!browser) return null;
|
|
return localStorage.getItem(PIN_KEY);
|
|
}
|
|
|
|
/**
|
|
* Clear the locally-cached recovery PIN. Called when the server resets it (host
|
|
* action) — the cached plaintext no longer matches the bcrypt hash, so showing it
|
|
* would mislead the user.
|
|
*/
|
|
export function clearPin(): void {
|
|
if (!browser) return;
|
|
localStorage.removeItem(PIN_KEY);
|
|
currentPin.set(null);
|
|
}
|
|
|
|
export function getUserId(): string | null {
|
|
return readAuth(USER_ID_KEY);
|
|
}
|
|
|
|
export function getDisplayName(): string | null {
|
|
return readAuth(DISPLAY_NAME_KEY);
|
|
}
|
|
|
|
export function getExpiry(): Date | null {
|
|
const token = getToken();
|
|
if (!token) return null;
|
|
try {
|
|
const payload = JSON.parse(atob(token.split('.')[1]));
|
|
return payload.exp ? new Date(payload.exp * 1000) : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function setAuth(
|
|
jwt: string,
|
|
pin: string | null,
|
|
userId: string,
|
|
displayName?: string
|
|
): void {
|
|
if (!browser) return;
|
|
// DISPLACE any resident admin session: reads are sessionStorage-first, so a leftover
|
|
// admin token there would otherwise shadow this guest login and hand the guest admin
|
|
// rights on a shared device. Clear the sessionStorage identity so exactly one is resident.
|
|
sessionStorage.removeItem(TOKEN_KEY);
|
|
sessionStorage.removeItem(USER_ID_KEY);
|
|
sessionStorage.removeItem(DISPLAY_NAME_KEY);
|
|
localStorage.setItem(TOKEN_KEY, jwt);
|
|
if (pin) {
|
|
localStorage.setItem(PIN_KEY, pin);
|
|
currentPin.set(pin);
|
|
}
|
|
localStorage.setItem(USER_ID_KEY, userId);
|
|
if (displayName) localStorage.setItem(DISPLAY_NAME_KEY, displayName);
|
|
isAuthenticated.set(true);
|
|
fireSetAuthHooks();
|
|
}
|
|
|
|
/**
|
|
* Persist an ADMIN session in sessionStorage (USER_JOURNEYS §11.1) rather than localStorage,
|
|
* so the elevated credential does not survive a tab/browser close on a shared device — the
|
|
* point of the tighter 1-day admin token. Admins have no recovery PIN. `getToken` reads
|
|
* sessionStorage first, so this wins over any leftover guest token on the same device.
|
|
*/
|
|
export function setAdminAuth(jwt: string, userId: string, displayName?: string): void {
|
|
if (!browser) return;
|
|
// DISPLACE any resident guest session so exactly one identity is resident (symmetric with
|
|
// setAuth). Keep the guest PIN — it's deliberately preserved for later recovery, and the
|
|
// admin has none. Reads are sessionStorage-first, so the admin token now wins cleanly.
|
|
localStorage.removeItem(TOKEN_KEY);
|
|
localStorage.removeItem(USER_ID_KEY);
|
|
localStorage.removeItem(DISPLAY_NAME_KEY);
|
|
sessionStorage.setItem(TOKEN_KEY, jwt);
|
|
sessionStorage.setItem(USER_ID_KEY, userId);
|
|
if (displayName) sessionStorage.setItem(DISPLAY_NAME_KEY, displayName);
|
|
isAuthenticated.set(true);
|
|
fireSetAuthHooks();
|
|
}
|
|
|
|
// Hook registry: cross-cutting stores (export-status, etc.) register a callback
|
|
// here at import-time so they get reset on every clearAuth path — both the
|
|
// explicit "Event verlassen" button and the api.ts 401 auto-clear. Keeps
|
|
// clearAuth the single source of truth without baking dependencies on every
|
|
// downstream store into this module (which would create circular imports).
|
|
const clearAuthHooks: Array<() => void> = [];
|
|
export function onClearAuth(fn: () => void): void {
|
|
clearAuthHooks.push(fn);
|
|
}
|
|
|
|
// The mirror of `onClearAuth`, for stores that must be RE-SEEDED when a new identity
|
|
// arrives rather than merely cleared. Without it, anything derived from the token
|
|
// survives a logout→login in the same tab: `goto()` is a client-side navigation, so no
|
|
// module is re-imported and no `onMount` re-runs, and the previous user's value simply
|
|
// stays. Fires after the new token is resident, so hooks can read it.
|
|
const setAuthHooks: Array<() => void> = [];
|
|
export function onSetAuth(fn: () => void): void {
|
|
setAuthHooks.push(fn);
|
|
}
|
|
|
|
function fireSetAuthHooks(): void {
|
|
for (const fn of setAuthHooks) {
|
|
try {
|
|
fn();
|
|
} catch {
|
|
/* hook failure is non-fatal */
|
|
}
|
|
}
|
|
}
|
|
|
|
export function clearAuth(): void {
|
|
if (!browser) return;
|
|
// Clear from BOTH stores — a guest token lives in localStorage, an admin token in
|
|
// sessionStorage; either could be present, and a stale one must not linger.
|
|
for (const store of [localStorage, sessionStorage]) {
|
|
store.removeItem(TOKEN_KEY);
|
|
store.removeItem(USER_ID_KEY);
|
|
// Clear the display name too — on a shared device the next user shouldn't see
|
|
// the previous guest's name pre-filled on /join.
|
|
store.removeItem(DISPLAY_NAME_KEY);
|
|
}
|
|
// PIN is intentionally kept so the user can recover
|
|
isAuthenticated.set(false);
|
|
// Hooks fire in registration order. Keep them dependency-free of each other —
|
|
// if you ever need ordering, introduce a priority field rather than relying
|
|
// on import-load timing, which is fragile across refactors.
|
|
for (const fn of clearAuthHooks) {
|
|
try {
|
|
fn();
|
|
} catch {
|
|
/* hook failure is non-fatal */
|
|
}
|
|
}
|
|
}
|
|
|
|
export function getRole(): 'guest' | 'host' | 'admin' | null {
|
|
const token = getToken();
|
|
if (!token) return null;
|
|
try {
|
|
const payload = JSON.parse(atob(token.split('.')[1]));
|
|
return payload.role ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function initAuth(): void {
|
|
if (!browser) return;
|
|
isAuthenticated.set(!!getToken());
|
|
currentPin.set(getPin());
|
|
}
|