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:
parent
add76be486
commit
6f4c11c7a6
16 changed files with 1152 additions and 107 deletions
13
README.md
13
README.md
|
|
@ -126,6 +126,19 @@ 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`.
|
||||
|
||||
## Promoting a moderator
|
||||
|
||||
Moderators can delete any `semi` or `public` activity (not `private` — those
|
||||
aren't visible to anyone else anyway). There's no admin UI; promotion is a
|
||||
one-liner against the SQLite file:
|
||||
|
||||
```bash
|
||||
sqlite3 data/vinterliste.db \
|
||||
"UPDATE users SET is_moderator = 1 WHERE email = 'olemd@example.org';"
|
||||
```
|
||||
|
||||
The user has to log out and back in for `MeResponse.is_moderator` to refresh.
|
||||
|
||||
## Manual verification
|
||||
|
||||
After signing up an account, the spec asks you to inspect a `private` row
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
130
frontend/src/components/Feedback.svelte
Normal file
130
frontend/src/components/Feedback.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
170
frontend/src/components/Profile.svelte
Normal file
170
frontend/src/components/Profile.svelte
Normal 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 2–31 tegn: a–z, 0–9, _ 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>
|
||||
61
frontend/src/components/PublicList.svelte
Normal file
61
frontend/src/components/PublicList.svelte
Normal 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>
|
||||
|
|
@ -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'),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -36,6 +36,29 @@ interface ActivityRow {
|
|||
updated_at: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the public-facing handle for an owner. Prefer `display_name`; fall
|
||||
* back to the part before the `@` in the email so accounts that haven't set
|
||||
* one still get something less hostile than a UUID slice. Email itself is
|
||||
* NOT surfaced — that's a contact identifier, not an attribution.
|
||||
*/
|
||||
function ownerDisplay(ownerId: string): string {
|
||||
const row = getDb()
|
||||
.prepare('SELECT display_name, email FROM users WHERE id = ?')
|
||||
.get(ownerId) as { display_name: string | null; email: string } | null;
|
||||
if (!row) return 'ukjent';
|
||||
if (row.display_name && row.display_name.trim()) return row.display_name;
|
||||
const at = row.email.indexOf('@');
|
||||
return at > 0 ? row.email.slice(0, at) : row.email;
|
||||
}
|
||||
|
||||
function isModerator(userId: string): boolean {
|
||||
const row = getDb()
|
||||
.prepare('SELECT is_moderator FROM users WHERE id = ?')
|
||||
.get(userId) as { is_moderator: number | null } | null;
|
||||
return row?.is_moderator === 1;
|
||||
}
|
||||
|
||||
function b64(b: Uint8Array | null): string | null {
|
||||
return b === null ? null : Buffer.from(b).toString('base64');
|
||||
}
|
||||
|
|
@ -78,6 +101,7 @@ function serialize(row: ActivityRow, viewerId: string | null): Activity {
|
|||
id: row.id,
|
||||
visibility: 'public',
|
||||
owner_id: row.owner_id,
|
||||
owner_display: ownerDisplay(row.owner_id),
|
||||
title: row.title ?? '',
|
||||
tags,
|
||||
loc_label: row.loc_label,
|
||||
|
|
@ -249,14 +273,23 @@ activitiesRoutes.patch('/:id', requireAuth, async (c) => {
|
|||
});
|
||||
|
||||
// --- DELETE /api/activities/:id ---------------------------------------------
|
||||
// Authz:
|
||||
// - private: owner only. Other users can't even see private rows, so
|
||||
// there's no moderation use-case here.
|
||||
// - semi/public: owner OR moderator. Moderation is the documented purpose
|
||||
// of keeping owner_id around on semi (see SECURITY.md).
|
||||
activitiesRoutes.delete('/:id', requireAuth, (c) => {
|
||||
const userId = c.get('userId');
|
||||
const id = c.req.param('id');
|
||||
const db = getDb();
|
||||
const existing = db.prepare('SELECT owner_id FROM activities WHERE id = ?').get(id) as
|
||||
| { owner_id: string } | null;
|
||||
const existing = db
|
||||
.prepare('SELECT owner_id, visibility FROM activities WHERE id = ?')
|
||||
.get(id) as { owner_id: string; visibility: Visibility } | null;
|
||||
if (!existing) return c.json({ error: 'not_found' }, 404);
|
||||
if (existing.owner_id !== userId) return c.json({ error: 'forbidden' }, 403);
|
||||
|
||||
const isOwner = existing.owner_id === userId;
|
||||
const canModerate = existing.visibility !== 'private' && isModerator(userId);
|
||||
if (!isOwner && !canModerate) return c.json({ error: 'forbidden' }, 403);
|
||||
|
||||
clearActivityTags(id);
|
||||
db.prepare('DELETE FROM activities WHERE id = ?').run(id);
|
||||
|
|
|
|||
123
server/auth.ts
123
server/auth.ts
|
|
@ -12,8 +12,59 @@ import type {
|
|||
PasswordChangeRequest,
|
||||
RecoveryCompleteRequest,
|
||||
MeResponse,
|
||||
ProfileUpdateRequest,
|
||||
} from '../shared/types';
|
||||
|
||||
const MAX_DISPLAY_NAME = 50;
|
||||
const USERNAME_RE = /^[a-z0-9][a-z0-9_-]{1,30}$/;
|
||||
|
||||
/**
|
||||
* Build the MeResponse payload from a users row. Centralised so every
|
||||
* endpoint that returns it (signup, login, /me, profile-update) stays in sync.
|
||||
*/
|
||||
function loadMe(userId: string): MeResponse | null {
|
||||
const row = getDb()
|
||||
.prepare(`
|
||||
SELECT id, email, display_name, is_moderator, username, public_list_enabled
|
||||
FROM users WHERE id = ?
|
||||
`)
|
||||
.get(userId) as
|
||||
| {
|
||||
id: string; email: string;
|
||||
display_name: string | null;
|
||||
is_moderator: number | null;
|
||||
username: string | null;
|
||||
public_list_enabled: number | null;
|
||||
}
|
||||
| null;
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
display_name: row.display_name,
|
||||
is_moderator: row.is_moderator === 1,
|
||||
username: row.username,
|
||||
public_list_enabled: row.public_list_enabled === 1,
|
||||
};
|
||||
}
|
||||
|
||||
function normaliseDisplayName(raw: unknown): string | null {
|
||||
if (raw === null) return null;
|
||||
if (typeof raw !== 'string') return null;
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
return trimmed.slice(0, MAX_DISPLAY_NAME);
|
||||
}
|
||||
|
||||
function normaliseUsername(raw: unknown): string | null | 'invalid' {
|
||||
if (raw === null) return null;
|
||||
if (typeof raw !== 'string') return 'invalid';
|
||||
const trimmed = raw.trim().toLowerCase();
|
||||
if (!trimmed) return null;
|
||||
if (!USERNAME_RE.test(trimmed)) return 'invalid';
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth routes. The server's job is narrow:
|
||||
* - store the user row (salts + wraps + verifier hash)
|
||||
|
|
@ -107,7 +158,8 @@ authRoutes.post('/signup', async (c) => {
|
|||
issueSession(c, id);
|
||||
gcSessions();
|
||||
|
||||
const me: MeResponse = { id, email };
|
||||
const me = loadMe(id);
|
||||
if (!me) return c.json({ error: 'internal_error' }, 500);
|
||||
return c.json(me);
|
||||
});
|
||||
|
||||
|
|
@ -160,7 +212,8 @@ authRoutes.post('/login', async (c) => {
|
|||
if (!row || !ok) return c.json({ error: 'invalid_credentials' }, 401);
|
||||
|
||||
issueSession(c, row.id);
|
||||
const me: MeResponse = { id: row.id, email };
|
||||
const me = loadMe(row.id);
|
||||
if (!me) return c.json({ error: 'invalid_credentials' }, 401);
|
||||
return c.json(me);
|
||||
});
|
||||
|
||||
|
|
@ -174,14 +227,70 @@ authRoutes.post('/logout', async (c) => {
|
|||
authRoutes.get('/me', async (c) => {
|
||||
const userId = currentUserId(c);
|
||||
if (!userId) return c.json({ error: 'unauthorized' }, 401);
|
||||
const row = getDb()
|
||||
.prepare('SELECT id, email FROM users WHERE id = ?')
|
||||
.get(userId) as { id: string; email: string } | null;
|
||||
if (!row) {
|
||||
const me = loadMe(userId);
|
||||
if (!me) {
|
||||
clearSession(c);
|
||||
return c.json({ error: 'unauthorized' }, 401);
|
||||
}
|
||||
const me: MeResponse = row;
|
||||
return c.json(me);
|
||||
});
|
||||
|
||||
// --- PATCH /auth/profile ----------------------------------------------------
|
||||
// Edit the editable parts of the user profile. All fields are optional so the
|
||||
// client can update just what changed. Email changes are deliberately out of
|
||||
// scope (login handle; needs a re-verification flow).
|
||||
authRoutes.patch('/profile', requireAuth, async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const body = (await c.req.json().catch(() => null)) as ProfileUpdateRequest | null;
|
||||
if (!body) return c.json({ error: 'invalid_json' }, 400);
|
||||
|
||||
const db = getDb();
|
||||
const updates: string[] = [];
|
||||
const params: (string | number | null)[] = [];
|
||||
|
||||
if ('display_name' in body) {
|
||||
updates.push('display_name = ?');
|
||||
params.push(normaliseDisplayName(body.display_name));
|
||||
}
|
||||
if ('username' in body) {
|
||||
const next = normaliseUsername(body.username);
|
||||
if (next === 'invalid') {
|
||||
return c.json({
|
||||
error: 'invalid:username',
|
||||
detail: 'lowercase a-z, 0-9, _ or -; 2-31 characters; must start with a letter or digit',
|
||||
}, 400);
|
||||
}
|
||||
if (next !== null) {
|
||||
// Pre-check uniqueness so we can return a clear 409 instead of the
|
||||
// SQLite UNIQUE-constraint error bubbling up as a 500.
|
||||
const taken = db.prepare(
|
||||
'SELECT 1 FROM users WHERE username = ? AND id <> ?',
|
||||
).get(next, userId);
|
||||
if (taken) return c.json({ error: 'username_taken' }, 409);
|
||||
}
|
||||
updates.push('username = ?');
|
||||
params.push(next);
|
||||
}
|
||||
if ('public_list_enabled' in body) {
|
||||
if (typeof body.public_list_enabled !== 'boolean') {
|
||||
return c.json({ error: 'invalid:public_list_enabled' }, 400);
|
||||
}
|
||||
updates.push('public_list_enabled = ?');
|
||||
params.push(body.public_list_enabled ? 1 : 0);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
// No-op update is fine — return current state so the client stays in sync.
|
||||
const me = loadMe(userId);
|
||||
if (!me) return c.json({ error: 'internal_error' }, 500);
|
||||
return c.json(me);
|
||||
}
|
||||
|
||||
params.push(userId);
|
||||
db.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...params);
|
||||
|
||||
const me = loadMe(userId);
|
||||
if (!me) return c.json({ error: 'internal_error' }, 500);
|
||||
return c.json(me);
|
||||
});
|
||||
|
||||
|
|
|
|||
33
server/db.ts
33
server/db.ts
|
|
@ -27,6 +27,20 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
|||
-- See SECURITY.md § "Recovery flow".
|
||||
rec_auth_salt BLOB NOT NULL,
|
||||
rec_auth_verifier_hash TEXT NOT NULL,
|
||||
-- Display name for public attribution. Nullable: a user without one falls
|
||||
-- back to a derived handle (email prefix) in serialised responses.
|
||||
display_name TEXT,
|
||||
-- Moderator flag. Promoted manually via "sqlite3 ... UPDATE" (see README).
|
||||
-- Moderators can delete any semi/public activity for moderation.
|
||||
is_moderator INTEGER NOT NULL DEFAULT 0,
|
||||
-- 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
|
||||
-- (lowercase, [a-z0-9_-]).
|
||||
username TEXT UNIQUE,
|
||||
-- Opt-in flag: gates whether /<username>/list actually returns data.
|
||||
-- Defaults to 0 so the route stays opt-in.
|
||||
public_list_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS activities (
|
||||
|
|
@ -64,6 +78,15 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
|||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS sessions_user_idx ON sessions(user_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS sessions_expires_idx ON sessions(expires_at)`,
|
||||
// Feedback: any logged-in user can submit; moderators read.
|
||||
`CREATE TABLE IF NOT EXISTS feedback (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('feature','bug')),
|
||||
body TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS feedback_created_idx ON feedback(created_at DESC)`,
|
||||
];
|
||||
|
||||
const PRAGMAS: readonly string[] = [
|
||||
|
|
@ -112,6 +135,16 @@ export function getDb(): Database {
|
|||
// Old users will need to re-sign-up to fully use recovery; see CLAUDE.md.
|
||||
ensureColumn(db, 'users', 'rec_auth_salt', 'BLOB');
|
||||
ensureColumn(db, 'users', 'rec_auth_verifier_hash', 'TEXT');
|
||||
// Profile fields. is_moderator gets a default at the SQL level so existing
|
||||
// rows aren't NULL — that would make the role check (`row.is_moderator === 1`)
|
||||
// ambiguous.
|
||||
ensureColumn(db, 'users', 'display_name', 'TEXT');
|
||||
ensureColumn(db, 'users', 'is_moderator', 'INTEGER NOT NULL DEFAULT 0');
|
||||
ensureColumn(db, 'users', 'username', 'TEXT');
|
||||
ensureColumn(db, 'users', 'public_list_enabled', 'INTEGER NOT NULL DEFAULT 0');
|
||||
// UNIQUE index on username via separate CREATE INDEX so the ALTER TABLE
|
||||
// migration path works (SQLite can't add UNIQUE via ADD COLUMN).
|
||||
db.prepare('CREATE UNIQUE INDEX IF NOT EXISTS users_username_idx ON users(username) WHERE username IS NOT NULL').run();
|
||||
|
||||
dbInstance = db;
|
||||
return db;
|
||||
|
|
|
|||
65
server/feedback.ts
Normal file
65
server/feedback.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { Hono } from 'hono';
|
||||
import { getDb } from './db';
|
||||
import { requireAuth, type AppVariables } from './session';
|
||||
import type { FeedbackSubmitRequest, FeedbackEntry } from '../shared/types';
|
||||
|
||||
const MAX_BODY = 4000;
|
||||
|
||||
/**
|
||||
* Feedback endpoints.
|
||||
*
|
||||
* POST /api/feedback — any authenticated user submits
|
||||
* GET /api/feedback — moderators only, returns the whole queue
|
||||
*
|
||||
* Submissions are kept verbatim — they're for the moderator's eyes, not for
|
||||
* display to other users. Moderators see the submitter's email and display
|
||||
* name so they can follow up.
|
||||
*/
|
||||
export const feedbackRoutes = new Hono<{ Variables: AppVariables }>();
|
||||
|
||||
function isModerator(userId: string): boolean {
|
||||
const row = getDb()
|
||||
.prepare('SELECT is_moderator FROM users WHERE id = ?')
|
||||
.get(userId) as { is_moderator: number | null } | null;
|
||||
return row?.is_moderator === 1;
|
||||
}
|
||||
|
||||
feedbackRoutes.post('/', requireAuth, async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const body = (await c.req.json().catch(() => null)) as FeedbackSubmitRequest | null;
|
||||
if (!body) return c.json({ error: 'invalid_json' }, 400);
|
||||
if (body.kind !== 'feature' && body.kind !== 'bug') {
|
||||
return c.json({ error: 'invalid:kind' }, 400);
|
||||
}
|
||||
if (typeof body.body !== 'string' || !body.body.trim()) {
|
||||
return c.json({ error: 'missing:body' }, 400);
|
||||
}
|
||||
const trimmed = body.body.trim().slice(0, MAX_BODY);
|
||||
const id = crypto.randomUUID();
|
||||
getDb()
|
||||
.prepare(
|
||||
'INSERT INTO feedback (id, user_id, kind, body, created_at) VALUES (?, ?, ?, ?, ?)',
|
||||
)
|
||||
.run(id, userId, body.kind, trimmed, Date.now());
|
||||
|
||||
const entry: FeedbackEntry = {
|
||||
id, kind: body.kind, body: trimmed, created_at: Date.now(),
|
||||
};
|
||||
return c.json(entry, 201);
|
||||
});
|
||||
|
||||
feedbackRoutes.get('/', requireAuth, (c) => {
|
||||
const userId = c.get('userId');
|
||||
if (!isModerator(userId)) return c.json({ error: 'forbidden' }, 403);
|
||||
|
||||
const rows = getDb()
|
||||
.prepare(`
|
||||
SELECT f.id, f.kind, f.body, f.created_at,
|
||||
f.user_id, u.email AS user_email, u.display_name AS user_display
|
||||
FROM feedback f
|
||||
JOIN users u ON u.id = f.user_id
|
||||
ORDER BY f.created_at DESC
|
||||
`)
|
||||
.all() as FeedbackEntry[];
|
||||
return c.json(rows);
|
||||
});
|
||||
|
|
@ -4,6 +4,8 @@ import { getDb } from './db';
|
|||
import { authRoutes } from './auth';
|
||||
import { activitiesRoutes } from './activities';
|
||||
import { tagsRoutes } from './tags';
|
||||
import { usersRoutes } from './users';
|
||||
import { feedbackRoutes } from './feedback';
|
||||
|
||||
// Initialise DB up front so the server fails fast on schema problems.
|
||||
getDb();
|
||||
|
|
@ -23,6 +25,8 @@ app.get('/api/health', (c) =>
|
|||
app.route('/api/auth', authRoutes);
|
||||
app.route('/api/activities', activitiesRoutes);
|
||||
app.route('/api/tags', tagsRoutes);
|
||||
app.route('/api/users', usersRoutes);
|
||||
app.route('/api/feedback', feedbackRoutes);
|
||||
|
||||
// In production, serve the built Svelte SPA. Hono's bun static helper handles
|
||||
// asset MIME types; everything else falls through to index.html for SPA routing.
|
||||
|
|
|
|||
73
server/users.ts
Normal file
73
server/users.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { Hono } from 'hono';
|
||||
import { getDb } from './db';
|
||||
import { tagsFor } from './tags';
|
||||
import type { PublicListResponse, ActivityPublic } from '../shared/types';
|
||||
|
||||
/**
|
||||
* Opt-in per-user public lists. A user with both a `username` and
|
||||
* `public_list_enabled = 1` exposes their public activities at
|
||||
* `/api/users/:username/list`. Otherwise we 404 — the route refuses to leak
|
||||
* whether a username exists when the list isn't enabled.
|
||||
*/
|
||||
export const usersRoutes = new Hono();
|
||||
|
||||
interface ActivityRow {
|
||||
id: string;
|
||||
owner_id: string;
|
||||
title: string | null;
|
||||
scheduled_at: number | null;
|
||||
loc_label: string | null;
|
||||
loc_lat: number | null;
|
||||
loc_lng: number | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
usersRoutes.get('/:username/list', (c) => {
|
||||
const username = c.req.param('username').toLowerCase();
|
||||
const db = getDb();
|
||||
|
||||
const user = db
|
||||
.prepare('SELECT id, display_name, public_list_enabled FROM users WHERE username = ?')
|
||||
.get(username) as
|
||||
| { id: string; display_name: string | null; public_list_enabled: number | null }
|
||||
| null;
|
||||
|
||||
// Either the user doesn't exist, or they haven't opted in. Same response so
|
||||
// we don't leak existence.
|
||||
if (!user || user.public_list_enabled !== 1) {
|
||||
return c.json({ error: 'not_found' }, 404);
|
||||
}
|
||||
|
||||
const rows = db
|
||||
.prepare(`
|
||||
SELECT id, owner_id, title, scheduled_at, loc_label, loc_lat, loc_lng,
|
||||
created_at, updated_at
|
||||
FROM activities
|
||||
WHERE owner_id = ? AND visibility = 'public'
|
||||
ORDER BY created_at DESC
|
||||
`)
|
||||
.all(user.id) as ActivityRow[];
|
||||
|
||||
const activities: ActivityPublic[] = rows.map((r) => ({
|
||||
id: r.id,
|
||||
visibility: 'public',
|
||||
owner_id: r.owner_id,
|
||||
owner_display: user.display_name?.trim() || username,
|
||||
title: r.title ?? '',
|
||||
tags: tagsFor(r.id),
|
||||
loc_label: r.loc_label,
|
||||
loc_lat: r.loc_lat,
|
||||
loc_lng: r.loc_lng,
|
||||
scheduled_at: r.scheduled_at,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
}));
|
||||
|
||||
const resp: PublicListResponse = {
|
||||
username,
|
||||
display_name: user.display_name,
|
||||
activities,
|
||||
};
|
||||
return c.json(resp);
|
||||
});
|
||||
|
|
@ -70,13 +70,53 @@ export interface RecoveryCompleteRequest {
|
|||
export interface MeResponse {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name: string | null;
|
||||
is_moderator: boolean;
|
||||
username: string | null;
|
||||
public_list_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface ProfileUpdateRequest {
|
||||
// All optional — omit a field to leave it alone. Pass `null` to clear.
|
||||
display_name?: string | null;
|
||||
username?: string | null;
|
||||
public_list_enabled?: boolean;
|
||||
}
|
||||
|
||||
/** Response shape for GET /api/users/:username/list (opt-in public list). */
|
||||
export interface PublicListResponse {
|
||||
username: string;
|
||||
display_name: string | null;
|
||||
activities: ActivityPublic[];
|
||||
}
|
||||
|
||||
// --- Feedback ---------------------------------------------------------------
|
||||
export type FeedbackKind = 'feature' | 'bug';
|
||||
|
||||
export interface FeedbackSubmitRequest {
|
||||
kind: FeedbackKind;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface FeedbackEntry {
|
||||
id: string;
|
||||
kind: FeedbackKind;
|
||||
body: string;
|
||||
created_at: number;
|
||||
// Moderator-only fields; included when the caller is a moderator viewing
|
||||
// the list. (The submit endpoint doesn't return these — a submitter doesn't
|
||||
// need to see them.)
|
||||
user_id?: string;
|
||||
user_email?: string;
|
||||
user_display?: string | null;
|
||||
}
|
||||
|
||||
// --- Activities --------------------------------------------------------------
|
||||
export interface ActivityPublic {
|
||||
id: string;
|
||||
visibility: 'public';
|
||||
owner_id: string; // serialized for public
|
||||
owner_id: string; // serialized for public
|
||||
owner_display: string; // display_name OR derived handle (email prefix)
|
||||
title: string;
|
||||
tags: string[];
|
||||
loc_label: string | null;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue