Admin role, root/home URL split, activity permalinks

Three related changes.

1. **Admin role.** New `is_admin INTEGER NOT NULL DEFAULT 0` column on
   users; added to MeResponse. Admin strictly implies moderator —
   shared/roles.ts has a single isModerator()/isAdmin() pair so the
   implication can't drift between callers. The duplicated isModerator()
   helpers in server/activities.ts and server/feedback.ts now import
   from there.

   /api/admin endpoints (admin-only):
     GET   /admin/users           — list users with their roles
     PATCH /admin/users/:id/role  — set is_moderator and/or is_admin

   Last-admin guard: the role-update endpoint refuses to demote the only
   remaining admin (409 cannot_demote_last_admin). Bootstrap is via
   `sqlite3 ... UPDATE users SET is_admin=1` — documented in README.

   Frontend Admin.svelte: table of users with toggles for moderator and
   admin. Visible from the nav only when the current user is admin.
   Toggling our own role refreshes session.user so the nav adapts
   immediately.

2. **Root/home split.** The URL `/` always shows the public landing
   (public + semi activities), even when the user is logged in. `/home`
   is the authenticated dashboard. After login or signup the SPA pushes
   `/home`; after logout it pushes `/`. popstate is wired so the
   back/forward buttons work. Unknown paths fall through to the public
   landing, not a 404.

3. **Activity permalinks at /a/:id.** New SPA route renders a single
   activity via the existing GET /api/activities/:id endpoint (private
   rows still require the owner's session to decrypt). A "Del" button
   on each ActivityRow copies the absolute permalink to the clipboard.
   Clipboard API has a prompt() fallback for environments where it's
   blocked.

Server changes minimal: server/admin.ts is the new file; server/roles.ts
is the lifted helper; server/index.ts wires the admin routes; server/db.ts
gets one more ensureColumn() line.

26 tests still pass; typecheck clean; Vite build succeeds. Bundle grew
from 28.6 KB gzipped to 30.2 KB reflecting the Admin + permalink views.
This commit is contained in:
Ole-Morten Duesund 2026-05-25 13:23:13 +02:00
commit bd82f71a01
16 changed files with 573 additions and 80 deletions

View file

@ -11,77 +11,139 @@
import Profile from './components/Profile.svelte';
import Feedback from './components/Feedback.svelte';
import PublicList from './components/PublicList.svelte';
type View = 'login' | 'signup' | 'recovery' | 'home' | 'profile' | 'feedback' | 'public-list' | 'loading';
let view: View = $state('loading');
let publicListUsername = $state('');
let defaultEmail: string = $state('');
import Admin from './components/Admin.svelte';
import ActivityPermalink from './components/ActivityPermalink.svelte';
/**
* 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.
* URL contract:
* / — always the public landing (anyone), even when logged in
* /home — authenticated dashboard (private+semi+public)
* /a/:id — permalink to a single activity (any visibility)
* /<username>/list — opt-in public list for that user
*
* 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.
* Anything else (signup, login, recovery, profile, feedback, admin) is an
* in-app view state, not a URL. We update window.history on view changes
* for the URL-backed views so reload / back-button do something sensible.
*/
function parsePath(): { username: string } | null {
type View =
| 'loading'
| 'login' | 'signup' | 'recovery'
| 'public-home' // "/" — public/semi only, anyone
| 'home' // "/home" — full authenticated dashboard
| 'profile' | 'feedback' | 'admin'
| 'public-list' // "/<username>/list"
| 'permalink'; // "/a/:id"
let view: View = $state('loading');
let publicListUsername = $state('');
let activityId = $state('');
let defaultEmail = $state('');
interface Route {
view: 'public-home' | 'home' | 'public-list' | 'permalink';
payload?: string;
}
function parsePath(): Route {
const path = window.location.pathname;
const m = path.match(/^\/([a-z0-9_-]{2,31})\/list\/?$/);
return m ? { username: m[1]! } : null;
if (path === '/' || path === '') return { view: 'public-home' };
if (path === '/home' || path === '/home/') return { view: 'home' };
const userList = path.match(/^\/([a-z0-9_-]{2,31})\/list\/?$/);
if (userList) return { view: 'public-list', payload: userList[1] };
const perma = path.match(/^\/a\/([A-Za-z0-9-]+)\/?$/);
if (perma) return { view: 'permalink', payload: perma[1] };
// Unknown path: treat as the public landing rather than 404. The server
// returns index.html for anything non-API anyway, so this keeps deep
// links from looking broken.
return { view: 'public-home' };
}
function pushUrl(path: string) {
if (window.location.pathname !== path) {
window.history.pushState({}, '', path);
}
}
onMount(async () => {
await ready();
window.addEventListener('popstate', () => {
const route = parsePath();
applyRoute(route);
});
const route = parsePath();
if (route) {
publicListUsername = route.username;
view = 'public-list';
return;
if (route.view === 'public-list' && route.payload) {
publicListUsername = route.payload;
}
if (route.view === 'permalink' && route.payload) {
activityId = route.payload;
}
// Probe the server for an existing session, but it doesn't change which
// URL we're rendering — only what content we'd show on /home.
try {
const me = await api.me();
// We have an active server session but no DEK — the user reloaded the
// page. Drop the stale server session and pre-fill their email on the
// login form, but otherwise show the same public landing as any other
// logged-out visitor. They can hit "Logg inn" when they want to unlock.
defaultEmail = me.email;
// Reloaded with a server session but no DEK. Drop the server session;
// we can't decrypt anything without the password anyway.
await api.logout();
view = 'home';
} catch {
// No session (or expired). Show the public landing.
view = 'home';
// No session — fine.
}
applyRoute(route);
});
function onAuthed() {
// After authenticating from a deep link, return to "/".
if (window.location.pathname !== '/') {
window.history.replaceState({}, '', '/');
function applyRoute(route: Route) {
if (route.view === 'public-home') view = 'public-home';
else if (route.view === 'home') view = session.user ? 'home' : 'login';
else if (route.view === 'public-list') {
publicListUsername = route.payload ?? publicListUsername;
view = 'public-list';
} else if (route.view === 'permalink') {
activityId = route.payload ?? activityId;
view = 'permalink';
}
}
function onAuthed() {
pushUrl('/home');
view = 'home';
}
async function onLogout() {
await logout();
view = 'login';
pushUrl('/');
view = 'public-home';
}
function leavePublicList() {
window.history.replaceState({}, '', '/');
view = session.user ? 'home' : 'login';
function goHome() {
pushUrl('/home');
view = 'home';
}
function goPublicHome() {
pushUrl('/');
view = 'public-home';
}
</script>
<main>
<nav class="top">
<h1 style="margin: 0;">Vinterliste</h1>
{#if view !== 'public-list'}
<h1 style="margin: 0;">
<a href="/" onclick={(e) => { e.preventDefault(); goPublicHome(); }}
style="color: inherit; text-decoration: none;">Vinterliste</a>
</h1>
{#if view !== 'public-list' && view !== 'permalink'}
<div class="row">
{#if session.user}
{#if view !== 'home'}
<button type="button" onclick={goHome}>Min liste</button>
{/if}
{#if session.user.is_admin}
<button type="button" onclick={() => (view = 'admin')}>Admin</button>
{/if}
<button type="button" onclick={() => (view = 'feedback')}
aria-label="Send tilbakemelding">
Tilbakemelding
@ -103,7 +165,11 @@
{#if view === 'loading'}
<p class="muted">Laster …</p>
{:else if view === 'public-list'}
<PublicList username={publicListUsername} onBack={leavePublicList} />
<PublicList username={publicListUsername} onBack={goPublicHome} />
{:else if view === 'permalink'}
<ActivityPermalink id={activityId} onBack={goPublicHome} />
{:else if view === 'public-home'}
<Home publicOnly={true} />
{:else if view === 'login'}
<Login
defaultEmail={defaultEmail}
@ -114,15 +180,14 @@
{:else if view === 'signup'}
<Signup onAuthed={onAuthed} onWantLogin={() => (view = 'login')} />
{:else if view === 'recovery'}
<Recovery
onAuthed={onAuthed}
onWantLogin={() => (view = 'login')}
/>
<Recovery onAuthed={onAuthed} onWantLogin={() => (view = 'login')} />
{:else if view === 'profile'}
<Profile onDone={() => (view = 'home')} />
<Profile onDone={goHome} />
{:else if view === 'feedback'}
<Feedback onDone={() => (view = 'home')} />
<Feedback onDone={goHome} />
{:else if view === 'admin'}
<Admin onDone={goHome} />
{:else}
<Home />
<Home publicOnly={false} />
{/if}
</main>

View file

@ -0,0 +1,57 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api, ApiError } from '../lib/api';
import ActivityRow from './ActivityRow.svelte';
import type { Activity } from '../../../shared/types';
interface Props {
id: string;
onBack?: () => void;
}
let { id, onBack }: Props = $props();
let activity: Activity | null = $state(null);
let loading = $state(true);
let notFound = $state(false);
let error: string | null = $state(null);
onMount(async () => {
try {
activity = await api.getActivity(id);
} catch (err) {
if (err instanceof ApiError && err.status === 404) notFound = true;
else error = 'Kunne ikke laste aktiviteten.';
} finally {
loading = false;
}
});
function onDeleted() {
activity = null;
if (onBack) onBack();
}
</script>
<section aria-label="Aktivitet">
{#if onBack}
<div class="row" style="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 aktiviteten</h2>
<p class="muted">
Lenken peker ikke til noen aktivitet — den kan være slettet, eller
privat og bare synlig for eieren.
</p>
</div>
{:else if error}
<p class="error">{error}</p>
{:else if activity}
<ActivityRow activity={activity} onDeleted={onDeleted} />
{/if}
</section>

View file

@ -103,6 +103,25 @@
await api.deleteActivity(activity.id);
onDeleted(activity.id);
}
/**
* Share-link: copy /a/<id> (absolute URL) to the clipboard. Private rows
* are shareable in principle but only the owner can decrypt — the receiver
* sees a "private, can't view" message. We still show the button so the
* owner can save the link.
*/
let copiedAt: number | null = $state(null);
async function copyPermalink() {
const url = `${window.location.origin}/a/${activity.id}`;
try {
await navigator.clipboard.writeText(url);
copiedAt = Date.now();
setTimeout(() => { copiedAt = null; }, 1500);
} catch {
// Fallback: open a prompt so the user can copy manually.
window.prompt('Kopier denne lenken:', url);
}
}
</script>
{#snippet locationLine(label: string, lat: number | null, lng: number | null)}
@ -164,14 +183,16 @@
{/if}
{/if}
{#if canEdit || canDelete}
<div class="row" style="margin-top: 0.5rem;">
{#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}
<div class="row" style="margin-top: 0.5rem;">
{#if canEdit && onEdit}
<button type="button" onclick={startEdit}>Rediger</button>
{/if}
{#if canDelete}
<button class="danger" type="button" onclick={del}>Slett</button>
{/if}
<button type="button" onclick={copyPermalink}
aria-label="Kopier lenke til denne aktiviteten">
{copiedAt ? 'Kopiert!' : 'Del'}
</button>
</div>
</article>

View file

@ -0,0 +1,126 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api, ApiError } from '../lib/api';
import { session } from '../lib/session.svelte';
import type { AdminUser } from '../../../shared/types';
interface Props {
onDone: () => void;
}
let { onDone }: Props = $props();
let users: AdminUser[] = $state([]);
let loading = $state(true);
let error: string | null = $state(null);
onMount(load);
async function load() {
loading = true;
error = null;
try {
users = await api.adminListUsers();
} catch (err) {
error = err instanceof ApiError && err.status === 403
? 'Bare administratorer kan se denne siden.'
: 'Kunne ikke laste brukere.';
} finally {
loading = false;
}
}
/**
* Optimistic update: flip the role locally, then send the PATCH. If the
* server rejects (e.g., last-admin guard fires), reload from the server so
* the UI reflects the actual state.
*/
async function setRole(user: AdminUser, change: { is_moderator?: boolean; is_admin?: boolean }) {
try {
const updated = await api.adminSetRole(user.id, change);
users = users.map((u) => (u.id === updated.id ? updated : u));
// If we just toggled OUR OWN role, refresh the in-memory session so the
// nav links (Admin, Tilbakemelding) reflect the new state immediately.
if (session.user && updated.id === session.user.id) {
session.user.is_moderator = updated.is_moderator;
session.user.is_admin = updated.is_admin;
}
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
error = 'Kan ikke fjerne den siste administratoren.';
} else {
error = 'Endringen feilet.';
}
// Reload to resync state.
await load();
}
}
function formatDate(epochMs: number): string {
return new Date(epochMs).toLocaleDateString('nb-NO', {
year: 'numeric', month: '2-digit', day: '2-digit',
});
}
</script>
<section aria-label="Administrasjon">
<div class="row" style="justify-content: space-between; margin-bottom: 1rem;">
<h2 style="margin: 0;">Administrasjon</h2>
<button type="button" onclick={onDone}>Tilbake</button>
</div>
<p class="muted">
Administratorer kan utnevne moderatorer og andre administratorer. Du kan
ikke fjerne den siste administratoren — bruk <code>sqlite3</code> direkte
om du må.
</p>
{#if error}<p class="error" role="alert">{error}</p>{/if}
{#if loading}
<p class="muted">Laster …</p>
{:else}
<div class="card" style="padding: 0; overflow-x: auto;">
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="border-bottom: 1px solid var(--border);">
<th style="text-align: left; padding: 0.5rem;">Bruker</th>
<th style="text-align: left; padding: 0.5rem;">Opprettet</th>
<th style="text-align: left; padding: 0.5rem;">Moderator</th>
<th style="text-align: left; padding: 0.5rem;">Admin</th>
</tr>
</thead>
<tbody>
{#each users as u (u.id)}
<tr style="border-bottom: 1px solid var(--border);">
<td style="padding: 0.5rem;">
<div>{u.display_name?.trim() || u.email}</div>
<div class="muted" style="font-size: 0.8rem;">{u.email}{u.username ? ` · @${u.username}` : ''}</div>
</td>
<td style="padding: 0.5rem;" class="muted">{formatDate(u.created_at)}</td>
<td style="padding: 0.5rem;">
<label class="row" style="gap: 0.4rem;">
<input
type="checkbox"
checked={u.is_moderator}
onchange={(e) => setRole(u, { is_moderator: (e.currentTarget as HTMLInputElement).checked })}
/>
<span class="muted">{u.is_moderator ? 'Ja' : 'Nei'}</span>
</label>
</td>
<td style="padding: 0.5rem;">
<label class="row" style="gap: 0.4rem;">
<input
type="checkbox"
checked={u.is_admin}
onchange={(e) => setRole(u, { is_admin: (e.currentTarget as HTMLInputElement).checked })}
/>
<span class="muted">{u.is_admin ? 'Ja' : 'Nei'}</span>
</label>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>

View file

@ -9,6 +9,13 @@
import ActivityRow from './ActivityRow.svelte';
import type { Activity } from '../../../shared/types';
interface Props {
/** When true, render only public+semi (the "/" landing). When false, show
* the full authenticated dashboard including the viewer's own private rows. */
publicOnly?: boolean;
}
let { publicOnly = false }: Props = $props();
let activities: Activity[] = $state([]);
let loading = $state(true);
let showForm = $state(false);
@ -82,7 +89,11 @@
].some((s) => s.toLowerCase().includes(needle));
}
const filtered = $derived(activities.filter((a) => matchesQuery(a, query)));
const filtered = $derived(
activities
.filter((a) => !publicOnly || a.visibility !== 'private')
.filter((a) => matchesQuery(a, query)),
);
// Split into the three sections defined in the spec — "mine privat" first,
// then anonyme, then offentlige.
@ -93,7 +104,12 @@
<section aria-label="Aktiviteter">
<div class="row" style="justify-content: space-between; margin-bottom: 1rem;">
{#if session.user}
{#if publicOnly}
<p class="muted" style="margin: 0;">
Offentlige og halv-offentlige aktiviteter. Logg inn for å se og legge
til dine egne.
</p>
{:else if session.user}
<p class="muted" style="margin: 0;">
Velkommen, {session.user.display_name?.trim() || session.user.email}.
Her er aktivitetene dine for vinteren.
@ -101,11 +117,6 @@
{#if !showForm && !editing}
<button class="primary" onclick={() => (showForm = true)}>Ny aktivitet</button>
{/if}
{:else}
<p class="muted" style="margin: 0;">
Offentlige og halv-offentlige aktiviteter. Logg inn for å legge til
dine egne — privat eller offentlig.
</p>
{/if}
</div>

View file

@ -4,6 +4,7 @@ import type {
MeResponse, Activity, CreateActivityRequest, UpdateActivityRequest,
TagSuggestion, ProfileUpdateRequest,
PublicListResponse, FeedbackSubmitRequest, FeedbackEntry,
AdminUser, AdminRoleUpdate,
} from '../../../shared/types';
const BASE = '/api';
@ -62,6 +63,8 @@ export const api = {
// --- activities -----------------------------------------------------------
listActivities: () => http<Activity[]>('/activities'),
getActivity: (id: string) =>
http<Activity>(`/activities/${encodeURIComponent(id)}`),
createActivity: (body: CreateActivityRequest) =>
http<Activity>('/activities', { method: 'POST', body: JSON.stringify(body) }),
updateActivity: (id: string, body: UpdateActivityRequest) =>
@ -83,4 +86,11 @@ export const api = {
submitFeedback: (body: FeedbackSubmitRequest) =>
http<FeedbackEntry>('/feedback', { method: 'POST', body: JSON.stringify(body) }),
listFeedback: () => http<FeedbackEntry[]>('/feedback'),
// --- admin (admin role only) ----------------------------------------------
adminListUsers: () => http<AdminUser[]>('/admin/users'),
adminSetRole: (id: string, body: AdminRoleUpdate) =>
http<AdminUser>(`/admin/users/${encodeURIComponent(id)}/role`, {
method: 'PATCH', body: JSON.stringify(body),
}),
};