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:
parent
5c9455c3f3
commit
755a615f61
14 changed files with 567 additions and 37 deletions
24
README.md
24
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 `/<origin>/invite/<token>` and the
|
||||
recipient is dropped straight into the signup form with the token attached.
|
||||
|
||||
Successful invite-signups record `users.invited_by = <inviter_id>` 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';"
|
||||
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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' }),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import {
|
|||
currentUserId, issueSession, clearSession, requireAuth, gcSessions,
|
||||
type AppVariables,
|
||||
} from './session';
|
||||
import { isSelfRegistryEnabled } from './settings';
|
||||
import { claimInvite } from './invites';
|
||||
import type {
|
||||
SignupRequest,
|
||||
ChallengeResponse,
|
||||
|
|
@ -124,6 +126,21 @@ authRoutes.post('/signup', async (c) => {
|
|||
const existing = db.prepare('SELECT 1 FROM users WHERE email = ?').get(email);
|
||||
if (existing) return c.json({ error: 'email_taken' }, 409);
|
||||
|
||||
// Gate: signup requires either (a) self-registry enabled, or (b) a valid
|
||||
// unclaimed invite token. We resolve the invite path even when self-registry
|
||||
// is on so the `invited_by` attribution still records who shared the link.
|
||||
const inviteToken = typeof body.invite_token === 'string' ? body.invite_token.trim() : '';
|
||||
const selfReg = isSelfRegistryEnabled();
|
||||
if (!selfReg && !inviteToken) {
|
||||
return c.json({ error: 'signup_closed' }, 403);
|
||||
}
|
||||
|
||||
// First-user-auto-admin: count rows *before* the insert. If this is the
|
||||
// first user, they become admin so the deployment is never stranded
|
||||
// without one. (Documented in README.)
|
||||
const userCount = (db.prepare('SELECT COUNT(*) AS n FROM users').get() as { n: number }).n;
|
||||
const isFirstUser = userCount === 0;
|
||||
|
||||
// Server-side hash of the client-derived verifier. The verifier is already
|
||||
// expensive to brute-force (Argon2id-MODERATE), so Bun.password adds a second
|
||||
// hardening layer in case the DB leaks but the verifier salt is still public.
|
||||
|
|
@ -136,27 +153,52 @@ authRoutes.post('/signup', async (c) => {
|
|||
|
||||
const id = newId();
|
||||
const now = Date.now();
|
||||
db.prepare(`
|
||||
INSERT INTO users
|
||||
(id, email, auth_salt, auth_verifier_hash, kek_salt,
|
||||
wrapped_dek_pw, dek_pw_nonce, wrapped_dek_rec, rec_salt, dek_rec_nonce,
|
||||
rec_auth_salt, rec_auth_verifier_hash, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
email,
|
||||
b64ToBuffer(body.auth_salt),
|
||||
verifierHash,
|
||||
b64ToBuffer(body.kek_salt),
|
||||
b64ToBuffer(body.wrapped_dek_pw),
|
||||
b64ToBuffer(body.dek_pw_nonce),
|
||||
b64ToBuffer(body.wrapped_dek_rec),
|
||||
b64ToBuffer(body.rec_salt),
|
||||
b64ToBuffer(body.dek_rec_nonce),
|
||||
b64ToBuffer(body.rec_auth_salt),
|
||||
recVerifierHash,
|
||||
now,
|
||||
);
|
||||
|
||||
// The user-insert and the invite-claim must commit or fail together: a
|
||||
// claimed invite pointing at a nonexistent user (or a user without their
|
||||
// claimed invite recorded) would be an audit-trail inconsistency.
|
||||
let invitedBy: string | null = null;
|
||||
try {
|
||||
db.transaction(() => {
|
||||
if (inviteToken) {
|
||||
const inviter = claimInvite(inviteToken, id);
|
||||
if (!inviter) {
|
||||
if (!selfReg) throw new Error('invalid_invite');
|
||||
// self-registry on + bad token = proceed without attribution
|
||||
} else {
|
||||
invitedBy = inviter;
|
||||
}
|
||||
}
|
||||
db.prepare(`
|
||||
INSERT INTO users
|
||||
(id, email, auth_salt, auth_verifier_hash, kek_salt,
|
||||
wrapped_dek_pw, dek_pw_nonce, wrapped_dek_rec, rec_salt, dek_rec_nonce,
|
||||
rec_auth_salt, rec_auth_verifier_hash, is_admin, invited_by, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
email,
|
||||
b64ToBuffer(body.auth_salt),
|
||||
verifierHash,
|
||||
b64ToBuffer(body.kek_salt),
|
||||
b64ToBuffer(body.wrapped_dek_pw),
|
||||
b64ToBuffer(body.dek_pw_nonce),
|
||||
b64ToBuffer(body.wrapped_dek_rec),
|
||||
b64ToBuffer(body.rec_salt),
|
||||
b64ToBuffer(body.dek_rec_nonce),
|
||||
b64ToBuffer(body.rec_auth_salt),
|
||||
recVerifierHash,
|
||||
isFirstUser ? 1 : 0,
|
||||
invitedBy,
|
||||
now,
|
||||
);
|
||||
})();
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message === 'invalid_invite') {
|
||||
return c.json({ error: 'invalid_invite' }, 403);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
issueSession(c, id);
|
||||
gcSessions();
|
||||
|
|
|
|||
26
server/db.ts
26
server/db.ts
|
|
@ -35,9 +35,12 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
|||
is_moderator INTEGER NOT NULL DEFAULT 0,
|
||||
-- Admin flag. Strictly stronger than moderator: admins have everything
|
||||
-- moderators have, plus the /api/admin/* endpoints (promote/demote other
|
||||
-- users). Bootstrapped via "sqlite3 ... UPDATE" (see README); after that,
|
||||
-- admins can grant moderator/admin to others through the UI.
|
||||
-- users). The first user to sign up is auto-promoted to admin so the
|
||||
-- deployment is never stranded without one; after that, admins can
|
||||
-- grant moderator/admin to others through the UI.
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
-- Inviter, if this account was created via an invite token. Nullable.
|
||||
invited_by TEXT REFERENCES users(id),
|
||||
-- Optional public URL slug. When set + opt-in, the user's public
|
||||
-- activities are reachable at "/<username>/list". Distinct from
|
||||
-- display_name because URL slugs need uniqueness and shape constraints
|
||||
|
|
@ -105,6 +108,24 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
|||
PRIMARY KEY (activity_id, user_id)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS activity_hearts_user_idx ON activity_hearts(user_id)`,
|
||||
// Global settings (key/value). Currently used for self_registry_enabled
|
||||
// but kept generic so future toggles don't need their own table.
|
||||
`CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)`,
|
||||
// Invites: any logged-in user generates a token; signup-via-token records
|
||||
// the inviter so accounts have a traceable origin. Tokens are
|
||||
// single-use; claimed_* are set when a signup consumes one.
|
||||
`CREATE TABLE IF NOT EXISTS invites (
|
||||
token TEXT PRIMARY KEY,
|
||||
inviter_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at INTEGER NOT NULL,
|
||||
claimed_at INTEGER,
|
||||
claimed_by_user_id TEXT REFERENCES users(id)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS invites_inviter_idx ON invites(inviter_user_id)`,
|
||||
];
|
||||
|
||||
const PRAGMAS: readonly string[] = [
|
||||
|
|
@ -159,6 +180,7 @@ export function getDb(): Database {
|
|||
ensureColumn(db, 'users', 'display_name', 'TEXT');
|
||||
ensureColumn(db, 'users', 'is_moderator', 'INTEGER NOT NULL DEFAULT 0');
|
||||
ensureColumn(db, 'users', 'is_admin', 'INTEGER NOT NULL DEFAULT 0');
|
||||
ensureColumn(db, 'users', 'invited_by', 'TEXT');
|
||||
// Feedback triage columns (added after the feedback feature shipped).
|
||||
ensureColumn(db, 'feedback', 'done_at', 'INTEGER');
|
||||
ensureColumn(db, 'feedback', 'done_by', 'TEXT');
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import { tagsRoutes } from './tags';
|
|||
import { usersRoutes } from './users';
|
||||
import { feedbackRoutes } from './feedback';
|
||||
import { adminRoutes } from './admin';
|
||||
import { settingsRoutes } from './settings';
|
||||
import { invitesRoutes } from './invites';
|
||||
|
||||
// Initialise DB up front so the server fails fast on schema problems.
|
||||
getDb();
|
||||
|
|
@ -29,6 +31,8 @@ app.route('/api/tags', tagsRoutes);
|
|||
app.route('/api/users', usersRoutes);
|
||||
app.route('/api/feedback', feedbackRoutes);
|
||||
app.route('/api/admin', adminRoutes);
|
||||
app.route('/api/settings', settingsRoutes);
|
||||
app.route('/api/invites', invitesRoutes);
|
||||
|
||||
// In production, serve the built Svelte SPA. The static helper is registered
|
||||
// for the asset directory and for the top-level files that Vite copies from
|
||||
|
|
|
|||
136
server/invites.ts
Normal file
136
server/invites.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import { Hono } from 'hono';
|
||||
import { getDb } from './db';
|
||||
import { requireAuth, type AppVariables } from './session';
|
||||
import type { InviteEntry } from '../shared/types';
|
||||
|
||||
/**
|
||||
* Invite tokens. Any logged-in user can create one and share it; signup-via-
|
||||
* token records the inviter on the new user and marks the invite claimed.
|
||||
*
|
||||
* Tokens are single-use: each invite either has no `claimed_at` (still usable)
|
||||
* or one (consumed, forever). To "regenerate" you delete-and-create.
|
||||
*
|
||||
* Token format: 22-char base64url of 16 random bytes (~128 bits of entropy).
|
||||
* Long enough to be unguessable, short enough to share verbatim in chat.
|
||||
*/
|
||||
export const invitesRoutes = new Hono<{ Variables: AppVariables }>();
|
||||
|
||||
function newToken(): string {
|
||||
const buf = new Uint8Array(16);
|
||||
crypto.getRandomValues(buf);
|
||||
return Buffer.from(buf)
|
||||
.toString('base64')
|
||||
.replaceAll('+', '-')
|
||||
.replaceAll('/', '_')
|
||||
.replace(/=+$/, '');
|
||||
}
|
||||
|
||||
interface InviteRow {
|
||||
token: string;
|
||||
inviter_user_id: string;
|
||||
created_at: number;
|
||||
claimed_at: number | null;
|
||||
claimed_by_user_id: string | null;
|
||||
claimed_display_name: string | null;
|
||||
claimed_email: string | null;
|
||||
}
|
||||
|
||||
function toEntry(row: InviteRow, origin: string): InviteEntry {
|
||||
let claimedByDisplay: string | null = null;
|
||||
if (row.claimed_at && row.claimed_email) {
|
||||
if (row.claimed_display_name && row.claimed_display_name.trim()) {
|
||||
claimedByDisplay = row.claimed_display_name;
|
||||
} else {
|
||||
const at = row.claimed_email.indexOf('@');
|
||||
claimedByDisplay = at > 0 ? row.claimed_email.slice(0, at) : row.claimed_email;
|
||||
}
|
||||
}
|
||||
return {
|
||||
token: row.token,
|
||||
url: `${origin}/invite/${row.token}`,
|
||||
created_at: row.created_at,
|
||||
claimed_at: row.claimed_at,
|
||||
claimed_by_display: claimedByDisplay,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a token and atomically mark it claimed. Returns the inviter id
|
||||
* if the token was valid + unclaimed; null otherwise. Called from signup.
|
||||
*/
|
||||
export function claimInvite(token: string, claimerUserId: string): string | null {
|
||||
const db = getDb();
|
||||
const row = db
|
||||
.prepare('SELECT inviter_user_id, claimed_at FROM invites WHERE token = ?')
|
||||
.get(token) as { inviter_user_id: string; claimed_at: number | null } | null;
|
||||
if (!row) return null;
|
||||
if (row.claimed_at !== null) return null;
|
||||
|
||||
// Two near-simultaneous claims could both pass the check above. Make the
|
||||
// claim conditional on claimed_at still being NULL so only one wins.
|
||||
const res = db
|
||||
.prepare('UPDATE invites SET claimed_at = ?, claimed_by_user_id = ? WHERE token = ? AND claimed_at IS NULL')
|
||||
.run(Date.now(), claimerUserId, token);
|
||||
if (res.changes === 0) return null;
|
||||
return row.inviter_user_id;
|
||||
}
|
||||
|
||||
function originOf(c: { req: { url: string } }): string {
|
||||
const u = new URL(c.req.url);
|
||||
return `${u.protocol}//${u.host}`;
|
||||
}
|
||||
|
||||
// --- GET /api/invites -------------------------------------------------------
|
||||
// List the caller's invites (active first, then claimed).
|
||||
invitesRoutes.get('/', requireAuth, (c) => {
|
||||
const userId = c.get('userId');
|
||||
const rows = getDb()
|
||||
.prepare(`
|
||||
SELECT i.token, i.inviter_user_id, i.created_at, i.claimed_at,
|
||||
i.claimed_by_user_id,
|
||||
u.display_name AS claimed_display_name,
|
||||
u.email AS claimed_email
|
||||
FROM invites i
|
||||
LEFT JOIN users u ON u.id = i.claimed_by_user_id
|
||||
WHERE i.inviter_user_id = ?
|
||||
ORDER BY (i.claimed_at IS NOT NULL) ASC, i.created_at DESC
|
||||
`)
|
||||
.all(userId) as InviteRow[];
|
||||
return c.json(rows.map((r) => toEntry(r, originOf(c))));
|
||||
});
|
||||
|
||||
// --- POST /api/invites ------------------------------------------------------
|
||||
invitesRoutes.post('/', requireAuth, (c) => {
|
||||
const userId = c.get('userId');
|
||||
const token = newToken();
|
||||
getDb()
|
||||
.prepare('INSERT INTO invites (token, inviter_user_id, created_at) VALUES (?, ?, ?)')
|
||||
.run(token, userId, Date.now());
|
||||
|
||||
const row = getDb()
|
||||
.prepare(`
|
||||
SELECT i.token, i.inviter_user_id, i.created_at, i.claimed_at,
|
||||
i.claimed_by_user_id,
|
||||
NULL AS claimed_display_name, NULL AS claimed_email
|
||||
FROM invites i WHERE i.token = ?
|
||||
`)
|
||||
.get(token) as InviteRow;
|
||||
return c.json(toEntry(row, originOf(c)), 201);
|
||||
});
|
||||
|
||||
// --- DELETE /api/invites/:token ---------------------------------------------
|
||||
// Cancel an unused invite. Refuses to delete already-claimed invites so the
|
||||
// audit trail (who-invited-whom) survives.
|
||||
invitesRoutes.delete('/:token', requireAuth, (c) => {
|
||||
const userId = c.get('userId');
|
||||
const token = c.req.param('token');
|
||||
const db = getDb();
|
||||
const row = db
|
||||
.prepare('SELECT inviter_user_id, claimed_at FROM invites WHERE token = ?')
|
||||
.get(token) as { inviter_user_id: string; claimed_at: number | null } | null;
|
||||
if (!row) return c.json({ error: 'not_found' }, 404);
|
||||
if (row.inviter_user_id !== userId) return c.json({ error: 'forbidden' }, 403);
|
||||
if (row.claimed_at !== null) return c.json({ error: 'already_claimed' }, 409);
|
||||
db.prepare('DELETE FROM invites WHERE token = ?').run(token);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
69
server/settings.ts
Normal file
69
server/settings.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { Hono } from 'hono';
|
||||
import { getDb } from './db';
|
||||
import { requireAuth, type AppVariables } from './session';
|
||||
import { isAdmin } from './roles';
|
||||
import type { PublicSettings, SettingsUpdateRequest } from '../shared/types';
|
||||
|
||||
/**
|
||||
* Global site settings. Generic key/value table so future toggles don't each
|
||||
* need their own column. Keys are validated against a whitelist (`KNOWN`)
|
||||
* before write so we don't accumulate junk rows from typos or curious admins.
|
||||
*/
|
||||
export const settingsRoutes = new Hono<{ Variables: AppVariables }>();
|
||||
|
||||
const KNOWN = new Set<string>(['self_registry_enabled']);
|
||||
const DEFAULTS: Record<string, string> = {
|
||||
// Default to allowing signup so a fresh deployment is usable. An admin
|
||||
// can disable it once they've bootstrapped their account.
|
||||
self_registry_enabled: '1',
|
||||
};
|
||||
|
||||
function readRaw(key: string): string {
|
||||
const row = getDb()
|
||||
.prepare('SELECT value FROM settings WHERE key = ?')
|
||||
.get(key) as { value: string } | null;
|
||||
return row?.value ?? DEFAULTS[key] ?? '';
|
||||
}
|
||||
|
||||
function writeRaw(key: string, value: string): void {
|
||||
getDb()
|
||||
.prepare(`
|
||||
INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
|
||||
`)
|
||||
.run(key, value, Date.now());
|
||||
}
|
||||
|
||||
/** Boolean helper used by signup. */
|
||||
export function isSelfRegistryEnabled(): boolean {
|
||||
return readRaw('self_registry_enabled') === '1';
|
||||
}
|
||||
|
||||
function publicSnapshot(): PublicSettings {
|
||||
return {
|
||||
self_registry_enabled: readRaw('self_registry_enabled') === '1',
|
||||
};
|
||||
}
|
||||
|
||||
// --- GET /api/settings ------------------------------------------------------
|
||||
// Public: clients need to know whether to show the "Opprett konto" button on
|
||||
// the login screen. Nothing sensitive here.
|
||||
settingsRoutes.get('/', (c) => c.json(publicSnapshot()));
|
||||
|
||||
// --- PATCH /api/settings ----------------------------------------------------
|
||||
// Admin-only.
|
||||
settingsRoutes.patch('/', requireAuth, async (c) => {
|
||||
const userId = c.get('userId');
|
||||
if (!isAdmin(userId)) return c.json({ error: 'forbidden' }, 403);
|
||||
|
||||
const body = (await c.req.json().catch(() => null)) as SettingsUpdateRequest | null;
|
||||
if (!body) return c.json({ error: 'invalid_json' }, 400);
|
||||
|
||||
for (const [k, v] of Object.entries(body)) {
|
||||
if (!KNOWN.has(k)) return c.json({ error: `unknown_key:${k}` }, 400);
|
||||
if (typeof v !== 'boolean') return c.json({ error: `invalid:${k}` }, 400);
|
||||
writeRaw(k, v ? '1' : '0');
|
||||
}
|
||||
|
||||
return c.json(publicSnapshot());
|
||||
});
|
||||
|
|
@ -22,6 +22,33 @@ export interface SignupRequest {
|
|||
// verifier, recovery-complete is rejected (closing the lockout DoS).
|
||||
rec_auth_salt: string;
|
||||
rec_auth_verifier: string;
|
||||
/**
|
||||
* Optional invite token. Required when self-registry is disabled.
|
||||
* When present and valid, the new user's `invited_by` is set to the
|
||||
* inviter and the invite is marked claimed.
|
||||
*/
|
||||
invite_token?: string;
|
||||
}
|
||||
|
||||
// --- Site settings ---------------------------------------------------------
|
||||
/** Subset of settings exposed to anonymous clients (gates the signup UI). */
|
||||
export interface PublicSettings {
|
||||
self_registry_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface SettingsUpdateRequest {
|
||||
self_registry_enabled?: boolean;
|
||||
}
|
||||
|
||||
// --- Invites ---------------------------------------------------------------
|
||||
export interface InviteEntry {
|
||||
token: string;
|
||||
/** Absolute URL the inviter shares: <origin>/invite/<token>. */
|
||||
url: string;
|
||||
created_at: number;
|
||||
claimed_at: number | null;
|
||||
/** Display name (or email prefix) of the user who claimed it, if any. */
|
||||
claimed_by_display: string | null;
|
||||
}
|
||||
|
||||
export interface ChallengeResponse {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue