diff --git a/README.md b/README.md index 7a188e3..a5554ce 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,19 @@ podman run --replace --name vinterliste \ The container exposes `/api/health` for healthchecks and bakes the build date / git revision into both OCI labels and `/etc/build-info`. +## Promoting a moderator + +Moderators can delete any `semi` or `public` activity (not `private` — those +aren't visible to anyone else anyway). There's no admin UI; promotion is a +one-liner against the SQLite file: + +```bash +sqlite3 data/vinterliste.db \ + "UPDATE users SET is_moderator = 1 WHERE email = 'olemd@example.org';" +``` + +The user has to log out and back in for `MeResponse.is_moderator` to refresh. + ## Manual verification After signing up an account, the spec asks you to inspect a `private` row diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 7bfb03b..16f19d2 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -2,35 +2,64 @@ import { onMount } from 'svelte'; import { ready } from './lib/crypto'; import { api, ApiError } from './lib/api'; - import { session, setSession } from './lib/session.svelte'; + import { session } from './lib/session.svelte'; import { logout } from './lib/auth'; import Login from './components/Login.svelte'; import Signup from './components/Signup.svelte'; import Recovery from './components/Recovery.svelte'; import Home from './components/Home.svelte'; + import Profile from './components/Profile.svelte'; + import Feedback from './components/Feedback.svelte'; + import PublicList from './components/PublicList.svelte'; - type View = 'login' | 'signup' | 'recovery' | 'home' | 'loading'; + type View = 'login' | 'signup' | 'recovery' | 'home' | 'profile' | 'feedback' | 'public-list' | 'loading'; let view: View = $state('loading'); + let publicListUsername = $state(''); + let defaultEmail: string = $state(''); + + /** + * Hand-rolled path routing. The only path-based view is `//list`; + * everything else falls back to the in-app view state. This avoids pulling + * in a router for a single dynamic route. + * + * On signup/login we replaceState() back to "/" so the browser address + * doesn't keep showing the public-list URL while the user is in their own + * authenticated view. + */ + function parsePath(): { username: string } | null { + const path = window.location.pathname; + const m = path.match(/^\/([a-z0-9_-]{2,31})\/list\/?$/); + return m ? { username: m[1]! } : null; + } onMount(async () => { await ready(); + + const route = parsePath(); + if (route) { + publicListUsername = route.username; + view = 'public-list'; + return; + } + try { const me = await api.me(); // We have an active server session but no DEK — the user reloaded the // page. Force them through the login screen so we can re-unlock. - view = 'login'; - // Pre-fill the email field on the login form. defaultEmail = me.email; await api.logout(); // drop the stale server session + view = 'login'; } catch (err) { if (err instanceof ApiError && err.status === 401) view = 'login'; else view = 'login'; } }); - let defaultEmail: string = $state(''); - function onAuthed() { + // After authenticating from a deep link, return to "/". + if (window.location.pathname !== '/') { + window.history.replaceState({}, '', '/'); + } view = 'home'; } @@ -38,14 +67,26 @@ await logout(); view = 'login'; } + + function leavePublicList() { + window.history.replaceState({}, '', '/'); + view = session.user ? 'home' : 'login'; + }