From 755a615f61eb4cf7f9b4698dfdf27f966ecd4f24 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Mon, 25 May 2026 13:45:32 +0200 Subject: [PATCH] Self-registry toggle, invite links with attribution, first-user-admin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three pieces of a single registration story. 1. **Self-registry toggle.** New generic `settings` key/value table. Initial key: `self_registry_enabled` (default `1`). Admin-only PATCH /api/settings flips it. GET /api/settings is public so the login screen can hide the "Opprett konto" CTA when registration is closed. 2. **Invite links.** New `invites(token, inviter_user_id, created_at, claimed_at, claimed_by_user_id)` table; tokens are 22-char base64url (~128 bits of entropy). Endpoints: POST /api/invites — create (any logged-in user) GET /api/invites — list mine DELETE /api/invites/:token — cancel an unclaimed invite Claimed invites are kept in the DB (the audit trail of who-invited- whom survives) — only unclaimed ones can be cancelled. The signup endpoint accepts an optional `invite_token`. The signup handler does the claim + user-insert in a single SQLite transaction so we can't end up with a claimed invite pointing at a missing user. A concurrent claim race is closed by `UPDATE … WHERE claimed_at IS NULL` — only one transaction's UPDATE actually flips the column. New `users.invited_by` column records the inviter id so accounts have a traceable origin. Profile page shows the user's invites with "Kopier lenke" / "Avbryt" buttons; the SPA serves /invite/ into the Signup view with the token prefilled. 3. **First-user auto-admin.** The signup handler counts users *before* the insert; if it's the first one, `is_admin` is set on the row. This solves the bootstrap chicken-and-egg without an env var or sqlite3 step. Documented in README. When self-registry is **off**: - The login screen hides "Opprett konto" and shows a "stengt" notice - /api/auth/signup with no invite returns 403 signup_closed - /api/auth/signup with a valid invite still works (and attributes) - /api/auth/signup with an *invalid* invite returns 403 invalid_invite When self-registry is **on**: - Anyone can sign up (no invite required) - An invite that comes along is still consumed for attribution - An invalid invite is ignored — signup proceeds without attribution 26 tests still pass; typecheck clean; bundle 31.2 KB → 32.7 KB gzipped. --- README.md | 24 ++++- frontend/src/App.svelte | 27 ++++- frontend/src/components/Admin.svelte | 42 +++++++- frontend/src/components/Login.svelte | 16 ++- frontend/src/components/Profile.svelte | 101 ++++++++++++++++++ frontend/src/components/Signup.svelte | 29 +++++- frontend/src/lib/api.ts | 12 +++ frontend/src/lib/auth.ts | 7 +- server/auth.ts | 84 +++++++++++---- server/db.ts | 26 ++++- server/index.ts | 4 + server/invites.ts | 136 +++++++++++++++++++++++++ server/settings.ts | 69 +++++++++++++ shared/types.ts | 27 +++++ 14 files changed, 567 insertions(+), 37 deletions(-) create mode 100644 server/invites.ts create mode 100644 server/settings.ts diff --git a/README.md b/README.md index ee0950a..7fa82a7 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,22 @@ 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`. +## Registration: open, invite-only, or both + +Admins can toggle self-registration from the Admin UI. Two modes: + +- **Open** (default): anyone can hit `/` → "Logg inn" → "Opprett konto". +- **Closed**: the "Opprett konto" button is hidden; new accounts can only + be created through invite links. + +Any logged-in user can generate invite links from their Profile page. Each +link is single-use; the link URL is `//invite/` and the +recipient is dropped straight into the signup form with the token attached. + +Successful invite-signups record `users.invited_by = ` so the +account has a traceable origin. Invites that have been claimed are kept in +the DB (they can no longer be cancelled) so the audit trail survives. + ## Installable (PWA) + mobile The SPA ships with a web app manifest (`/manifest.webmanifest`), an SVG icon @@ -156,11 +172,13 @@ There are three privilege levels: Admin implies moderator — admins automatically pass any `is_moderator` check. -The **first admin** has to be promoted out of band (chicken-and-egg). After -that, admins can grant moderator/admin to others through the Admin UI. +The **first user** to sign up is auto-promoted to admin so a fresh +deployment is never stranded without one. After that, admins can grant +moderator/admin to others through the Admin UI. If for any reason that +didn't happen (e.g., you imported a DB), you can still bootstrap manually: ```bash -# Bootstrap the first admin: +# Manually promote (e.g. for imported DBs): sqlite3 data/vinterliste.db \ "UPDATE users SET is_admin = 1 WHERE email = 'you@example.org';" diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 2500b14..efb3a5a 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -38,9 +38,11 @@ let publicListUsername = $state(''); let activityId = $state(''); let defaultEmail = $state(''); + let inviteToken = $state(''); + let selfRegistryEnabled = $state(true); interface Route { - view: 'public-home' | 'home' | 'public-list' | 'permalink'; + view: 'public-home' | 'home' | 'public-list' | 'permalink' | 'invite'; payload?: string; } @@ -52,6 +54,8 @@ if (userList) return { view: 'public-list', payload: userList[1] }; const perma = path.match(/^\/a\/([A-Za-z0-9-]+)\/?$/); if (perma) return { view: 'permalink', payload: perma[1] }; + const invite = path.match(/^\/invite\/([A-Za-z0-9_-]+)\/?$/); + if (invite) return { view: 'invite', payload: invite[1] }; // Unknown path: treat as the public landing rather than 404. The server // returns index.html for anything non-API anyway, so this keeps deep // links from looking broken. @@ -79,6 +83,14 @@ if (route.view === 'permalink' && route.payload) { activityId = route.payload; } + if (route.view === 'invite' && route.payload) { + inviteToken = route.payload; + } + + // Pull public site settings so the UI knows whether to show "Opprett konto". + api.getSettings() + .then((s) => { selfRegistryEnabled = s.self_registry_enabled; }) + .catch(() => { /* default open if the call fails */ }); // Probe the server for an existing session, but it doesn't change which // URL we're rendering — only what content we'd show on /home. @@ -104,6 +116,12 @@ } else if (route.view === 'permalink') { activityId = route.payload ?? activityId; view = 'permalink'; + } else if (route.view === 'invite') { + inviteToken = route.payload ?? inviteToken; + // /invite/ drops the visitor straight into the signup form with + // the token prefilled. If they're already logged in we still show the + // landing — they can't claim an invite on top of an existing account. + view = session.user ? 'public-home' : 'signup'; } } @@ -176,9 +194,14 @@ onAuthed={onAuthed} onWantSignup={() => (view = 'signup')} onWantRecovery={() => (view = 'recovery')} + signupAvailable={selfRegistryEnabled} /> {:else if view === 'signup'} - (view = 'login')} /> + (view = 'login')} + inviteToken={inviteToken} + /> {:else if view === 'recovery'} (view = 'login')} /> {:else if view === 'profile'} diff --git a/frontend/src/components/Admin.svelte b/frontend/src/components/Admin.svelte index 32ade6e..aff7701 100644 --- a/frontend/src/components/Admin.svelte +++ b/frontend/src/components/Admin.svelte @@ -2,7 +2,7 @@ import { onMount } from 'svelte'; import { api, ApiError } from '../lib/api'; import { session } from '../lib/session.svelte'; - import type { AdminUser } from '../../../shared/types'; + import type { AdminUser, PublicSettings } from '../../../shared/types'; interface Props { onDone: () => void; @@ -13,7 +13,16 @@ let loading = $state(true); let error: string | null = $state(null); - onMount(load); + let settings: PublicSettings | null = $state(null); + + onMount(async () => { + await load(); + try { + settings = await api.getSettings(); + } catch { + // Settings load failure is non-fatal; the toggle just won't render. + } + }); async function load() { loading = true; @@ -29,6 +38,17 @@ } } + async function toggleSelfRegistry(next: boolean) { + const prev = settings; + settings = settings ? { ...settings, self_registry_enabled: next } : settings; + try { + settings = await api.updateSettings({ self_registry_enabled: next }); + } catch { + settings = prev; + error = 'Kunne ikke endre innstilling.'; + } + } + /** * Optimistic update: flip the role locally, then send the PATCH. If the * server rejects (e.g., last-admin guard fires), reload from the server so @@ -74,6 +94,24 @@ om du må.

+ {#if settings} +
+

Selvregistrering

+ +
+ {/if} + {#if error}{/if} {#if loading} diff --git a/frontend/src/components/Login.svelte b/frontend/src/components/Login.svelte index d6d8791..6849a54 100644 --- a/frontend/src/components/Login.svelte +++ b/frontend/src/components/Login.svelte @@ -7,8 +7,12 @@ onAuthed: () => void; onWantSignup: () => void; onWantRecovery: () => void; + /** When false, hide the "Opprett konto" CTA — self-registry is closed + * and the user needs an invite link instead. */ + signupAvailable?: boolean; } - let { defaultEmail = '', onAuthed, onWantSignup, onWantRecovery }: Props = $props(); + let { defaultEmail = '', onAuthed, onWantSignup, onWantRecovery, + signupAvailable = true }: Props = $props(); // `defaultEmail` is intentionally used only for the initial value. The warning // about referencing a non-state value in `$state(...)` is the right shape of @@ -65,7 +69,15 @@ - + {#if signupAvailable} + + {/if} + {#if !signupAvailable} +

+ Selvregistrering er stengt. Be om en invitasjonslenke fra noen som + allerede har konto. +

+ {/if} diff --git a/frontend/src/components/Profile.svelte b/frontend/src/components/Profile.svelte index 04218c9..73aca60 100644 --- a/frontend/src/components/Profile.svelte +++ b/frontend/src/components/Profile.svelte @@ -1,7 +1,9 @@