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}
{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.
+