Files
EventSnap/frontend/src/lib/auth.ts
fabi f8cba95e49 chore(frontend): add ESLint + Prettier; fix real findings; format the tree
The frontend had no JS/TS linter — only svelte-check. Add flat-config ESLint (typescript-eslint
+ eslint-plugin-svelte) and Prettier (tabs/single-quote, matching the existing style).

Rules encode "catch bugs, not enforce taste":
  - svelte/require-each-key KEPT — it is the exact bug class as the feed mis-tap fix. Fixed every
    flagged block: keyed activeFilters, filteredUsers, stagedFiles (by previewUrl), captionTags,
    admin tabs/jobs/users, the export-viewer suggestions/filters/comments, and the static skeleton
    loops.
  - svelte/prefer-svelte-reactivity KEPT — inline-disabled only the verified-safe sites (a local
    freq Map in a $derived.by, throwaway URLSearchParams query builders), with a reason each.
  - svelte/no-navigation-without-resolve OFF — wants resolve() around every goto()/href; taste, not
    a bug, and pure churn.
  - svelte/no-unused-svelte-ignore OFF — those comments are consumed by svelte-check, which ESLint
    can't see, so it wrongly calls them unused; removing them would reintroduce a11y warnings.
  - no-explicit-any OFF for *.test.ts only (partial fixtures legitimately use any).

Real code fixes beyond keys: removed a dead jobLabel(), an unused ViewerComment import and unused
catch binding, an unused scroll-lock arg, and replaced an empty interface with a type alias.

Then `prettier --write` (54 files). Formatting only. Verified: eslint clean, svelte-check 0 errors,
vitest 46 passed, vite build succeeds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 20:45:42 +02:00

162 lines
5.7 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);
}
/**
* 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);
}
// 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);
}
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());
}