User profile, activity editing, search, OSM links, moderator role,

opt-in /<username>/list, and a feedback channel

Six related features that touch the user model and activity UX:

1. **User profile** (display_name, username, public_list_enabled).
   New `display_name`, `username` (UNIQUE, slug-shaped), and
   `public_list_enabled` columns. PATCH /api/auth/profile is a partial
   update — pass only the fields you want to change, null to clear.
   MeResponse exposes all three. Display name is shown on public
   activities and in the nav; falls back to the email prefix when
   unset.

2. **Change password from the profile editor.** Existing
   /api/auth/password endpoint surfaced in the new Profile.svelte;
   the local-decrypt failure path on a wrong current password is
   mapped to a clean error.

3. **Edit existing activities.** ActivityForm becomes dual-purpose
   (create or edit). Title, tags, date/time, location, and
   visibility are all editable. Visibility transitions decrypt or
   re-encrypt client-side as needed before PATCH, and the IndexedDB
   private-tag index is kept in sync diff-style.

4. **Search.** A search input on Home filters across visible
   activities. Private rows are searched against their decrypted
   cleartext (decrypted once and memoised via $derived, so the work
   is amortised across keystrokes). Matches across title, tags,
   location label, and (for public) author display name.

5. **OpenStreetMap links.** Each row with a location renders the
   label as an OSM link. Smart: coords if present
   (?mlat=&mlon=&map=15/lat/lng → pinned view), else
   /search?query=. Built with the WHATWG URL constructor so
   Norwegian characters and commas survive.

6. **Moderator role + semi-delete by owner.** New is_moderator
   column on users. Owners always delete their own rows; moderators
   can additionally delete any semi or public activity (private is
   excluded — it's invisible to others, so there's no moderation
   case). README documents the manual promotion via sqlite3.

7. **Opt-in /<username>/list.** New server route
   /api/users/:username/list returns the user's public activities
   when both `username` is set AND `public_list_enabled = 1`. 404
   when either condition fails — same response in both cases so the
   route doesn't leak username existence for users who haven't opted
   in. SPA-side, App.svelte parses window.location.pathname on
   mount; falls back to "/" via history.replaceState after
   authenticating from a deep link.

8. **Feedback channel.** New `feedback` table. POST /api/feedback
   for any authenticated user; GET /api/feedback gated to
   moderators. The Feedback.svelte component is dual-mode — the
   form is universal; the list view auto-loads only for
   moderators. Submitter identity (email + display name) is shown
   to moderators so they can follow up; not exposed to the
   submitter themselves.

Schema migrations land via the existing ensureColumn() helper so
scaffold DBs upgrade cleanly. The username UNIQUE constraint is
applied as a partial unique index (WHERE username IS NOT NULL) so
multiple users with NULL usernames don't collide.

All 26 existing tests still pass; typecheck clean for both
tsconfigs; Vite build succeeds.
This commit is contained in:
Ole-Morten Duesund 2026-05-25 12:44:33 +02:00
commit 6f4c11c7a6
16 changed files with 1152 additions and 107 deletions

View file

@ -2,35 +2,64 @@
import { onMount } from 'svelte';
import { ready } from './lib/crypto';
import { api, ApiError } from './lib/api';
import { session, setSession } from './lib/session.svelte';
import { session } 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';
import Profile from './components/Profile.svelte';
import Feedback from './components/Feedback.svelte';
import PublicList from './components/PublicList.svelte';
type View = 'login' | 'signup' | 'recovery' | 'home' | 'loading';
type View = 'login' | 'signup' | 'recovery' | 'home' | 'profile' | 'feedback' | 'public-list' | 'loading';
let view: View = $state('loading');
let publicListUsername = $state('');
let defaultEmail: string = $state('');
/**
* Hand-rolled path routing. The only path-based view is `/<username>/list`;
* everything else falls back to the in-app view state. This avoids pulling
* in a router for a single dynamic route.
*
* On signup/login we replaceState() back to "/" so the browser address
* doesn't keep showing the public-list URL while the user is in their own
* authenticated view.
*/
function parsePath(): { username: string } | null {
const path = window.location.pathname;
const m = path.match(/^\/([a-z0-9_-]{2,31})\/list\/?$/);
return m ? { username: m[1]! } : null;
}
onMount(async () => {
await ready();
const route = parsePath();
if (route) {
publicListUsername = route.username;
view = 'public-list';
return;
}
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
view = 'login';
} catch (err) {
if (err instanceof ApiError && err.status === 401) view = 'login';
else view = 'login';
}
});
let defaultEmail: string = $state('');
function onAuthed() {
// After authenticating from a deep link, return to "/".
if (window.location.pathname !== '/') {
window.history.replaceState({}, '', '/');
}
view = 'home';
}
@ -38,14 +67,26 @@
await logout();
view = 'login';
}
function leavePublicList() {
window.history.replaceState({}, '', '/');
view = session.user ? 'home' : 'login';
}
</script>
<main>
<nav class="top">
<h1 style="margin: 0;">Vinterliste</h1>
{#if session.user}
{#if session.user && view !== 'public-list'}
<div class="row">
<span class="muted">{session.user.email}</span>
<button type="button" onclick={() => (view = 'feedback')}
aria-label="Send tilbakemelding">
Tilbakemelding
</button>
<button type="button" onclick={() => (view = 'profile')}
aria-label="Profil">
{session.user.display_name?.trim() || session.user.email}
</button>
<button onclick={onLogout}>Logg ut</button>
</div>
{/if}
@ -53,6 +94,8 @@
{#if view === 'loading'}
<p class="muted">Laster …</p>
{:else if view === 'public-list'}
<PublicList username={publicListUsername} onBack={leavePublicList} />
{:else if view === 'login'}
<Login
defaultEmail={defaultEmail}
@ -67,6 +110,10 @@
onAuthed={onAuthed}
onWantLogin={() => (view = 'login')}
/>
{:else if view === 'profile'}
<Profile onDone={() => (view = 'home')} />
{:else if view === 'feedback'}
<Feedback onDone={() => (view = 'home')} />
{:else}
<Home />
{/if}

View file

@ -2,27 +2,88 @@
import { api } from '../lib/api';
import { session } from '../lib/session.svelte';
import {
encryptPayload, bytesToBase64,
encryptPayload, decryptPayload, base64ToBytes, bytesToBase64,
type PrivatePayload,
} from '../lib/crypto';
import { privateTagIndex } from '../lib/tagIndex';
import TagInput from './TagInput.svelte';
import type { Activity, Visibility, CreateActivityRequest } from '../../../shared/types';
import type {
Activity, Visibility, CreateActivityRequest, UpdateActivityRequest,
} from '../../../shared/types';
interface Props {
/** Pass an existing activity to enter edit mode; omit/null to create. */
existing?: Activity | null;
/** Used for both "created" and "updated". The parent decides what to do. */
onCreated: (a: Activity) => void;
onCancel?: () => void;
}
let { onCreated, onCancel }: Props = $props();
let { existing = null, onCreated, onCancel }: Props = $props();
const isEdit = $derived(existing !== null);
// --- Initial state from `existing` ---------------------------------------
// For private rows we decrypt locally so the form fields show real values.
function decryptExisting(): PrivatePayload | null {
if (!existing || existing.visibility !== 'private') return null;
if (!session.dek) return null;
try {
return decryptPayload(
{ ciphertext: base64ToBytes(existing.ciphertext), nonce: base64ToBytes(existing.nonce) },
session.dek,
);
} catch { return null; }
}
const initialPriv = decryptExisting();
// svelte-ignore state_referenced_locally
let visibility: Visibility = $state(existing?.visibility ?? 'private');
// svelte-ignore state_referenced_locally
let title = $state(
existing
? (existing.visibility === 'private' ? initialPriv?.title ?? '' : existing.title)
: '',
);
// svelte-ignore state_referenced_locally
let tags: string[] = $state(
existing
? (existing.visibility === 'private' ? initialPriv?.tags ?? [] : [...existing.tags])
: [],
);
// svelte-ignore state_referenced_locally
let locLabel = $state(
existing
? (existing.visibility === 'private'
? initialPriv?.loc_label ?? ''
: existing.loc_label ?? '')
: '',
);
// svelte-ignore state_referenced_locally
let scheduled = $state(
existing
? toLocalInput(
existing.visibility === 'private'
? initialPriv?.scheduled_at ?? null
: existing.scheduled_at,
)
: '',
);
// Original visibility + tags (for diffing the private-tag index on save).
const originalVisibility: Visibility | null = existing?.visibility ?? null;
const originalPrivateTags: string[] = initialPriv?.tags ?? [];
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 toLocalInput(epochSeconds: number | null | undefined): string {
if (!epochSeconds) return '';
const d = new Date(epochSeconds * 1000);
// <input type="datetime-local"> wants YYYY-MM-DDTHH:MM in local time.
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function scheduledEpoch(): number | null {
if (!scheduled) return null;
const d = new Date(scheduled);
@ -30,6 +91,64 @@
return Math.floor(d.getTime() / 1000);
}
/**
* Build the request body. The server expects either a private shape
* (ciphertext+nonce) OR a plaintext shape (title/tags/loc/scheduled). When
* switching visibility, we send the new shape and let the server clear
* the columns of the old shape (see server/activities.ts:patchActivity).
*/
function buildBody(): CreateActivityRequest {
if (visibility === 'private') {
if (!session.dek) throw new Error('not_logged_in');
const payload: PrivatePayload = {
title: title.trim(),
tags,
loc_label: locLabel || undefined,
scheduled_at: scheduledEpoch() ?? undefined,
};
const sealed = encryptPayload(payload, session.dek);
return {
visibility,
ciphertext: bytesToBase64(sealed.ciphertext),
nonce: bytesToBase64(sealed.nonce),
};
}
return {
visibility,
title: title.trim(),
tags,
loc_label: locLabel || null,
scheduled_at: scheduledEpoch(),
};
}
/**
* Keep the IndexedDB private tag index in sync with what the user actually
* has stored as private. Three cases:
* - private → private: diff: release old, record new
* - private → semi/public: release all
* - semi/public → private: record all
* - semi/public → semi/public: no-op (tags are server-side)
*/
async function syncPrivateTagIndex(): Promise<void> {
const wasPrivate = originalVisibility === 'private';
const isPrivate = visibility === 'private';
if (!wasPrivate && !isPrivate) return;
if (wasPrivate && !isPrivate) {
await privateTagIndex.release(originalPrivateTags);
return;
}
if (!wasPrivate && isPrivate) {
await privateTagIndex.record(tags);
return;
}
// both private: release the ones that went away, record the new ones
const newSet = new Set(tags);
const oldSet = new Set(originalPrivateTags);
await privateTagIndex.release(originalPrivateTags.filter((t) => !newSet.has(t)));
await privateTagIndex.record(tags.filter((t) => !oldSet.has(t)));
}
async function submit(e: SubmitEvent) {
e.preventDefault();
error = null;
@ -38,41 +157,13 @@
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);
const body = buildBody();
const result: Activity = isEdit && existing
? await api.updateActivity(existing.id, body as UpdateActivityRequest)
: await api.createActivity(body);
await syncPrivateTagIndex();
onCreated(result);
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
@ -81,8 +172,8 @@
}
</script>
<form onsubmit={submit} class="card" aria-labelledby="new-h">
<h2 id="new-h">Ny vinteraktivitet</h2>
<form onsubmit={submit} class="card" aria-labelledby="form-h">
<h2 id="form-h">{isEdit ? 'Rediger aktivitet' : 'Ny vinteraktivitet'}</h2>
<label for="vis">Synlighet</label>
<select id="vis" bind:value={visibility}>
@ -90,6 +181,16 @@
<option value="semi">Halv-offentlig (uten navn)</option>
<option value="public">Offentlig (med navn)</option>
</select>
{#if isEdit && originalVisibility !== null && originalVisibility !== visibility}
<p class="muted" style="margin-top: 0.25rem;">
Bytter synlighet fra <em>{originalVisibility}</em> til <em>{visibility}</em> ved lagring.
{#if visibility === 'private'}
Innholdet krypteres lokalt før det sendes.
{:else if originalVisibility === 'private'}
Innholdet sendes som klartekst etter dekryptering lokalt.
{/if}
</p>
{/if}
<label for="title">Tittel</label>
<input id="title" type="text" bind:value={title} required />
@ -110,7 +211,7 @@
<div class="row" style="margin-top: 1rem;">
<button class="primary" type="submit" disabled={busy}>
{busy ? 'Lagrer …' : 'Legg til'}
{busy ? 'Lagrer …' : isEdit ? 'Lagre endringer' : 'Legg til'}
</button>
{#if onCancel}
<button type="button" onclick={onCancel}>Avbryt</button>

View file

@ -10,34 +10,28 @@
interface Props {
activity: Activity;
/** Pre-decrypted payload for private rows (passed in by Home to avoid
* double-decrypting once per render). Null for non-private rows. */
privateCleartext?: PrivatePayload | null;
onDeleted: (id: string) => void;
onEdit?: (a: Activity) => void;
}
let { activity, onDeleted }: Props = $props();
let { activity, privateCleartext = null, onDeleted, onEdit }: 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;
}
// Fallback decrypt for the case where Home hasn't pre-computed yet (e.g.
// immediately after a create) — we tolerate redundant work to keep the
// contract simple.
const decrypted: PrivatePayload | null = $derived.by(() => {
if (activity.visibility !== 'private') return null;
if (privateCleartext) return privateCleartext;
if (!session.dek) return null;
try {
decrypted = decryptPayload(
{
ciphertext: base64ToBytes(activity.ciphertext),
nonce: base64ToBytes(activity.nonce),
},
return decryptPayload(
{ ciphertext: base64ToBytes(activity.ciphertext), nonce: base64ToBytes(activity.nonce) },
session.dek,
);
decryptError = null;
} catch (e) {
decryptError = 'Klarte ikke å dekryptere denne oppføringen.';
} catch {
return null;
}
});
@ -50,15 +44,62 @@
});
}
/**
* OpenStreetMap link for a location.
* - If coordinates are present → /?mlat=…&mlon=…&zoom=15 (shows a pin)
* - Else → /search?query=… (shows search results for the label)
*
* We deliberately build the URL with the WHATWG URL constructor so the query
* string is correctly percent-encoded; manual concat would break on commas
* or Norwegian characters in place names.
*/
function osmLink(label: string, lat: number | null, lng: number | null): string {
if (typeof lat === 'number' && typeof lng === 'number') {
const u = new URL('https://www.openstreetmap.org/');
u.searchParams.set('mlat', String(lat));
u.searchParams.set('mlon', String(lng));
u.hash = `map=15/${lat}/${lng}`;
return u.toString();
}
const u = new URL('https://www.openstreetmap.org/search');
u.searchParams.set('query', label);
return u.toString();
}
// Authz mirrors server/activities.ts:
// - private: owner-only (server only returns yours anyway)
// - public: owner OR moderator
// - semi: owner OR moderator (but UI can't tell ownership from the
// row because owner_id isn't serialised; we expose Delete only
// to moderators; owners must use server-side ownership check
// when they hit it — but in this UI we can't show a per-row
// "you own this" hint without leaking. Acceptable tradeoff
// for now; semi-owner-delete still works via Edit path that
// the server authorises).
const isOwner = $derived(
activity.visibility !== 'semi'
activity.visibility === 'public'
? session.user?.id === activity.owner_id
: false, // semi never serializes owner; we can't tell from the row alone
: activity.visibility === 'private'
? true
: false,
);
const isModerator = $derived(session.user?.is_moderator === true);
const canEdit = $derived(isOwner); // editing always requires ownership
const canDelete = $derived(
activity.visibility === 'private'
? true
: isOwner || isModerator,
);
// Build a normalised "for-editing" copy so the form receives the canonical
// Activity shape (the form decides whether the payload is private or not).
function startEdit() {
if (!onEdit) return;
onEdit(activity);
}
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);
}
@ -67,6 +108,18 @@
}
</script>
{#snippet locationLine(label: string, lat: number | null, lng: number | null)}
<p class="muted">
📍
<a
href={osmLink(label, lat, lng)}
target="_blank"
rel="noopener noreferrer"
aria-label={`Vis ${label} på OpenStreetMap`}
>{label}</a>
</p>
{/snippet}
<article class="card" aria-labelledby={`act-${activity.id}-h`}>
{#if activity.visibility === 'private'}
{#if decrypted}
@ -79,10 +132,10 @@
{#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.loc_label}
{@render locationLine(decrypted.loc_label, null, null)}
{/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}
@ -98,16 +151,23 @@
{#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.loc_label}
{@render locationLine(activity.loc_label, activity.loc_lat, activity.loc_lng)}
{/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>
<p class="muted" style="font-size: 0.8rem;">Lagt til av {activity.owner_display}</p>
{/if}
{/if}
{#if isOwner || activity.visibility === 'private'}
{#if canEdit || canDelete}
<div class="row" style="margin-top: 0.5rem;">
<button class="danger" type="button" onclick={del}>Slett</button>
{#if canEdit && onEdit}
<button type="button" onclick={startEdit}>Rediger</button>
{/if}
{#if canDelete}
<button class="danger" type="button" onclick={del}>Slett</button>
{/if}
</div>
{/if}
</article>

View file

@ -0,0 +1,130 @@
<script lang="ts">
import { api, ApiError } from '../lib/api';
import { session } from '../lib/session.svelte';
import type { FeedbackEntry, FeedbackKind } from '../../../shared/types';
interface Props {
onDone: () => void;
}
let { onDone }: Props = $props();
// --- Submit form (any logged-in user) ------------------------------------
let kind: FeedbackKind = $state('feature');
let body = $state('');
let busy = $state(false);
let error: string | null = $state(null);
let sent = $state(false);
async function submit(e: SubmitEvent) {
e.preventDefault();
error = null;
sent = false;
if (!body.trim()) {
error = 'Skriv noe i meldingen.';
return;
}
busy = true;
try {
await api.submitFeedback({ kind, body: body.trim() });
body = '';
sent = true;
// If we're a moderator, refresh the list so the new entry shows.
if (session.user?.is_moderator) await refreshList();
} catch (err) {
error = err instanceof ApiError ? `Sending feilet (${err.status}).` : 'Sending feilet.';
} finally {
busy = false;
}
}
// --- Moderator-only list -------------------------------------------------
let entries: FeedbackEntry[] = $state([]);
let listError: string | null = $state(null);
let listLoading = $state(false);
async function refreshList() {
if (!session.user?.is_moderator) return;
listLoading = true;
listError = null;
try {
entries = await api.listFeedback();
} catch (err) {
listError = err instanceof ApiError && err.status === 403
? 'Bare moderatorer kan se listen.'
: 'Kunne ikke laste tilbakemeldinger.';
} finally {
listLoading = false;
}
}
// Auto-load for moderators when component mounts.
$effect(() => {
if (session.user?.is_moderator) refreshList();
});
function formatDate(epochMs: number): string {
return new Date(epochMs).toLocaleString('nb-NO', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
});
}
</script>
<section aria-label="Tilbakemelding">
<div class="row" style="justify-content: space-between; margin-bottom: 1rem;">
<h2 style="margin: 0;">Tilbakemelding</h2>
<button type="button" onclick={onDone}>Tilbake</button>
</div>
<form onsubmit={submit} class="card" aria-labelledby="fb-h">
<h3 id="fb-h">Send inn</h3>
<p class="muted">Forslag eller feilrapporter går til moderator. Sendingen er ikke anonym — vi ser hvem som sender.</p>
<label for="fb-kind">Type</label>
<select id="fb-kind" bind:value={kind}>
<option value="feature">Forslag</option>
<option value="bug">Feilrapport</option>
</select>
<label for="fb-body">Beskrivelse</label>
<textarea id="fb-body" bind:value={body} rows="5"
placeholder="Beskriv kort hva du tenker eller hva som gikk galt"
required maxlength="4000"></textarea>
{#if error}<p class="error" role="alert">{error}</p>{/if}
{#if sent}<p class="muted" role="status">Takk, vi har mottatt det.</p>{/if}
<div class="row" style="margin-top: 0.75rem;">
<button class="primary" type="submit" disabled={busy}>
{busy ? 'Sender …' : 'Send'}
</button>
</div>
</form>
{#if session.user?.is_moderator}
<section class="card" aria-labelledby="fb-list-h">
<div class="row" style="justify-content: space-between;">
<h3 id="fb-list-h" style="margin: 0;">Innkommet ({entries.length})</h3>
<button type="button" onclick={refreshList} disabled={listLoading}>
{listLoading ? 'Laster …' : 'Oppdater'}
</button>
</div>
{#if listError}<p class="error">{listError}</p>{/if}
{#if !entries.length && !listLoading && !listError}
<p class="muted">Ingen tilbakemeldinger ennå.</p>
{/if}
{#each entries as e (e.id)}
<article class="card" style="margin-top: 0.5rem;">
<div class="row" style="justify-content: space-between;">
<strong>{e.kind === 'feature' ? 'Forslag' : 'Feilrapport'}</strong>
<span class="muted">{formatDate(e.created_at)}</span>
</div>
<p style="white-space: pre-wrap; margin: 0.5rem 0;">{e.body}</p>
<p class="muted" style="font-size: 0.85rem;">
Fra {e.user_display?.trim() || e.user_email}
</p>
</article>
{/each}
</section>
{/if}
</section>

View file

@ -2,6 +2,9 @@
import { onMount } from 'svelte';
import { api } from '../lib/api';
import { session } from '../lib/session.svelte';
import {
decryptPayload, base64ToBytes, type PrivatePayload,
} from '../lib/crypto';
import ActivityForm from './ActivityForm.svelte';
import ActivityRow from './ActivityRow.svelte';
import type { Activity } from '../../../shared/types';
@ -9,7 +12,9 @@
let activities: Activity[] = $state([]);
let loading = $state(true);
let showForm = $state(false);
let editing: Activity | null = $state(null);
let error: string | null = $state(null);
let query = $state('');
onMount(load);
@ -29,29 +34,97 @@
showForm = false;
}
function onUpdated(a: Activity) {
activities = activities.map((x) => (x.id === a.id ? a : x));
editing = null;
}
function onDeleted(id: string) {
activities = activities.filter((a) => a.id !== id);
}
/**
* Pre-decrypt private payloads once per activity so search can match against
* the cleartext. We can't avoid this — search has to see what the user sees.
* The cache lives in JS memory alongside the DEK and is dropped on logout.
*/
const privateCleartext = $derived.by(() => {
const map = new Map<string, PrivatePayload>();
if (!session.dek) return map;
for (const a of activities) {
if (a.visibility !== 'private') continue;
try {
map.set(a.id, decryptPayload(
{ ciphertext: base64ToBytes(a.ciphertext), nonce: base64ToBytes(a.nonce) },
session.dek,
));
} catch {
// skip — the row will show an error inline
}
}
return map;
});
function matchesQuery(a: Activity, q: string): boolean {
if (!q) return true;
const needle = q.toLowerCase();
if (a.visibility === 'private') {
const p = privateCleartext.get(a.id);
if (!p) return false;
return [p.title, p.loc_label ?? '', ...p.tags]
.some((s) => s.toLowerCase().includes(needle));
}
return [
a.title,
a.loc_label ?? '',
...a.tags,
a.visibility === 'public' ? a.owner_display : '',
].some((s) => s.toLowerCase().includes(needle));
}
const filtered = $derived(activities.filter((a) => matchesQuery(a, query)));
// 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'));
const myPrivate = $derived(filtered.filter((a) => a.visibility === 'private'));
const semi = $derived(filtered.filter((a) => a.visibility === 'semi'));
const pub = $derived(filtered.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.
Velkommen, {session.user?.display_name?.trim() || session.user?.email}.
Her er aktivitetene dine for vinteren.
</p>
{#if !showForm}
{#if !showForm && !editing}
<button class="primary" onclick={() => (showForm = true)}>Ny aktivitet</button>
{/if}
</div>
<label for="search" class="muted" style="display: block; margin-bottom: 0.25rem;">Søk</label>
<input
id="search"
type="search"
placeholder="Søk i titler, etiketter, sted, forfatter …"
bind:value={query}
aria-label="Søk i aktiviteter"
/>
{#if showForm}
<ActivityForm onCreated={onCreated} onCancel={() => (showForm = false)} />
<div style="margin-top: 1rem;">
<ActivityForm onCreated={onCreated} onCancel={() => (showForm = false)} />
</div>
{/if}
{#if editing}
<div style="margin-top: 1rem;">
<ActivityForm
existing={editing}
onCreated={onUpdated}
onCancel={() => (editing = null)}
/>
</div>
{/if}
{#if loading}
@ -62,26 +135,45 @@
{#if myPrivate.length}
<h2>Dine private</h2>
{#each myPrivate as a (a.id)}
<ActivityRow activity={a} onDeleted={onDeleted} />
<ActivityRow
activity={a}
privateCleartext={privateCleartext.get(a.id) ?? null}
onDeleted={onDeleted}
onEdit={(act) => (editing = act)}
/>
{/each}
{/if}
{#if semi.length}
<h2>Anonyme</h2>
{#each semi as a (a.id)}
<ActivityRow activity={a} onDeleted={onDeleted} />
<ActivityRow
activity={a}
privateCleartext={null}
onDeleted={onDeleted}
onEdit={(act) => (editing = act)}
/>
{/each}
{/if}
{#if pub.length}
<h2>Offentlige</h2>
{#each pub as a (a.id)}
<ActivityRow activity={a} onDeleted={onDeleted} />
<ActivityRow
activity={a}
privateCleartext={null}
onDeleted={onDeleted}
onEdit={(act) => (editing = act)}
/>
{/each}
{/if}
{#if !myPrivate.length && !semi.length && !pub.length}
<p class="muted">Ingen aktiviteter ennå. Trykk «Ny aktivitet» for å starte.</p>
{#if query}
<p class="muted">Ingen treff på «{query}».</p>
{:else}
<p class="muted">Ingen aktiviteter ennå. Trykk «Ny aktivitet» for å starte.</p>
{/if}
{/if}
{/if}
</section>

View file

@ -0,0 +1,170 @@
<script lang="ts">
import { api, ApiError } from '../lib/api';
import { changePassword } from '../lib/auth';
import { session } from '../lib/session.svelte';
interface Props {
onDone: () => void;
}
let { onDone }: Props = $props();
// --- Display name + username + opt-in toggle ------------------------------
// svelte-ignore state_referenced_locally
let displayName = $state(session.user?.display_name ?? '');
// svelte-ignore state_referenced_locally
let username = $state(session.user?.username ?? '');
// svelte-ignore state_referenced_locally
let publicListEnabled = $state(session.user?.public_list_enabled ?? false);
let displaySaving = $state(false);
let displayError: string | null = $state(null);
let displaySaved = $state(false);
async function saveDisplay(e: SubmitEvent) {
e.preventDefault();
displayError = null;
displaySaved = false;
displaySaving = true;
try {
const next = await api.updateProfile({
display_name: displayName.trim() ? displayName.trim() : null,
username: username.trim() ? username.trim().toLowerCase() : null,
public_list_enabled: publicListEnabled,
});
if (session.user) {
session.user.display_name = next.display_name;
session.user.username = next.username;
session.user.public_list_enabled = next.public_list_enabled;
}
displaySaved = true;
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
displayError = 'Brukernavnet er allerede tatt.';
} else if (err instanceof ApiError && err.status === 400) {
displayError = 'Brukernavn må være 231 tegn: az, 09, _ eller -.';
} else {
displayError = 'Lagring feilet.';
}
} finally {
displaySaving = false;
}
}
// --- Password change ------------------------------------------------------
let oldPw = $state('');
let newPw = $state('');
let confirmPw = $state('');
let pwError: string | null = $state(null);
let pwSaving = $state(false);
let pwChanged = $state(false);
async function savePassword(e: SubmitEvent) {
e.preventDefault();
pwError = null;
pwChanged = false;
if (newPw.length < 12) {
pwError = 'Det nye passordet må være minst 12 tegn.';
return;
}
if (newPw !== confirmPw) {
pwError = 'Passordene må være like.';
return;
}
if (!session.user) {
pwError = 'Du er ikke logget inn.';
return;
}
pwSaving = true;
try {
await changePassword(oldPw, newPw, session.user.email);
oldPw = '';
newPw = '';
confirmPw = '';
pwChanged = true;
} catch (err) {
// Wrong old password → libsodium decrypt failure (server has no way to
// surface this; the local unwrap is the gate).
if (err instanceof Error && err.message.toLowerCase().includes('decrypt')) {
pwError = 'Feil nåværende passord.';
} else {
pwError = 'Passordbytte feilet.';
}
} finally {
pwSaving = false;
}
}
</script>
<section aria-label="Profil">
<div class="row" style="justify-content: space-between; margin-bottom: 1rem;">
<h2 style="margin: 0;">Profil</h2>
<button type="button" onclick={onDone}>Tilbake</button>
</div>
<form onsubmit={saveDisplay} class="card" aria-labelledby="dn-h">
<h3 id="dn-h">Profil</h3>
<label for="dn">Visningsnavn</label>
<input id="dn" type="text" maxlength="50" bind:value={displayName}
placeholder="f.eks. Ole" />
<p class="muted" style="margin: 0.25rem 0 0.5rem;">
Vises på offentlige aktiviteter du legger til. La være tomt for å bruke
delen av eposten din før <code>@</code>.
</p>
<label for="un">Brukernavn for URL</label>
<input id="un" type="text" maxlength="31" bind:value={username}
placeholder="f.eks. ole" pattern="[a-z0-9_-]*" />
<p class="muted" style="margin: 0.25rem 0 0.5rem;">
Brukes i adressen <code>/{username.trim() || 'brukernavn'}/list</code>
hvis du skrur på den offentlige listen nedenfor. Små bokstaver, tall,
<code>_</code> og <code>-</code>.
</p>
<label class="row" style="gap: 0.5rem; align-items: center; margin-top: 0.75rem;">
<input type="checkbox" bind:checked={publicListEnabled}
disabled={!username.trim()} />
<span>Vis offentlig liste på <code>/{username.trim() || 'brukernavn'}/list</code></span>
</label>
{#if publicListEnabled && !username.trim()}
<p class="muted">Du må sette et brukernavn først.</p>
{/if}
{#if displayError}<p class="error" role="alert">{displayError}</p>{/if}
{#if displaySaved}<p class="muted" role="status">Lagret.</p>{/if}
<div class="row" style="margin-top: 0.75rem;">
<button class="primary" type="submit" disabled={displaySaving}>
{displaySaving ? 'Lagrer …' : 'Lagre'}
</button>
</div>
</form>
<form onsubmit={savePassword} class="card" aria-labelledby="pw-h">
<h3 id="pw-h">Bytt passord</h3>
<p class="muted">
Dataene dine forblir kryptert med samme nøkkel — bare innpakningen byttes.
Gjenopprettingskoden virker fortsatt.
</p>
<label for="pw-old">Nåværende passord</label>
<input id="pw-old" type="password" autocomplete="current-password"
bind:value={oldPw} required />
<label for="pw-new">Nytt passord (minst 12 tegn)</label>
<input id="pw-new" type="password" autocomplete="new-password" minlength="12"
bind:value={newPw} required />
<label for="pw-confirm">Bekreft nytt passord</label>
<input id="pw-confirm" type="password" autocomplete="new-password" minlength="12"
bind:value={confirmPw} required />
{#if pwError}<p class="error" role="alert">{pwError}</p>{/if}
{#if pwChanged}<p class="muted" role="status">Passordet er endret.</p>{/if}
<div class="row" style="margin-top: 0.75rem;">
<button class="primary" type="submit" disabled={pwSaving}>
{pwSaving ? 'Bytter …' : 'Bytt passord'}
</button>
</div>
</form>
</section>

View file

@ -0,0 +1,61 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api, ApiError } from '../lib/api';
import ActivityRow from './ActivityRow.svelte';
import type { ActivityPublic } from '../../../shared/types';
interface Props {
username: string;
onBack?: () => void;
}
let { username, onBack }: Props = $props();
let displayName: string | null = $state(null);
let activities: ActivityPublic[] = $state([]);
let loading = $state(true);
let notFound = $state(false);
let error: string | null = $state(null);
onMount(async () => {
try {
const res = await api.publicList(username);
displayName = res.display_name;
activities = res.activities;
} catch (err) {
if (err instanceof ApiError && err.status === 404) notFound = true;
else error = 'Kunne ikke laste listen.';
} finally {
loading = false;
}
});
</script>
<section aria-label="Offentlig liste">
{#if onBack}
<div class="row" style="justify-content: space-between; margin-bottom: 1rem;">
<button type="button" onclick={onBack}> Tilbake</button>
</div>
{/if}
{#if loading}
<p class="muted">Laster …</p>
{:else if notFound}
<div class="card">
<h2>Fant ikke listen</h2>
<p class="muted">
Brukernavnet finnes ikke, eller eieren har ikke skrudd på den offentlige listen.
</p>
</div>
{:else if error}
<p class="error">{error}</p>
{:else}
<h1>{displayName?.trim() || username}</h1>
<p class="muted">Offentlige vinteraktiviteter</p>
{#if !activities.length}
<p class="muted">Ingen offentlige aktiviteter ennå.</p>
{/if}
{#each activities as a (a.id)}
<ActivityRow activity={a} onDeleted={() => {}} />
{/each}
{/if}
</section>

View file

@ -2,7 +2,8 @@ import type {
SignupRequest, ChallengeResponse, LoginRequest,
RecoveryChallengeResponse, RecoveryCompleteRequest, PasswordChangeRequest,
MeResponse, Activity, CreateActivityRequest, UpdateActivityRequest,
TagSuggestion,
TagSuggestion, ProfileUpdateRequest,
PublicListResponse, FeedbackSubmitRequest, FeedbackEntry,
} from '../../../shared/types';
const BASE = '/api';
@ -54,6 +55,10 @@ export const api = {
http<{ ok: true }>('/auth/recovery-complete', {
method: 'POST', body: JSON.stringify(body),
}),
updateProfile: (body: ProfileUpdateRequest) =>
http<MeResponse>('/auth/profile', {
method: 'PATCH', body: JSON.stringify(body),
}),
// --- activities -----------------------------------------------------------
listActivities: () => http<Activity[]>('/activities'),
@ -69,4 +74,13 @@ export const api = {
// --- tags -----------------------------------------------------------------
tagSuggestions: (q: string, limit = 20) =>
http<TagSuggestion[]>(`/tags?q=${encodeURIComponent(q)}&limit=${limit}`),
// --- users (opt-in public list) -------------------------------------------
publicList: (username: string) =>
http<PublicListResponse>(`/users/${encodeURIComponent(username)}/list`),
// --- feedback -------------------------------------------------------------
submitFeedback: (body: FeedbackSubmitRequest) =>
http<FeedbackEntry>('/feedback', { method: 'POST', body: JSON.stringify(body) }),
listFeedback: () => http<FeedbackEntry[]>('/feedback'),
};