Scaffold Vinterliste — end-to-end encrypted winter activity list
Foundation for an E2E-encrypted activity list per winter-list-claude-code-prompt.md. Server (Bun + Hono): - bun:sqlite with WAL and the spec's schema (idempotent migration) - opaque server-stored sessions, httpOnly cookie - signup / challenge / login / logout / me / password / recovery-challenge / recovery-complete - activity CRUD with strict visibility rules: private uses ciphertext+nonce, semi never serializes owner_id, public attributes the owner - tag store with normalisation + autocomplete (semi/public only) Frontend (Svelte 5 + Vite): - libsodium-wrappers-sumo for client crypto (Argon2id + XChaCha20-Poly1305). SUMO is required because the standard build omits crypto_pwhash. - IndexedDB-backed private tag index (never leaves the browser) - in-memory DEK (no localStorage); page reload re-prompts for password - signup shows the recovery code once; tag input merges server + private sources with clear labelling - Bokmål UI Crypto module (shared/crypto.ts): - pure, runs in both Bun and the browser via a runtime-conditional loader that papers over libsodium-wrappers-sumo's broken ESM entry (createRequire on server, Vite alias in the browser) - DEK wrap/unwrap, AEAD payload encryption, recovery code generation with a visually-unambiguous alphabet Verification: - 22 crypto round-trip tests (wrap/unwrap, AEAD tamper rejection, password change preserves ciphertexts, recovery still works after rotation) - typecheck passes for server and frontend - Vite production build succeeds; libsodium SUMO chunk is ~315 KB gzipped Single-image Containerfile for podman: builds frontend in a builder stage, runs Bun in a slim runtime; one volume for the SQLite file; BUILD_DATE / GIT_REVISION baked into OCI labels and /etc/build-info. Known limitation deferred for this commit: the recovery endpoint has no server-side proof of the recovery code (anyone who knows an email can lock out the legitimate user, though they can't read any data). Closed in the next commit.
This commit is contained in:
commit
47963c9225
39 changed files with 4007 additions and 0 deletions
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="nb">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>Vinterliste</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
73
frontend/src/App.svelte
Normal file
73
frontend/src/App.svelte
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { ready } from './lib/crypto';
|
||||
import { api, ApiError } from './lib/api';
|
||||
import { session, setSession } 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';
|
||||
|
||||
type View = 'login' | 'signup' | 'recovery' | 'home' | 'loading';
|
||||
let view: View = $state('loading');
|
||||
|
||||
onMount(async () => {
|
||||
await ready();
|
||||
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
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) view = 'login';
|
||||
else view = 'login';
|
||||
}
|
||||
});
|
||||
|
||||
let defaultEmail: string = $state('');
|
||||
|
||||
function onAuthed() {
|
||||
view = 'home';
|
||||
}
|
||||
|
||||
async function onLogout() {
|
||||
await logout();
|
||||
view = 'login';
|
||||
}
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<nav class="top">
|
||||
<h1 style="margin: 0;">Vinterliste</h1>
|
||||
{#if session.user}
|
||||
<div class="row">
|
||||
<span class="muted">{session.user.email}</span>
|
||||
<button onclick={onLogout}>Logg ut</button>
|
||||
</div>
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
{#if view === 'loading'}
|
||||
<p class="muted">Laster …</p>
|
||||
{:else if view === 'login'}
|
||||
<Login
|
||||
defaultEmail={defaultEmail}
|
||||
onAuthed={onAuthed}
|
||||
onWantSignup={() => (view = 'signup')}
|
||||
onWantRecovery={() => (view = 'recovery')}
|
||||
/>
|
||||
{:else if view === 'signup'}
|
||||
<Signup onAuthed={onAuthed} onWantLogin={() => (view = 'login')} />
|
||||
{:else if view === 'recovery'}
|
||||
<Recovery
|
||||
onAuthed={onAuthed}
|
||||
onWantLogin={() => (view = 'login')}
|
||||
/>
|
||||
{:else}
|
||||
<Home />
|
||||
{/if}
|
||||
</main>
|
||||
119
frontend/src/components/ActivityForm.svelte
Normal file
119
frontend/src/components/ActivityForm.svelte
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
<script lang="ts">
|
||||
import { api } from '../lib/api';
|
||||
import { session } from '../lib/session.svelte';
|
||||
import {
|
||||
encryptPayload, bytesToBase64,
|
||||
type PrivatePayload,
|
||||
} from '../lib/crypto';
|
||||
import { privateTagIndex } from '../lib/tagIndex';
|
||||
import TagInput from './TagInput.svelte';
|
||||
import type { Activity, Visibility, CreateActivityRequest } from '../../../shared/types';
|
||||
|
||||
interface Props {
|
||||
onCreated: (a: Activity) => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
let { onCreated, onCancel }: Props = $props();
|
||||
|
||||
let visibility: Visibility = $state('private');
|
||||
let title = $state('');
|
||||
let tags: string[] = $state([]);
|
||||
let locLabel = $state('');
|
||||
let scheduled = $state(''); // <input type="datetime-local"> string
|
||||
let error: string | null = $state(null);
|
||||
let busy = $state(false);
|
||||
|
||||
function scheduledEpoch(): number | null {
|
||||
if (!scheduled) return null;
|
||||
const d = new Date(scheduled);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return Math.floor(d.getTime() / 1000);
|
||||
}
|
||||
|
||||
async function submit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
error = null;
|
||||
if (!title.trim()) {
|
||||
error = 'Tittel er påkrevd.';
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
|
||||
try {
|
||||
let body: CreateActivityRequest;
|
||||
if (visibility === 'private') {
|
||||
if (!session.dek) {
|
||||
error = 'Du må være logget inn for å lage en privat oppføring.';
|
||||
busy = false;
|
||||
return;
|
||||
}
|
||||
const payload: PrivatePayload = {
|
||||
title: title.trim(),
|
||||
tags,
|
||||
loc_label: locLabel || undefined,
|
||||
scheduled_at: scheduledEpoch() ?? undefined,
|
||||
};
|
||||
const sealed = encryptPayload(payload, session.dek);
|
||||
body = {
|
||||
visibility,
|
||||
ciphertext: bytesToBase64(sealed.ciphertext),
|
||||
nonce: bytesToBase64(sealed.nonce),
|
||||
};
|
||||
} else {
|
||||
body = {
|
||||
visibility,
|
||||
title: title.trim(),
|
||||
tags,
|
||||
loc_label: locLabel || null,
|
||||
scheduled_at: scheduledEpoch(),
|
||||
};
|
||||
}
|
||||
|
||||
const created = await api.createActivity(body);
|
||||
// Mirror private tags into the client index.
|
||||
if (visibility === 'private') await privateTagIndex.record(tags);
|
||||
onCreated(created);
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<form onsubmit={submit} class="card" aria-labelledby="new-h">
|
||||
<h2 id="new-h">Ny vinteraktivitet</h2>
|
||||
|
||||
<label for="vis">Synlighet</label>
|
||||
<select id="vis" bind:value={visibility}>
|
||||
<option value="private">Privat (ende-til-ende-kryptert)</option>
|
||||
<option value="semi">Halv-offentlig (uten navn)</option>
|
||||
<option value="public">Offentlig (med navn)</option>
|
||||
</select>
|
||||
|
||||
<label for="title">Tittel</label>
|
||||
<input id="title" type="text" bind:value={title} required />
|
||||
|
||||
<div id="tags-label" style="margin: 0.75rem 0 0.25rem; font-weight: 500;">Etiketter</div>
|
||||
<div role="group" aria-labelledby="tags-label">
|
||||
<TagInput visibility={visibility} bind:tags onChange={(t) => (tags = t)} />
|
||||
</div>
|
||||
|
||||
<label for="loc">Sted (valgfritt)</label>
|
||||
<input id="loc" type="text" bind:value={locLabel}
|
||||
placeholder="f.eks. Sognsvann, Oslo" />
|
||||
|
||||
<label for="sched">Tidspunkt (valgfritt)</label>
|
||||
<input id="sched" type="datetime-local" bind:value={scheduled} />
|
||||
|
||||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||||
|
||||
<div class="row" style="margin-top: 1rem;">
|
||||
<button class="primary" type="submit" disabled={busy}>
|
||||
{busy ? 'Lagrer …' : 'Legg til'}
|
||||
</button>
|
||||
{#if onCancel}
|
||||
<button type="button" onclick={onCancel}>Avbryt</button>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
113
frontend/src/components/ActivityRow.svelte
Normal file
113
frontend/src/components/ActivityRow.svelte
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
<script lang="ts">
|
||||
import { session } from '../lib/session.svelte';
|
||||
import {
|
||||
decryptPayload, base64ToBytes,
|
||||
type PrivatePayload,
|
||||
} from '../lib/crypto';
|
||||
import { api } from '../lib/api';
|
||||
import { privateTagIndex } from '../lib/tagIndex';
|
||||
import type { Activity } from '../../../shared/types';
|
||||
|
||||
interface Props {
|
||||
activity: Activity;
|
||||
onDeleted: (id: string) => void;
|
||||
}
|
||||
let { activity, onDeleted }: Props = $props();
|
||||
|
||||
let decrypted: PrivatePayload | null = $state(null);
|
||||
let decryptError: string | null = $state(null);
|
||||
|
||||
$effect(() => {
|
||||
if (activity.visibility !== 'private') {
|
||||
decrypted = null;
|
||||
decryptError = null;
|
||||
return;
|
||||
}
|
||||
if (!session.dek) {
|
||||
decryptError = 'Lås opp med passordet ditt for å lese.';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
decrypted = decryptPayload(
|
||||
{
|
||||
ciphertext: base64ToBytes(activity.ciphertext),
|
||||
nonce: base64ToBytes(activity.nonce),
|
||||
},
|
||||
session.dek,
|
||||
);
|
||||
decryptError = null;
|
||||
} catch (e) {
|
||||
decryptError = 'Klarte ikke å dekryptere denne oppføringen.';
|
||||
}
|
||||
});
|
||||
|
||||
function formatDate(epochSeconds: number | null | undefined): string {
|
||||
if (!epochSeconds) return '';
|
||||
const d = new Date(epochSeconds * 1000);
|
||||
return d.toLocaleString('nb-NO', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
|
||||
});
|
||||
}
|
||||
|
||||
const isOwner = $derived(
|
||||
activity.visibility !== 'semi'
|
||||
? session.user?.id === activity.owner_id
|
||||
: false, // semi never serializes owner; we can't tell from the row alone
|
||||
);
|
||||
|
||||
async function del() {
|
||||
if (!confirm('Slett denne oppføringen?')) return;
|
||||
// For private, release tags from the local index before deletion.
|
||||
if (activity.visibility === 'private' && decrypted) {
|
||||
await privateTagIndex.release(decrypted.tags);
|
||||
}
|
||||
await api.deleteActivity(activity.id);
|
||||
onDeleted(activity.id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<article class="card" aria-labelledby={`act-${activity.id}-h`}>
|
||||
{#if activity.visibility === 'private'}
|
||||
{#if decrypted}
|
||||
<h3 id={`act-${activity.id}-h`} style="display: flex; align-items: center;">
|
||||
{decrypted.title}
|
||||
<span class="vis-badge private">Privat</span>
|
||||
</h3>
|
||||
{#if decrypted.tags.length}
|
||||
<div>
|
||||
{#each decrypted.tags as t}<span class="tag private">{t}</span>{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if decrypted.loc_label}<p class="muted">📍 {decrypted.loc_label}</p>{/if}
|
||||
{#if decrypted.scheduled_at}<p class="muted">🕒 {formatDate(decrypted.scheduled_at)}</p>{/if}
|
||||
{:else if decryptError}
|
||||
<p class="error">{decryptError}</p>
|
||||
{:else}
|
||||
<p class="muted">Dekrypterer …</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<h3 id={`act-${activity.id}-h`} style="display: flex; align-items: center;">
|
||||
{activity.title}
|
||||
<span class="vis-badge {activity.visibility}">
|
||||
{activity.visibility === 'semi' ? 'Anonym' : 'Offentlig'}
|
||||
</span>
|
||||
</h3>
|
||||
{#if activity.tags.length}
|
||||
<div>
|
||||
{#each activity.tags as t}<span class="tag">{t}</span>{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if activity.loc_label}<p class="muted">📍 {activity.loc_label}</p>{/if}
|
||||
{#if activity.scheduled_at}<p class="muted">🕒 {formatDate(activity.scheduled_at)}</p>{/if}
|
||||
{#if activity.visibility === 'public'}
|
||||
<p class="muted" style="font-size: 0.8rem;">Lagt til av bruker {activity.owner_id.slice(0, 8)}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if isOwner || activity.visibility === 'private'}
|
||||
<div class="row" style="margin-top: 0.5rem;">
|
||||
<button class="danger" type="button" onclick={del}>Slett</button>
|
||||
</div>
|
||||
{/if}
|
||||
</article>
|
||||
87
frontend/src/components/Home.svelte
Normal file
87
frontend/src/components/Home.svelte
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '../lib/api';
|
||||
import { session } from '../lib/session.svelte';
|
||||
import ActivityForm from './ActivityForm.svelte';
|
||||
import ActivityRow from './ActivityRow.svelte';
|
||||
import type { Activity } from '../../../shared/types';
|
||||
|
||||
let activities: Activity[] = $state([]);
|
||||
let loading = $state(true);
|
||||
let showForm = $state(false);
|
||||
let error: string | null = $state(null);
|
||||
|
||||
onMount(load);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
try {
|
||||
activities = await api.listActivities();
|
||||
} catch (e) {
|
||||
error = 'Kunne ikke laste oppføringer.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onCreated(a: Activity) {
|
||||
activities = [a, ...activities];
|
||||
showForm = false;
|
||||
}
|
||||
|
||||
function onDeleted(id: string) {
|
||||
activities = activities.filter((a) => a.id !== id);
|
||||
}
|
||||
|
||||
// Split into the three sections defined in the spec — "mine privat" first,
|
||||
// then anonyme, then offentlige.
|
||||
const myPrivate = $derived(activities.filter((a) => a.visibility === 'private'));
|
||||
const semi = $derived(activities.filter((a) => a.visibility === 'semi'));
|
||||
const pub = $derived(activities.filter((a) => a.visibility === 'public'));
|
||||
</script>
|
||||
|
||||
<section aria-label="Aktiviteter">
|
||||
<div class="row" style="justify-content: space-between; margin-bottom: 1rem;">
|
||||
<p class="muted" style="margin: 0;">
|
||||
Velkommen, {session.user?.email}. Her er aktivitetene dine for vinteren.
|
||||
</p>
|
||||
{#if !showForm}
|
||||
<button class="primary" onclick={() => (showForm = true)}>Ny aktivitet</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showForm}
|
||||
<ActivityForm onCreated={onCreated} onCancel={() => (showForm = false)} />
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<p class="muted">Laster …</p>
|
||||
{:else if error}
|
||||
<p class="error">{error}</p>
|
||||
{:else}
|
||||
{#if myPrivate.length}
|
||||
<h2>Dine private</h2>
|
||||
{#each myPrivate as a (a.id)}
|
||||
<ActivityRow activity={a} onDeleted={onDeleted} />
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if semi.length}
|
||||
<h2>Anonyme</h2>
|
||||
{#each semi as a (a.id)}
|
||||
<ActivityRow activity={a} onDeleted={onDeleted} />
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if pub.length}
|
||||
<h2>Offentlige</h2>
|
||||
{#each pub as a (a.id)}
|
||||
<ActivityRow activity={a} onDeleted={onDeleted} />
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if !myPrivate.length && !semi.length && !pub.length}
|
||||
<p class="muted">Ingen aktiviteter ennå. Trykk «Ny aktivitet» for å starte.</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
71
frontend/src/components/Login.svelte
Normal file
71
frontend/src/components/Login.svelte
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
<script lang="ts">
|
||||
import { login } from '../lib/auth';
|
||||
import { ApiError } from '../lib/api';
|
||||
|
||||
interface Props {
|
||||
defaultEmail?: string;
|
||||
onAuthed: () => void;
|
||||
onWantSignup: () => void;
|
||||
onWantRecovery: () => void;
|
||||
}
|
||||
let { defaultEmail = '', onAuthed, onWantSignup, onWantRecovery }: 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
|
||||
// warning, but the wrong target here — we don't want reactive bidirectional
|
||||
// tracking of the prop.
|
||||
// svelte-ignore state_referenced_locally
|
||||
let email = $state(defaultEmail);
|
||||
let password = $state('');
|
||||
let error: string | null = $state(null);
|
||||
let busy = $state(false);
|
||||
|
||||
async function submit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
error = null;
|
||||
busy = true;
|
||||
try {
|
||||
await login(email.trim().toLowerCase(), password);
|
||||
password = '';
|
||||
onAuthed();
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
error = 'Feil epost eller passord.';
|
||||
} else if (err instanceof ApiError && err.status === 404) {
|
||||
error = 'Ingen bruker med den eposten.';
|
||||
} else if (err instanceof Error && err.message.includes('decrypt')) {
|
||||
// libsodium throws a generic decryption error — that means the password
|
||||
// derived a KEK that doesn't unwrap. We landed here despite the server
|
||||
// accepting the verifier, which shouldn't happen unless the DB is
|
||||
// tampered with. Treat as wrong password.
|
||||
error = 'Klarte ikke å låse opp dataene dine.';
|
||||
} else {
|
||||
error = 'Innlogging feilet.';
|
||||
}
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<form onsubmit={submit} class="card" aria-labelledby="login-h">
|
||||
<h2 id="login-h">Logg inn</h2>
|
||||
|
||||
<label for="login-email">Epost</label>
|
||||
<input id="login-email" type="email" autocomplete="username"
|
||||
bind:value={email} required />
|
||||
|
||||
<label for="login-password">Passord</label>
|
||||
<input id="login-password" type="password" autocomplete="current-password"
|
||||
bind:value={password} required />
|
||||
|
||||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||||
|
||||
<div class="row" style="margin-top: 1rem;">
|
||||
<button class="primary" type="submit" disabled={busy}>
|
||||
{busy ? 'Logger inn …' : 'Logg inn'}
|
||||
</button>
|
||||
<button type="button" onclick={onWantSignup}>Opprett konto</button>
|
||||
<button type="button" onclick={onWantRecovery}>Bruk gjenopprettingskode</button>
|
||||
</div>
|
||||
</form>
|
||||
75
frontend/src/components/Recovery.svelte
Normal file
75
frontend/src/components/Recovery.svelte
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<script lang="ts">
|
||||
import { recover } from '../lib/auth';
|
||||
import { ApiError } from '../lib/api';
|
||||
|
||||
interface Props {
|
||||
onAuthed: () => void;
|
||||
onWantLogin: () => void;
|
||||
}
|
||||
let { onAuthed, onWantLogin }: Props = $props();
|
||||
|
||||
let email = $state('');
|
||||
let code = $state('');
|
||||
let newPassword = $state('');
|
||||
let confirm = $state('');
|
||||
let error: string | null = $state(null);
|
||||
let busy = $state(false);
|
||||
|
||||
async function submit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
error = null;
|
||||
if (newPassword.length < 12) {
|
||||
error = 'Det nye passordet må være minst 12 tegn.';
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirm) {
|
||||
error = 'Passordene må være like.';
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
try {
|
||||
await recover(email.trim().toLowerCase(), code, newPassword);
|
||||
onAuthed();
|
||||
} catch (err) {
|
||||
// libsodium throws a generic decrypt error if the code is wrong.
|
||||
if (err instanceof Error && err.message.toLowerCase().includes('decrypt')) {
|
||||
error = 'Feil gjenopprettingskode.';
|
||||
} else if (err instanceof ApiError && err.status === 404) {
|
||||
error = 'Ingen bruker med den eposten.';
|
||||
} else {
|
||||
error = 'Gjenoppretting feilet.';
|
||||
}
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<form onsubmit={submit} class="card" aria-labelledby="rec-h">
|
||||
<h2 id="rec-h">Gjenopprett konto</h2>
|
||||
<p class="muted">Skriv inn gjenopprettingskoden du fikk da du opprettet kontoen, og velg et nytt passord.</p>
|
||||
|
||||
<label for="rec-email">Epost</label>
|
||||
<input id="rec-email" type="email" autocomplete="username" bind:value={email} required />
|
||||
|
||||
<label for="rec-code">Gjenopprettingskode</label>
|
||||
<input id="rec-code" type="text" autocomplete="off" spellcheck="false"
|
||||
bind:value={code} required />
|
||||
|
||||
<label for="rec-pw">Nytt passord</label>
|
||||
<input id="rec-pw" type="password" autocomplete="new-password" minlength="12"
|
||||
bind:value={newPassword} required />
|
||||
|
||||
<label for="rec-confirm">Bekreft nytt passord</label>
|
||||
<input id="rec-confirm" type="password" autocomplete="new-password" minlength="12"
|
||||
bind:value={confirm} required />
|
||||
|
||||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||||
|
||||
<div class="row" style="margin-top: 1rem;">
|
||||
<button class="primary" type="submit" disabled={busy}>
|
||||
{busy ? 'Gjenoppretter …' : 'Sett nytt passord'}
|
||||
</button>
|
||||
<button type="button" onclick={onWantLogin}>Tilbake til innlogging</button>
|
||||
</div>
|
||||
</form>
|
||||
83
frontend/src/components/Signup.svelte
Normal file
83
frontend/src/components/Signup.svelte
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
<script lang="ts">
|
||||
import { signup } from '../lib/auth';
|
||||
import { ApiError } from '../lib/api';
|
||||
|
||||
interface Props {
|
||||
onAuthed: () => void;
|
||||
onWantLogin: () => void;
|
||||
}
|
||||
let { onAuthed, onWantLogin }: Props = $props();
|
||||
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let confirm = $state('');
|
||||
let error: string | null = $state(null);
|
||||
let busy = $state(false);
|
||||
let recoveryCode: string | null = $state(null);
|
||||
|
||||
async function submit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
error = null;
|
||||
if (password.length < 12) {
|
||||
error = 'Passordet må være minst 12 tegn. (Det er den eneste måten å hente dataene dine på — uten det trenger du gjenopprettingskoden.)';
|
||||
return;
|
||||
}
|
||||
if (password !== confirm) {
|
||||
error = 'Passordene må være like.';
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
try {
|
||||
const { recoveryCode: code } = await signup(email.trim().toLowerCase(), password);
|
||||
recoveryCode = code;
|
||||
password = '';
|
||||
confirm = '';
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 409) error = 'Den eposten er allerede i bruk.';
|
||||
else error = 'Kontooppretting feilet.';
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if recoveryCode}
|
||||
<section class="card" aria-labelledby="rc-h">
|
||||
<h2 id="rc-h">Skriv ned gjenopprettingskoden din</h2>
|
||||
<p>
|
||||
Denne koden lar deg låse opp dataene dine om du glemmer passordet. Vi
|
||||
lagrer den <strong>ikke</strong> på serveren — skriver du ikke ned
|
||||
koden nå, vil dataene være borte for godt om passordet forsvinner.
|
||||
</p>
|
||||
<code class="recovery-code" aria-label="Gjenopprettingskode">{recoveryCode}</code>
|
||||
<button class="primary" onclick={onAuthed}>Jeg har skrevet den ned</button>
|
||||
</section>
|
||||
{:else}
|
||||
<form onsubmit={submit} class="card" aria-labelledby="signup-h">
|
||||
<h2 id="signup-h">Opprett konto</h2>
|
||||
<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.
|
||||
</p>
|
||||
|
||||
<label for="signup-email">Epost</label>
|
||||
<input id="signup-email" type="email" autocomplete="username" bind:value={email} required />
|
||||
|
||||
<label for="signup-password">Passord (minst 12 tegn)</label>
|
||||
<input id="signup-password" type="password" autocomplete="new-password"
|
||||
minlength="12" bind:value={password} required />
|
||||
|
||||
<label for="signup-confirm">Bekreft passord</label>
|
||||
<input id="signup-confirm" type="password" autocomplete="new-password"
|
||||
minlength="12" bind:value={confirm} required />
|
||||
|
||||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||||
|
||||
<div class="row" style="margin-top: 1rem;">
|
||||
<button class="primary" type="submit" disabled={busy}>
|
||||
{busy ? 'Oppretter …' : 'Opprett konto'}
|
||||
</button>
|
||||
<button type="button" onclick={onWantLogin}>Jeg har allerede en konto</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
122
frontend/src/components/TagInput.svelte
Normal file
122
frontend/src/components/TagInput.svelte
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Tag input with autocomplete. Suggestions come from two sources:
|
||||
* - public/semi tags (the server's `tags` table) via `/api/tags?q=…`
|
||||
* - the user's private tags via IndexedDB
|
||||
*
|
||||
* For a private activity we only show the IndexedDB source (private tags
|
||||
* never round-trip through the server). For semi/public, we show server
|
||||
* suggestions; we still show the user's private matches too, labelled, so
|
||||
* they can keep a consistent vocabulary across visibility — but only the
|
||||
* private ones the user is explicitly accepting will be visible to others.
|
||||
*
|
||||
* Decision (documented in CLAUDE.md): the two sources are merged but
|
||||
* clearly labelled in the dropdown so the user knows where each match
|
||||
* comes from. A "private-only" suggestion offered for a public activity
|
||||
* becomes public the moment the user accepts it — the label warns them.
|
||||
*/
|
||||
import { api } from '../lib/api';
|
||||
import { privateTagIndex } from '../lib/tagIndex';
|
||||
import type { Visibility } from '../../../shared/types';
|
||||
|
||||
interface Props {
|
||||
visibility: Visibility;
|
||||
tags: string[];
|
||||
onChange: (tags: string[]) => void;
|
||||
}
|
||||
let { visibility, tags = $bindable(), onChange }: Props = $props();
|
||||
|
||||
let input = $state('');
|
||||
let serverHits: { name: string; usage_count: number }[] = $state([]);
|
||||
let privateHits: { name: string; count: number }[] = $state([]);
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function add(name: string) {
|
||||
const n = name.trim().toLowerCase();
|
||||
if (!n) return;
|
||||
if (tags.includes(n)) return;
|
||||
tags = [...tags, n];
|
||||
onChange(tags);
|
||||
input = '';
|
||||
serverHits = [];
|
||||
privateHits = [];
|
||||
}
|
||||
|
||||
function remove(name: string) {
|
||||
tags = tags.filter((t) => t !== name);
|
||||
onChange(tags);
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault();
|
||||
add(input);
|
||||
} else if (e.key === 'Backspace' && !input && tags.length) {
|
||||
remove(tags[tags.length - 1]!);
|
||||
}
|
||||
}
|
||||
|
||||
function onType(e: Event) {
|
||||
input = (e.target as HTMLInputElement).value;
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(query, 120);
|
||||
}
|
||||
|
||||
async function query() {
|
||||
const q = input.trim();
|
||||
if (!q) { serverHits = []; privateHits = []; return; }
|
||||
|
||||
privateHits = await privateTagIndex.suggest(q, 10).catch(() => []);
|
||||
|
||||
if (visibility !== 'private') {
|
||||
serverHits = await api.tagSuggestions(q, 10).catch(() => []);
|
||||
} else {
|
||||
serverHits = [];
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="row" role="group" aria-label="Etiketter">
|
||||
{#each tags as t (t)}
|
||||
<span class="tag">
|
||||
{t}
|
||||
<button type="button" aria-label="Fjern {t}"
|
||||
style="margin-left: 0.3rem; padding: 0 0.3rem;"
|
||||
onclick={() => remove(t)}>×</button>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
oninput={onType}
|
||||
onkeydown={onKey}
|
||||
placeholder="Legg til etikett, trykk Enter"
|
||||
aria-label="Ny etikett"
|
||||
/>
|
||||
|
||||
{#if serverHits.length || privateHits.length}
|
||||
<div class="card" style="margin-top: 0.25rem;" role="listbox" aria-label="Forslag">
|
||||
{#each serverHits as s (s.name)}
|
||||
<button type="button"
|
||||
style="display: block; width: 100%; text-align: left; border: none; background: transparent; padding: 0.25rem 0;"
|
||||
onclick={() => add(s.name)}>
|
||||
{s.name}
|
||||
<span class="muted">offentlig · {s.usage_count}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{#each privateHits as p (p.name)}
|
||||
{#if !serverHits.find((s) => s.name === p.name)}
|
||||
<button type="button"
|
||||
style="display: block; width: 100%; text-align: left; border: none; background: transparent; padding: 0.25rem 0;"
|
||||
onclick={() => add(p.name)}>
|
||||
{p.name}
|
||||
<span class="muted">
|
||||
{visibility === 'private' ? 'privat' : 'kun din'} · {p.count}
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
72
frontend/src/lib/api.ts
Normal file
72
frontend/src/lib/api.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import type {
|
||||
SignupRequest, ChallengeResponse, LoginRequest,
|
||||
RecoveryChallengeResponse, RecoveryCompleteRequest, PasswordChangeRequest,
|
||||
MeResponse, Activity, CreateActivityRequest, UpdateActivityRequest,
|
||||
TagSuggestion,
|
||||
} from '../../../shared/types';
|
||||
|
||||
const BASE = '/api';
|
||||
|
||||
async function http<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
...(init.headers ?? {}),
|
||||
},
|
||||
...init,
|
||||
});
|
||||
if (!res.ok) {
|
||||
let detail: unknown = null;
|
||||
try { detail = await res.json(); } catch { /* ignore */ }
|
||||
const err = new ApiError(res.status, detail);
|
||||
throw err;
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public readonly status: number, public readonly detail: unknown) {
|
||||
super(`API ${status}: ${JSON.stringify(detail)}`);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
// --- auth -------------------------------------------------------------------
|
||||
export const api = {
|
||||
signup: (body: SignupRequest) =>
|
||||
http<MeResponse>('/auth/signup', { method: 'POST', body: JSON.stringify(body) }),
|
||||
challenge: (email: string) =>
|
||||
http<ChallengeResponse>('/auth/challenge', { method: 'POST', body: JSON.stringify({ email }) }),
|
||||
login: (body: LoginRequest) =>
|
||||
http<MeResponse>('/auth/login', { method: 'POST', body: JSON.stringify(body) }),
|
||||
logout: () => http<{ ok: true }>('/auth/logout', { method: 'POST' }),
|
||||
me: () => http<MeResponse>('/auth/me'),
|
||||
passwordChange: (body: PasswordChangeRequest) =>
|
||||
http<{ ok: true }>('/auth/password', { method: 'POST', body: JSON.stringify(body) }),
|
||||
recoveryChallenge: (email: string) =>
|
||||
http<RecoveryChallengeResponse>('/auth/recovery-challenge', {
|
||||
method: 'POST', body: JSON.stringify({ email }),
|
||||
}),
|
||||
recoveryComplete: (body: RecoveryCompleteRequest) =>
|
||||
http<{ ok: true }>('/auth/recovery-complete', {
|
||||
method: 'POST', body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
// --- activities -----------------------------------------------------------
|
||||
listActivities: () => http<Activity[]>('/activities'),
|
||||
createActivity: (body: CreateActivityRequest) =>
|
||||
http<Activity>('/activities', { method: 'POST', body: JSON.stringify(body) }),
|
||||
updateActivity: (id: string, body: UpdateActivityRequest) =>
|
||||
http<Activity>(`/activities/${encodeURIComponent(id)}`, {
|
||||
method: 'PATCH', body: JSON.stringify(body),
|
||||
}),
|
||||
deleteActivity: (id: string) =>
|
||||
http<{ ok: true }>(`/activities/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
|
||||
// --- tags -----------------------------------------------------------------
|
||||
tagSuggestions: (q: string, limit = 20) =>
|
||||
http<TagSuggestion[]>(`/tags?q=${encodeURIComponent(q)}&limit=${limit}`),
|
||||
};
|
||||
186
frontend/src/lib/auth.ts
Normal file
186
frontend/src/lib/auth.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
/**
|
||||
* High-level auth flows — these orchestrate libsodium key derivation, the API
|
||||
* client, and the in-memory session state. The crypto module is pure; this
|
||||
* module is where the *workflow* (signup, login, password change, recovery)
|
||||
* lives.
|
||||
*/
|
||||
import { api } from './api';
|
||||
import { setSession, clearSession as clearLocal } from './session.svelte';
|
||||
import { privateTagIndex } from './tagIndex';
|
||||
import {
|
||||
ready,
|
||||
generateDek,
|
||||
generateSalt,
|
||||
generateRecoveryCode,
|
||||
normalizeRecoveryCode,
|
||||
deriveKey,
|
||||
deriveAuthVerifier,
|
||||
wrapDek,
|
||||
unwrapDek,
|
||||
bytesToBase64,
|
||||
base64ToBytes,
|
||||
zero,
|
||||
} from './crypto';
|
||||
import type { MeResponse } from '../../../shared/types';
|
||||
|
||||
export interface SignupResult {
|
||||
user: MeResponse;
|
||||
recoveryCode: string; // shown once, never sent
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
await ready();
|
||||
|
||||
const dek = generateDek();
|
||||
const recoveryCode = generateRecoveryCode();
|
||||
|
||||
const kekSalt = generateSalt();
|
||||
const recSalt = generateSalt();
|
||||
const authSalt = generateSalt();
|
||||
|
||||
const kekPw = deriveKey(password, kekSalt);
|
||||
const kekRec = deriveKey(normalizeRecoveryCode(recoveryCode), recSalt);
|
||||
const authVerifier = deriveAuthVerifier(password, authSalt);
|
||||
|
||||
const wrappedPw = wrapDek(dek, kekPw);
|
||||
const wrappedRec = wrapDek(dek, kekRec);
|
||||
|
||||
// Zero out KEKs we no longer need before going async.
|
||||
zero(kekPw);
|
||||
zero(kekRec);
|
||||
|
||||
const user = await api.signup({
|
||||
email,
|
||||
auth_salt: bytesToBase64(authSalt),
|
||||
auth_verifier: authVerifier,
|
||||
kek_salt: bytesToBase64(kekSalt),
|
||||
wrapped_dek_pw: bytesToBase64(wrappedPw.ciphertext),
|
||||
dek_pw_nonce: bytesToBase64(wrappedPw.nonce),
|
||||
rec_salt: bytesToBase64(recSalt),
|
||||
wrapped_dek_rec: bytesToBase64(wrappedRec.ciphertext),
|
||||
dek_rec_nonce: bytesToBase64(wrappedRec.nonce),
|
||||
});
|
||||
|
||||
setSession(user, dek);
|
||||
return { user, recoveryCode };
|
||||
}
|
||||
|
||||
/**
|
||||
* Login: fetch challenge, derive both KEK and verifier, send verifier to login,
|
||||
* unwrap DEK locally on success.
|
||||
*/
|
||||
export async function login(email: string, password: string): Promise<MeResponse> {
|
||||
await ready();
|
||||
|
||||
const challenge = await api.challenge(email);
|
||||
const authSalt = base64ToBytes(challenge.auth_salt);
|
||||
const kekSalt = base64ToBytes(challenge.kek_salt);
|
||||
|
||||
const authVerifier = deriveAuthVerifier(password, authSalt);
|
||||
const kekPw = deriveKey(password, kekSalt);
|
||||
|
||||
const user = await api.login({ email, auth_verifier: authVerifier });
|
||||
|
||||
const dek = unwrapDek(
|
||||
{
|
||||
ciphertext: base64ToBytes(challenge.wrapped_dek_pw),
|
||||
nonce: base64ToBytes(challenge.dek_pw_nonce),
|
||||
},
|
||||
kekPw,
|
||||
);
|
||||
zero(kekPw);
|
||||
|
||||
setSession(user, dek);
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
await api.logout().catch(() => null);
|
||||
await privateTagIndex.clear().catch(() => null);
|
||||
clearLocal();
|
||||
}
|
||||
|
||||
/**
|
||||
* Password change: unwrap DEK with old password, re-wrap with new password,
|
||||
* push the new material. Recovery wrap stays put.
|
||||
*/
|
||||
export async function changePassword(oldPassword: string, newPassword: string, email: string): Promise<void> {
|
||||
await ready();
|
||||
|
||||
// Re-derive old keys from the server's current material.
|
||||
const challenge = await api.challenge(email);
|
||||
const oldKek = deriveKey(oldPassword, base64ToBytes(challenge.kek_salt));
|
||||
const dek = unwrapDek(
|
||||
{
|
||||
ciphertext: base64ToBytes(challenge.wrapped_dek_pw),
|
||||
nonce: base64ToBytes(challenge.dek_pw_nonce),
|
||||
},
|
||||
oldKek,
|
||||
);
|
||||
zero(oldKek);
|
||||
|
||||
const kekSalt = generateSalt();
|
||||
const authSalt = generateSalt();
|
||||
const newKek = deriveKey(newPassword, kekSalt);
|
||||
const verifier = deriveAuthVerifier(newPassword, authSalt);
|
||||
const wrapped = wrapDek(dek, newKek);
|
||||
zero(newKek);
|
||||
|
||||
await api.passwordChange({
|
||||
auth_salt: bytesToBase64(authSalt),
|
||||
auth_verifier: verifier,
|
||||
kek_salt: bytesToBase64(kekSalt),
|
||||
wrapped_dek_pw: bytesToBase64(wrapped.ciphertext),
|
||||
dek_pw_nonce: bytesToBase64(wrapped.nonce),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Recovery: user provides their recovery code; we unwrap the DEK via the
|
||||
* recovery wrap, then build new password-side material.
|
||||
*/
|
||||
export async function recover(
|
||||
email: string,
|
||||
recoveryCode: string,
|
||||
newPassword: string,
|
||||
): Promise<void> {
|
||||
await ready();
|
||||
|
||||
const challenge = await api.recoveryChallenge(email);
|
||||
const kekRec = deriveKey(
|
||||
normalizeRecoveryCode(recoveryCode),
|
||||
base64ToBytes(challenge.rec_salt),
|
||||
);
|
||||
const dek = unwrapDek(
|
||||
{
|
||||
ciphertext: base64ToBytes(challenge.wrapped_dek_rec),
|
||||
nonce: base64ToBytes(challenge.dek_rec_nonce),
|
||||
},
|
||||
kekRec,
|
||||
);
|
||||
zero(kekRec);
|
||||
|
||||
const kekSalt = generateSalt();
|
||||
const authSalt = generateSalt();
|
||||
const newKek = deriveKey(newPassword, kekSalt);
|
||||
const verifier = deriveAuthVerifier(newPassword, authSalt);
|
||||
const wrapped = wrapDek(dek, newKek);
|
||||
zero(newKek);
|
||||
// dek not yet zeroed — we still need it to log the user back in.
|
||||
|
||||
await api.recoveryComplete({
|
||||
email,
|
||||
auth_salt: bytesToBase64(authSalt),
|
||||
auth_verifier: verifier,
|
||||
kek_salt: bytesToBase64(kekSalt),
|
||||
wrapped_dek_pw: bytesToBase64(wrapped.ciphertext),
|
||||
dek_pw_nonce: bytesToBase64(wrapped.nonce),
|
||||
});
|
||||
|
||||
// Log in with the new password so the user lands in an authenticated state.
|
||||
await login(email, newPassword);
|
||||
}
|
||||
4
frontend/src/lib/crypto.ts
Normal file
4
frontend/src/lib/crypto.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
// Re-export the shared crypto module so frontend imports don't have to know
|
||||
// where it physically lives. Bundlers (Vite) inline this; tests (Bun) import
|
||||
// the shared module directly.
|
||||
export * from '../../../shared/crypto';
|
||||
30
frontend/src/lib/session.svelte.ts
Normal file
30
frontend/src/lib/session.svelte.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/**
|
||||
* In-memory session state — DEK and current user. The DEK NEVER leaves this
|
||||
* module's closure (no localStorage, no sessionStorage) so a page reload
|
||||
* intentionally drops it and forces a re-unlock with the password.
|
||||
*
|
||||
* Svelte 5 runes give us cheap reactivity without a separate store.
|
||||
*/
|
||||
import type { MeResponse } from '../../../shared/types';
|
||||
|
||||
interface State {
|
||||
user: MeResponse | null;
|
||||
dek: Uint8Array | null;
|
||||
}
|
||||
|
||||
export const session = $state<State>({ user: null, dek: null });
|
||||
|
||||
export function setSession(user: MeResponse, dek: Uint8Array): void {
|
||||
session.user = user;
|
||||
session.dek = dek;
|
||||
}
|
||||
|
||||
export function clearSession(): void {
|
||||
if (session.dek) {
|
||||
// Best-effort zeroisation. The buffer may have been copied internally by
|
||||
// libsodium before we get here, but we still wipe what we hold.
|
||||
session.dek.fill(0);
|
||||
}
|
||||
session.user = null;
|
||||
session.dek = null;
|
||||
}
|
||||
121
frontend/src/lib/tagIndex.ts
Normal file
121
frontend/src/lib/tagIndex.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
/**
|
||||
* Client-side index of the user's private tags, kept in IndexedDB.
|
||||
*
|
||||
* Why IndexedDB and not localStorage:
|
||||
* - we want this scoped per-origin and not synced across tabs through the
|
||||
* JS event loop;
|
||||
* - we want async, non-blocking reads on the autocomplete hot path;
|
||||
* - private tags must never round-trip through the server, so the storage
|
||||
* has to be local.
|
||||
*
|
||||
* The schema is intentionally tiny: a single object store keyed by the
|
||||
* lowercase tag name, with a `count` field for ranking suggestions.
|
||||
*/
|
||||
|
||||
const DB_NAME = 'vinterliste';
|
||||
const STORE = 'private_tags';
|
||||
const VERSION = 1;
|
||||
|
||||
interface TagRow {
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
function openDb(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, VERSION);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains(STORE)) {
|
||||
db.createObjectStore(STORE, { keyPath: 'name' });
|
||||
}
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function withStore<T>(
|
||||
mode: IDBTransactionMode,
|
||||
fn: (store: IDBObjectStore) => Promise<T> | T,
|
||||
): Promise<T> {
|
||||
const db = await openDb();
|
||||
return await new Promise<T>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE, mode);
|
||||
const store = tx.objectStore(STORE);
|
||||
let result: T;
|
||||
Promise.resolve(fn(store)).then((r) => { result = r; }).catch(reject);
|
||||
tx.oncomplete = () => resolve(result);
|
||||
tx.onerror = () => reject(tx.error);
|
||||
tx.onabort = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
function normalise(name: string): string {
|
||||
return name.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export const privateTagIndex = {
|
||||
/** Bump or insert every tag in `tags`. Call after creating/updating a private activity. */
|
||||
async record(tags: string[]): Promise<void> {
|
||||
const names = [...new Set(tags.map(normalise).filter(Boolean))];
|
||||
if (names.length === 0) return;
|
||||
await withStore('readwrite', (store) => {
|
||||
for (const name of names) {
|
||||
const req = store.get(name);
|
||||
req.onsuccess = () => {
|
||||
const existing = req.result as TagRow | undefined;
|
||||
const next: TagRow = existing
|
||||
? { name, count: existing.count + 1 }
|
||||
: { name, count: 1 };
|
||||
store.put(next);
|
||||
};
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/** Decrement counts for the tags of an activity that's being removed/edited. */
|
||||
async release(tags: string[]): Promise<void> {
|
||||
const names = [...new Set(tags.map(normalise).filter(Boolean))];
|
||||
if (names.length === 0) return;
|
||||
await withStore('readwrite', (store) => {
|
||||
for (const name of names) {
|
||||
const req = store.get(name);
|
||||
req.onsuccess = () => {
|
||||
const existing = req.result as TagRow | undefined;
|
||||
if (!existing) return;
|
||||
if (existing.count <= 1) store.delete(name);
|
||||
else store.put({ name, count: existing.count - 1 });
|
||||
};
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/** Return suggestions matching `q` (prefix match), sorted by count desc. */
|
||||
async suggest(q: string, limit = 20): Promise<TagRow[]> {
|
||||
const prefix = normalise(q);
|
||||
return withStore('readonly', (store) => new Promise<TagRow[]>((resolve, reject) => {
|
||||
const out: TagRow[] = [];
|
||||
const req = store.openCursor();
|
||||
req.onsuccess = () => {
|
||||
const cursor = req.result;
|
||||
if (!cursor) {
|
||||
out.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
|
||||
resolve(out.slice(0, limit));
|
||||
return;
|
||||
}
|
||||
const row = cursor.value as TagRow;
|
||||
if (!prefix || row.name.startsWith(prefix)) out.push(row);
|
||||
cursor.continue();
|
||||
};
|
||||
req.onerror = () => reject(req.error);
|
||||
}));
|
||||
},
|
||||
|
||||
/** Drop the index — used on logout to leave no per-user residue. */
|
||||
async clear(): Promise<void> {
|
||||
await withStore('readwrite', (store) => {
|
||||
store.clear();
|
||||
});
|
||||
},
|
||||
};
|
||||
8
frontend/src/main.ts
Normal file
8
frontend/src/main.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { mount } from 'svelte';
|
||||
import App from './App.svelte';
|
||||
import './styles.css';
|
||||
|
||||
const target = document.getElementById('app');
|
||||
if (!target) throw new Error('No #app element in DOM');
|
||||
|
||||
mount(App, { target });
|
||||
158
frontend/src/styles.css
Normal file
158
frontend/src/styles.css
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #fafafa;
|
||||
--fg: #1c1c1c;
|
||||
--muted: #6c6c6c;
|
||||
--border: #d8d8d8;
|
||||
--accent: #1f6feb;
|
||||
--accent-fg: white;
|
||||
--danger: #b3261e;
|
||||
--card: #ffffff;
|
||||
--radius: 10px;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #161616;
|
||||
--fg: #e8e8e8;
|
||||
--muted: #9a9a9a;
|
||||
--border: #2d2d2d;
|
||||
--accent: #4a8cff;
|
||||
--card: #1f1f1f;
|
||||
--danger: #f28b82;
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 1rem 4rem;
|
||||
}
|
||||
|
||||
h1, h2, h3 { line-height: 1.2; margin-top: 0; }
|
||||
h1 { font-size: 1.75rem; }
|
||||
h2 { font-size: 1.25rem; }
|
||||
p { line-height: 1.5; }
|
||||
a { color: var(--accent); }
|
||||
|
||||
input, button, select, textarea {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
input[type="text"], input[type="email"], input[type="password"],
|
||||
input[type="datetime-local"], textarea, select {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
input:focus-visible, button:focus-visible, select:focus-visible, textarea:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
padding: 0.5rem 0.9rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
color: var(--fg);
|
||||
}
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
border-color: transparent;
|
||||
}
|
||||
button.danger {
|
||||
background: transparent;
|
||||
color: var(--danger);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
button:disabled { opacity: 0.5; cursor: progress; }
|
||||
|
||||
label { display: block; margin: 0.75rem 0 0.25rem; font-weight: 500; }
|
||||
.row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
|
||||
.muted { color: var(--muted); font-size: 0.9rem; }
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.banner {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 4px solid var(--accent);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.banner.danger { border-left-color: var(--danger); }
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: rgba(127,127,127,0.15);
|
||||
border-radius: 999px;
|
||||
padding: 0.1rem 0.55rem;
|
||||
font-size: 0.85rem;
|
||||
margin: 0.15rem 0.2rem 0.15rem 0;
|
||||
}
|
||||
.tag.private { background: rgba(31,111,235,0.15); }
|
||||
|
||||
.recovery-code {
|
||||
display: block;
|
||||
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
background: var(--card);
|
||||
border: 1px dashed var(--border);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: 1.1rem;
|
||||
letter-spacing: 0.05em;
|
||||
text-align: center;
|
||||
margin: 0.5rem 0 1rem;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
nav.top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.vis-badge {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
.vis-badge.private { background: rgba(31,111,235,0.15); color: var(--accent); }
|
||||
.vis-badge.semi { background: rgba(127,127,127,0.18); color: var(--muted); }
|
||||
.vis-badge.public { background: rgba(46,160,67,0.18); color: #2ea043; }
|
||||
|
||||
.error { color: var(--danger); margin-top: 0.5rem; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* { animation: none !important; transition: none !important; }
|
||||
}
|
||||
5
frontend/svelte.config.js
Normal file
5
frontend/svelte.config.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
export default {
|
||||
preprocess: vitePreprocess(),
|
||||
};
|
||||
19
frontend/tsconfig.json
Normal file
19
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"extends": "@tsconfig/svelte/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowImportingTsExtensions": false,
|
||||
"skipLibCheck": true,
|
||||
"verbatimModuleSyntax": false,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*", "../shared/**/*"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
40
frontend/vite.config.ts
Normal file
40
frontend/vite.config.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { defineConfig } from 'vite';
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = (rel: string) => fileURLToPath(new URL(rel, import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
root: here('.'),
|
||||
plugins: [svelte()],
|
||||
resolve: {
|
||||
alias: {
|
||||
// The ESM entry of libsodium-wrappers-sumo is broken upstream (relative
|
||||
// import of a file that isn't shipped). Redirect to the CJS bundle,
|
||||
// which Vite's CJS interop handles transparently. Mirrors tsconfig
|
||||
// paths so editors and tsc agree.
|
||||
'libsodium-wrappers-sumo': here(
|
||||
'../node_modules/libsodium-wrappers-sumo/dist/modules-sumo/libsodium-wrappers.js',
|
||||
),
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
// The CJS entry needs to be pre-bundled or browsers will choke on `require`.
|
||||
include: ['libsodium-wrappers-sumo'],
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3000',
|
||||
},
|
||||
fs: {
|
||||
allow: ['..'],
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
sourcemap: true,
|
||||
target: 'es2022',
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue