Self-registry toggle, invite links with attribution, first-user-admin

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/<token>
   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.
This commit is contained in:
Ole-Morten Duesund 2026-05-25 13:45:32 +02:00
commit 755a615f61
14 changed files with 567 additions and 37 deletions

View file

@ -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/<token> 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'}
<Signup onAuthed={onAuthed} onWantLogin={() => (view = 'login')} />
<Signup
onAuthed={onAuthed}
onWantLogin={() => (view = 'login')}
inviteToken={inviteToken}
/>
{:else if view === 'recovery'}
<Recovery onAuthed={onAuthed} onWantLogin={() => (view = 'login')} />
{:else if view === 'profile'}

View file

@ -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å.
</p>
{#if settings}
<div class="card">
<h3 style="margin-top: 0;">Selvregistrering</h3>
<label class="row" style="gap: 0.5rem; align-items: center;">
<input
type="checkbox"
checked={settings.self_registry_enabled}
onchange={(e) => toggleSelfRegistry((e.currentTarget as HTMLInputElement).checked)}
/>
<span>
{settings.self_registry_enabled
? 'Åpen — hvem som helst kan opprette konto.'
: 'Lukket — bare invitasjonslenker fungerer.'}
</span>
</label>
</div>
{/if}
{#if error}<p class="error" role="alert">{error}</p>{/if}
{#if loading}

View file

@ -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 @@
<button class="primary" type="submit" disabled={busy}>
{busy ? 'Logger inn …' : 'Logg inn'}
</button>
<button type="button" onclick={onWantSignup}>Opprett konto</button>
{#if signupAvailable}
<button type="button" onclick={onWantSignup}>Opprett konto</button>
{/if}
<button type="button" onclick={onWantRecovery}>Bruk gjenopprettingskode</button>
</div>
{#if !signupAvailable}
<p class="muted" style="margin-top: 0.5rem;">
Selvregistrering er stengt. Be om en invitasjonslenke fra noen som
allerede har konto.
</p>
{/if}
</form>

View file

@ -1,7 +1,9 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api, ApiError } from '../lib/api';
import { changePassword } from '../lib/auth';
import { session } from '../lib/session.svelte';
import type { InviteEntry } from '../../../shared/types';
interface Props {
onDone: () => void;
@ -57,6 +59,62 @@
let pwSaving = $state(false);
let pwChanged = $state(false);
// --- Invites -------------------------------------------------------------
let invites: InviteEntry[] = $state([]);
let invitesError: string | null = $state(null);
let creating = $state(false);
let copiedToken: string | null = $state(null);
async function loadInvites() {
try {
invites = await api.listInvites();
} catch {
invitesError = 'Kunne ikke laste invitasjoner.';
}
}
onMount(loadInvites);
async function createInvite() {
creating = true;
invitesError = null;
try {
const inv = await api.createInvite();
invites = [inv, ...invites];
} catch {
invitesError = 'Kunne ikke opprette invitasjon.';
} finally {
creating = false;
}
}
async function cancelInvite(inv: InviteEntry) {
if (inv.claimed_at) return;
if (!confirm('Avbryt denne invitasjonen?')) return;
try {
await api.cancelInvite(inv.token);
invites = invites.filter((i) => i.token !== inv.token);
} catch {
invitesError = 'Kunne ikke avbryte invitasjonen.';
}
}
async function copyInviteUrl(inv: InviteEntry) {
try {
await navigator.clipboard.writeText(inv.url);
copiedToken = inv.token;
setTimeout(() => { copiedToken = null; }, 1500);
} catch {
window.prompt('Kopier denne lenken:', inv.url);
}
}
function formatDate(epochMs: number): string {
return new Date(epochMs).toLocaleDateString('nb-NO', {
year: 'numeric', month: '2-digit', day: '2-digit',
});
}
async function savePassword(e: SubmitEvent) {
e.preventDefault();
pwError = null;
@ -139,6 +197,49 @@
</div>
</form>
<section class="card" aria-labelledby="inv-h">
<h3 id="inv-h">Invitasjonslenker</h3>
<p class="muted">
Generer en lenke du kan dele. Den som registrerer seg via lenken blir
knyttet til deg som invitør. Hver lenke kan bare brukes én gang.
</p>
{#if invitesError}<p class="error" role="alert">{invitesError}</p>{/if}
<div class="row" style="margin-bottom: 0.5rem;">
<button class="primary" type="button" onclick={createInvite} disabled={creating}>
{creating ? 'Lager …' : 'Ny invitasjon'}
</button>
</div>
{#if invites.length === 0}
<p class="muted">Ingen invitasjoner ennå.</p>
{/if}
{#each invites as inv (inv.token)}
<article class="card" style="margin-top: 0.5rem; {inv.claimed_at ? 'opacity: 0.7;' : ''}">
<div class="row" style="justify-content: space-between;">
<code style="font-size: 0.8rem; word-break: break-all;">{inv.url}</code>
<span class="muted" style="white-space: nowrap;">{formatDate(inv.created_at)}</span>
</div>
<div class="row" style="margin-top: 0.5rem;">
{#if inv.claimed_at}
<span class="vis-badge semi">Brukt</span>
{#if inv.claimed_by_display}
<span class="muted">av {inv.claimed_by_display} · {formatDate(inv.claimed_at)}</span>
{/if}
{:else}
<button type="button" onclick={() => copyInviteUrl(inv)}>
{copiedToken === inv.token ? 'Kopiert!' : 'Kopier lenke'}
</button>
<button class="danger" type="button" onclick={() => cancelInvite(inv)}>
Avbryt
</button>
{/if}
</div>
</article>
{/each}
</section>
<form onsubmit={savePassword} class="card" aria-labelledby="pw-h">
<h3 id="pw-h">Bytt passord</h3>
<p class="muted">

View file

@ -5,8 +5,10 @@
interface Props {
onAuthed: () => void;
onWantLogin: () => void;
/** Pre-filled invite token (e.g. from /invite/<token>). Sent on signup. */
inviteToken?: string;
}
let { onAuthed, onWantLogin }: Props = $props();
let { onAuthed, onWantLogin, inviteToken }: Props = $props();
let email = $state('');
let password = $state('');
@ -28,13 +30,29 @@
}
busy = true;
try {
const { recoveryCode: code } = await signup(email.trim().toLowerCase(), password);
const { recoveryCode: code } = await signup(
email.trim().toLowerCase(),
password,
inviteToken,
);
recoveryCode = code;
password = '';
confirm = '';
} catch (err) {
if (err instanceof ApiError && err.status === 409) error = 'Den eposten er allerede i bruk.';
else error = 'Kontooppretting feilet.';
if (err instanceof ApiError && err.status === 409) {
error = 'Den eposten er allerede i bruk.';
} else if (err instanceof ApiError && err.status === 403) {
const detail = err.detail as { error?: string } | null;
if (detail?.error === 'signup_closed') {
error = 'Selvregistrering er ikke åpen. Du trenger en invitasjonslenke.';
} else if (detail?.error === 'invalid_invite') {
error = 'Invitasjonslenken er ugyldig eller allerede brukt.';
} else {
error = 'Kontooppretting feilet.';
}
} else {
error = 'Kontooppretting feilet.';
}
} finally {
busy = false;
}
@ -55,6 +73,9 @@
{:else}
<form onsubmit={submit} class="card" aria-labelledby="signup-h">
<h2 id="signup-h">Opprett konto</h2>
{#if inviteToken}
<div class="banner">Du registrerer deg med en invitasjonslenke.</div>
{/if}
<p class="muted">
Vi krypterer alt du markerer som <em>privat</em> i nettleseren din. Serveren
ser aldri passordet ditt eller innholdet i private oppføringer.

View file

@ -5,6 +5,7 @@ import type {
TagSuggestion, ProfileUpdateRequest,
PublicListResponse, FeedbackSubmitRequest, FeedbackEntry, FeedbackUpdateRequest,
AdminUser, AdminRoleUpdate,
PublicSettings, SettingsUpdateRequest, InviteEntry,
} from '../../../shared/types';
const BASE = '/api';
@ -103,4 +104,15 @@ export const api = {
http<AdminUser>(`/admin/users/${encodeURIComponent(id)}/role`, {
method: 'PATCH', body: JSON.stringify(body),
}),
// --- settings -------------------------------------------------------------
getSettings: () => http<PublicSettings>('/settings'),
updateSettings: (body: SettingsUpdateRequest) =>
http<PublicSettings>('/settings', { method: 'PATCH', body: JSON.stringify(body) }),
// --- invites --------------------------------------------------------------
listInvites: () => http<InviteEntry[]>('/invites'),
createInvite: () => http<InviteEntry>('/invites', { method: 'POST' }),
cancelInvite: (token: string) =>
http<{ ok: true }>(`/invites/${encodeURIComponent(token)}`, { method: 'DELETE' }),
};

View file

@ -32,7 +32,11 @@ export interface SignupResult {
* Signup: generate DEK and both wraps client-side, send wraps + auth verifier
* to the server.
*/
export async function signup(email: string, password: string): Promise<SignupResult> {
export async function signup(
email: string,
password: string,
inviteToken?: string,
): Promise<SignupResult> {
await ready();
const dek = generateDek();
@ -70,6 +74,7 @@ export async function signup(email: string, password: string): Promise<SignupRes
dek_rec_nonce: bytesToBase64(wrappedRec.nonce),
rec_auth_salt: bytesToBase64(recAuthSalt),
rec_auth_verifier: recAuthVerifier,
...(inviteToken ? { invite_token: inviteToken } : {}),
});
setSession(user, dek);