From 6f4c11c7a6ab3c1aac47d19f7ace51f6cd67dbbf Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Mon, 25 May 2026 12:44:33 +0200 Subject: [PATCH] User profile, activity editing, search, OSM links, moderator role, opt-in //list, and a feedback channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six related features that touch the user model and activity UX: 1. **User profile** (display_name, username, public_list_enabled). New `display_name`, `username` (UNIQUE, slug-shaped), and `public_list_enabled` columns. PATCH /api/auth/profile is a partial update — pass only the fields you want to change, null to clear. MeResponse exposes all three. Display name is shown on public activities and in the nav; falls back to the email prefix when unset. 2. **Change password from the profile editor.** Existing /api/auth/password endpoint surfaced in the new Profile.svelte; the local-decrypt failure path on a wrong current password is mapped to a clean error. 3. **Edit existing activities.** ActivityForm becomes dual-purpose (create or edit). Title, tags, date/time, location, and visibility are all editable. Visibility transitions decrypt or re-encrypt client-side as needed before PATCH, and the IndexedDB private-tag index is kept in sync diff-style. 4. **Search.** A search input on Home filters across visible activities. Private rows are searched against their decrypted cleartext (decrypted once and memoised via $derived, so the work is amortised across keystrokes). Matches across title, tags, location label, and (for public) author display name. 5. **OpenStreetMap links.** Each row with a location renders the label as an OSM link. Smart: coords if present (?mlat=&mlon=&map=15/lat/lng → pinned view), else /search?query=. Built with the WHATWG URL constructor so Norwegian characters and commas survive. 6. **Moderator role + semi-delete by owner.** New is_moderator column on users. Owners always delete their own rows; moderators can additionally delete any semi or public activity (private is excluded — it's invisible to others, so there's no moderation case). README documents the manual promotion via sqlite3. 7. **Opt-in //list.** New server route /api/users/:username/list returns the user's public activities when both `username` is set AND `public_list_enabled = 1`. 404 when either condition fails — same response in both cases so the route doesn't leak username existence for users who haven't opted in. SPA-side, App.svelte parses window.location.pathname on mount; falls back to "/" via history.replaceState after authenticating from a deep link. 8. **Feedback channel.** New `feedback` table. POST /api/feedback for any authenticated user; GET /api/feedback gated to moderators. The Feedback.svelte component is dual-mode — the form is universal; the list view auto-loads only for moderators. Submitter identity (email + display name) is shown to moderators so they can follow up; not exposed to the submitter themselves. Schema migrations land via the existing ensureColumn() helper so scaffold DBs upgrade cleanly. The username UNIQUE constraint is applied as a partial unique index (WHERE username IS NOT NULL) so multiple users with NULL usernames don't collide. All 26 existing tests still pass; typecheck clean for both tsconfigs; Vite build succeeds. --- README.md | 13 ++ frontend/src/App.svelte | 63 ++++++- frontend/src/components/ActivityForm.svelte | 191 +++++++++++++++----- frontend/src/components/ActivityRow.svelte | 124 +++++++++---- frontend/src/components/Feedback.svelte | 130 +++++++++++++ frontend/src/components/Home.svelte | 112 +++++++++++- frontend/src/components/Profile.svelte | 170 +++++++++++++++++ frontend/src/components/PublicList.svelte | 61 +++++++ frontend/src/lib/api.ts | 16 +- server/activities.ts | 39 +++- server/auth.ts | 123 ++++++++++++- server/db.ts | 33 ++++ server/feedback.ts | 65 +++++++ server/index.ts | 4 + server/users.ts | 73 ++++++++ shared/types.ts | 42 ++++- 16 files changed, 1152 insertions(+), 107 deletions(-) create mode 100644 frontend/src/components/Feedback.svelte create mode 100644 frontend/src/components/Profile.svelte create mode 100644 frontend/src/components/PublicList.svelte create mode 100644 server/feedback.ts create mode 100644 server/users.ts 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'; + }