New shared primitives: - Toaster + toast-store, ConfirmSheet, Modal, focusTrap action, pullToRefresh action, avatarPalette + initials helper, Skeleton, HeartBurst, haptics, export-status store with onClearAuth hook Critical UX/a11y: - Replaced window.confirm with branded ConfirmSheet - Focus management + Escape on every modal (PIN, Lightbox, Onboarding, ContextSheet, data-mode sheet, leave-confirm, HTML guide, host/admin ban + PIN-display modals) - Sheet backdrops are real buttons with aria-label - Silent ApiError catches now surface via global Toaster Major polish: - Dark-mode parity on HashtagChips + avatars (shared palette) - Conditional Export tab in BottomNav (badge dot when ZIP ready) - Back chevrons on /recover (history-aware) and /export - Upload composer discard confirmation when content is staged - Camera segmented Photo/Video shutter - PIN auto-submit on 4th digit, paste-flash-free (controlled input) - Welcome-back toast on /feed after PIN recovery Minor: - Skeleton states on feed; pull-to-refresh with live drag indicator - Haptics on like / capture / submit / PIN-copy / onboarding complete - Comment 500-char counter; quota "Fast voll" / "Limit erreicht" labels - Onboarding pip ≥24px tap targets; long-press hint step - overscroll-behavior lock on <html> while feed mounted - teardownExportStatus wired via onClearAuth (covers 401 + explicit logout) - ConfirmSheet per-instance titleId; Modal requires titleId or ariaLabel Tests (7 new Playwright specs): - 01-auth/pin-auto-submit, 01-auth/back-chevron - 03-feed/confirm-sheet-delete, 03-feed/toast-on-failure - 09-mobile/focus-trap, 09-mobile/sheet-escape, 09-mobile/upload-cancel-confirm FOLLOWUPS.md captures the deferred AT inert containment work with acceptance criteria + implementation sketches. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
112 lines
3.4 KiB
TypeScript
112 lines
3.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);
|
|
|
|
export function getToken(): string | null {
|
|
if (!browser) return null;
|
|
return localStorage.getItem(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 {
|
|
if (!browser) return null;
|
|
return localStorage.getItem(USER_ID_KEY);
|
|
}
|
|
|
|
export function getDisplayName(): string | null {
|
|
if (!browser) return null;
|
|
return localStorage.getItem(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;
|
|
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);
|
|
}
|
|
|
|
// 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;
|
|
localStorage.removeItem(TOKEN_KEY);
|
|
localStorage.removeItem(USER_ID_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());
|
|
}
|