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:
parent
f0b4d735b5
commit
bd82f71a01
16 changed files with 573 additions and 80 deletions
13
CLAUDE.md
13
CLAUDE.md
|
|
@ -80,6 +80,19 @@ Sessions are opaque tokens stored in the `sessions` table; the cookie is
|
|||
right behaviour: it kicks out any logged-in session that may have been
|
||||
hijacked, and the user has to re-login with the new password.
|
||||
|
||||
## Roles
|
||||
|
||||
Three levels: user / moderator / admin. Admin **implies** moderator —
|
||||
`isModerator()` in `server/roles.ts` returns true for admins. Keep that
|
||||
implication invariant: an admin who can't moderate is meaningless and
|
||||
breaks the UI's assumptions. Add new privileges by checking `isAdmin()`,
|
||||
not by relaxing `isModerator()`.
|
||||
|
||||
The admin endpoints (`/api/admin/*`) are gated by the `isAdmin()` check in
|
||||
`server/admin.ts`. A last-admin safety net prevents the only remaining
|
||||
admin from demoting themselves via the API — explicit `sqlite3` is
|
||||
required for that, so the operator can't accidentally lock themselves out.
|
||||
|
||||
## Tag input merging — design decision
|
||||
|
||||
Server tags and IndexedDB tags are merged in one dropdown, each row labelled
|
||||
|
|
|
|||
33
README.md
33
README.md
|
|
@ -143,18 +143,39 @@ Layout adapts to small screens via:
|
|||
- `min-height: 44px` on buttons (WCAG 2.5.5 enhanced touch target)
|
||||
- `font-size: 16px` on inputs below 480px so iOS doesn't auto-zoom
|
||||
|
||||
## Promoting a moderator
|
||||
## Roles: moderator and admin
|
||||
|
||||
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:
|
||||
There are three privilege levels:
|
||||
|
||||
| Role | What it grants |
|
||||
|-----------------|--------------------------------------------------------------------------------|
|
||||
| **Anonymous** | Browse public + semi activities, view opt-in `/<username>/list` pages |
|
||||
| **User** | + manage own activities, edit own profile, submit feedback |
|
||||
| **Moderator** | + delete any `semi`/`public` activity, read the feedback list |
|
||||
| **Admin** | + grant/revoke moderator and admin on other users (via `/api/admin/users`) |
|
||||
|
||||
Admin implies moderator — admins automatically pass any `is_moderator` check.
|
||||
|
||||
The **first admin** has to be promoted out of band (chicken-and-egg). After
|
||||
that, admins can grant moderator/admin to others through the Admin UI.
|
||||
|
||||
```bash
|
||||
# Bootstrap the first admin:
|
||||
sqlite3 data/vinterliste.db \
|
||||
"UPDATE users SET is_moderator = 1 WHERE email = 'olemd@example.org';"
|
||||
"UPDATE users SET is_admin = 1 WHERE email = 'you@example.org';"
|
||||
|
||||
# Promote a plain moderator (admins can also do this from the UI):
|
||||
sqlite3 data/vinterliste.db \
|
||||
"UPDATE users SET is_moderator = 1 WHERE email = 'them@example.org';"
|
||||
```
|
||||
|
||||
The user has to log out and back in for `MeResponse.is_moderator` to refresh.
|
||||
The user has to log out and back in for the in-memory `session.user` to
|
||||
refresh — server-side authz updates on the next request regardless.
|
||||
|
||||
A last-admin safety net is wired into the role-update endpoint: an admin
|
||||
trying to demote themselves while they're the only remaining admin gets a
|
||||
`409 cannot_demote_last_admin`. If you really want to strand the deployment
|
||||
with no admin, you have to use `sqlite3` directly.
|
||||
|
||||
## Manual verification
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
57
frontend/src/components/ActivityPermalink.svelte
Normal file
57
frontend/src/components/ActivityPermalink.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
126
frontend/src/components/Admin.svelte
Normal file
126
frontend/src/components/Admin.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Hono } from 'hono';
|
|||
import { getDb } from './db';
|
||||
import { requireAuth, currentUserId, type AppVariables } from './session';
|
||||
import { setActivityTags, clearActivityTags, tagsFor } from './tags';
|
||||
import { isModerator } from './roles';
|
||||
import type {
|
||||
Activity, ActivityPublic, ActivitySemi, ActivityPrivate,
|
||||
CreateActivityRequest, UpdateActivityRequest, Visibility,
|
||||
|
|
@ -61,12 +62,6 @@ function ownerAttribution(ownerId: string): { display: string; username: string
|
|||
return { display, username };
|
||||
}
|
||||
|
||||
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');
|
||||
|
|
|
|||
117
server/admin.ts
Normal file
117
server/admin.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { Hono } from 'hono';
|
||||
import { getDb } from './db';
|
||||
import { requireAuth, type AppVariables } from './session';
|
||||
import { isAdmin } from './roles';
|
||||
import type { AdminUser, AdminRoleUpdate } from '../shared/types';
|
||||
|
||||
/**
|
||||
* Admin endpoints. Admin is strictly stronger than moderator: anything a
|
||||
* moderator can do, an admin can also do (the `isModerator()` helper returns
|
||||
* true for admins). What's *only* available here:
|
||||
*
|
||||
* GET /api/admin/users — list all users with their roles
|
||||
* PATCH /api/admin/users/:id/role — set is_moderator and/or is_admin
|
||||
*
|
||||
* No user deletion yet — that's a bigger ask (cascades into activities,
|
||||
* feedback, sessions) and worth its own design pass.
|
||||
*/
|
||||
export const adminRoutes = new Hono<{ Variables: AppVariables }>();
|
||||
|
||||
// requireAuth must run first so c.get('userId') is populated for the admin check.
|
||||
adminRoutes.use('*', requireAuth);
|
||||
|
||||
// Then the admin gate. Anything below this middleware is admin-only.
|
||||
adminRoutes.use('*', async (c, next) => {
|
||||
const userId = c.get('userId');
|
||||
if (!isAdmin(userId)) return c.json({ error: 'forbidden' }, 403);
|
||||
await next();
|
||||
});
|
||||
|
||||
interface UserRow {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name: string | null;
|
||||
username: string | null;
|
||||
public_list_enabled: number | null;
|
||||
is_moderator: number | null;
|
||||
is_admin: number | null;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
function toAdminUser(row: UserRow): AdminUser {
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
display_name: row.display_name,
|
||||
username: row.username,
|
||||
public_list_enabled: row.public_list_enabled === 1,
|
||||
is_moderator: row.is_moderator === 1,
|
||||
is_admin: row.is_admin === 1,
|
||||
created_at: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
// --- GET /api/admin/users ---------------------------------------------------
|
||||
adminRoutes.get('/users', (c) => {
|
||||
const rows = getDb()
|
||||
.prepare(`
|
||||
SELECT id, email, display_name, username, public_list_enabled,
|
||||
is_moderator, is_admin, created_at
|
||||
FROM users
|
||||
ORDER BY created_at ASC
|
||||
`)
|
||||
.all() as UserRow[];
|
||||
return c.json(rows.map(toAdminUser));
|
||||
});
|
||||
|
||||
// --- PATCH /api/admin/users/:id/role ----------------------------------------
|
||||
adminRoutes.patch('/users/:id/role', async (c) => {
|
||||
const targetId = c.req.param('id');
|
||||
const callerId = c.get('userId');
|
||||
const body = (await c.req.json().catch(() => null)) as AdminRoleUpdate | null;
|
||||
if (!body) return c.json({ error: 'invalid_json' }, 400);
|
||||
if (!('is_moderator' in body) && !('is_admin' in body)) {
|
||||
return c.json({ error: 'no_role_fields' }, 400);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const target = db
|
||||
.prepare('SELECT id, is_admin FROM users WHERE id = ?')
|
||||
.get(targetId) as { id: string; is_admin: number | null } | null;
|
||||
if (!target) return c.json({ error: 'not_found' }, 404);
|
||||
|
||||
// Last-admin guard: refuse to demote the only remaining admin so we don't
|
||||
// strand the deployment with no way back into admin-land except sqlite3.
|
||||
// (You can still demote yourself if at least one other admin exists.)
|
||||
if (target.id === callerId && body.is_admin === false && target.is_admin === 1) {
|
||||
const others = db
|
||||
.prepare('SELECT COUNT(*) AS n FROM users WHERE is_admin = 1 AND id <> ?')
|
||||
.get(callerId) as { n: number };
|
||||
if (others.n === 0) {
|
||||
return c.json({ error: 'cannot_demote_last_admin' }, 409);
|
||||
}
|
||||
}
|
||||
|
||||
const updates: string[] = [];
|
||||
const params: (number | string)[] = [];
|
||||
if (typeof body.is_moderator === 'boolean') {
|
||||
updates.push('is_moderator = ?');
|
||||
params.push(body.is_moderator ? 1 : 0);
|
||||
}
|
||||
if (typeof body.is_admin === 'boolean') {
|
||||
updates.push('is_admin = ?');
|
||||
params.push(body.is_admin ? 1 : 0);
|
||||
}
|
||||
params.push(targetId);
|
||||
|
||||
db.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...params);
|
||||
|
||||
const refreshed = db
|
||||
.prepare(`
|
||||
SELECT id, email, display_name, username, public_list_enabled,
|
||||
is_moderator, is_admin, created_at
|
||||
FROM users WHERE id = ?
|
||||
`)
|
||||
.get(targetId) as UserRow;
|
||||
return c.json(toAdminUser(refreshed));
|
||||
});
|
||||
|
|
@ -25,7 +25,8 @@ const USERNAME_RE = /^[a-z0-9][a-z0-9_-]{1,30}$/;
|
|||
function loadMe(userId: string): MeResponse | null {
|
||||
const row = getDb()
|
||||
.prepare(`
|
||||
SELECT id, email, display_name, is_moderator, username, public_list_enabled
|
||||
SELECT id, email, display_name, is_moderator, is_admin,
|
||||
username, public_list_enabled
|
||||
FROM users WHERE id = ?
|
||||
`)
|
||||
.get(userId) as
|
||||
|
|
@ -33,6 +34,7 @@ function loadMe(userId: string): MeResponse | null {
|
|||
id: string; email: string;
|
||||
display_name: string | null;
|
||||
is_moderator: number | null;
|
||||
is_admin: number | null;
|
||||
username: string | null;
|
||||
public_list_enabled: number | null;
|
||||
}
|
||||
|
|
@ -43,6 +45,7 @@ function loadMe(userId: string): MeResponse | null {
|
|||
email: row.email,
|
||||
display_name: row.display_name,
|
||||
is_moderator: row.is_moderator === 1,
|
||||
is_admin: row.is_admin === 1,
|
||||
username: row.username,
|
||||
public_list_enabled: row.public_list_enabled === 1,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -33,6 +33,11 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
|||
-- 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,
|
||||
-- Admin flag. Strictly stronger than moderator: admins have everything
|
||||
-- moderators have, plus the /api/admin/* endpoints (promote/demote other
|
||||
-- users). Bootstrapped via "sqlite3 ... UPDATE" (see README); after that,
|
||||
-- admins can grant moderator/admin to others through the UI.
|
||||
is_admin 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
|
||||
|
|
@ -140,6 +145,7 @@ export function getDb(): Database {
|
|||
// ambiguous.
|
||||
ensureColumn(db, 'users', 'display_name', 'TEXT');
|
||||
ensureColumn(db, 'users', 'is_moderator', 'INTEGER NOT NULL DEFAULT 0');
|
||||
ensureColumn(db, 'users', 'is_admin', 'INTEGER NOT NULL DEFAULT 0');
|
||||
ensureColumn(db, 'users', '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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Hono } from 'hono';
|
||||
import { getDb } from './db';
|
||||
import { requireAuth, type AppVariables } from './session';
|
||||
import { isModerator } from './roles';
|
||||
import type { FeedbackSubmitRequest, FeedbackEntry } from '../shared/types';
|
||||
|
||||
const MAX_BODY = 4000;
|
||||
|
|
@ -17,13 +18,6 @@ const MAX_BODY = 4000;
|
|||
*/
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { activitiesRoutes } from './activities';
|
|||
import { tagsRoutes } from './tags';
|
||||
import { usersRoutes } from './users';
|
||||
import { feedbackRoutes } from './feedback';
|
||||
import { adminRoutes } from './admin';
|
||||
|
||||
// Initialise DB up front so the server fails fast on schema problems.
|
||||
getDb();
|
||||
|
|
@ -27,6 +28,7 @@ app.route('/api/activities', activitiesRoutes);
|
|||
app.route('/api/tags', tagsRoutes);
|
||||
app.route('/api/users', usersRoutes);
|
||||
app.route('/api/feedback', feedbackRoutes);
|
||||
app.route('/api/admin', adminRoutes);
|
||||
|
||||
// In production, serve the built Svelte SPA. The static helper is registered
|
||||
// for the asset directory and for the top-level files that Vite copies from
|
||||
|
|
|
|||
32
server/roles.ts
Normal file
32
server/roles.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { getDb } from './db';
|
||||
|
||||
/**
|
||||
* Role checks. Admin strictly implies moderator — there's no useful state
|
||||
* where someone is "admin but can't moderate." Keep the check definitions
|
||||
* in one place so the implication can't drift.
|
||||
*
|
||||
* We re-query the DB for each check rather than caching on the session: a
|
||||
* demoted user should lose privileges immediately on their next request,
|
||||
* which matters more than the tiny query cost.
|
||||
*/
|
||||
|
||||
interface RoleFlags {
|
||||
is_moderator: number | null;
|
||||
is_admin: number | null;
|
||||
}
|
||||
|
||||
function loadFlags(userId: string): RoleFlags | null {
|
||||
return getDb()
|
||||
.prepare('SELECT is_moderator, is_admin FROM users WHERE id = ?')
|
||||
.get(userId) as RoleFlags | null;
|
||||
}
|
||||
|
||||
export function isModerator(userId: string): boolean {
|
||||
const r = loadFlags(userId);
|
||||
return r?.is_moderator === 1 || r?.is_admin === 1;
|
||||
}
|
||||
|
||||
export function isAdmin(userId: string): boolean {
|
||||
const r = loadFlags(userId);
|
||||
return r?.is_admin === 1;
|
||||
}
|
||||
|
|
@ -72,10 +72,30 @@ export interface MeResponse {
|
|||
email: string;
|
||||
display_name: string | null;
|
||||
is_moderator: boolean;
|
||||
is_admin: boolean;
|
||||
username: string | null;
|
||||
public_list_enabled: boolean;
|
||||
}
|
||||
|
||||
// --- Admin -----------------------------------------------------------------
|
||||
/** A single row in the admin user list. Admin-only. */
|
||||
export interface AdminUser {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name: string | null;
|
||||
username: string | null;
|
||||
public_list_enabled: boolean;
|
||||
is_moderator: boolean;
|
||||
is_admin: boolean;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
/** PATCH /api/admin/users/:id/role payload. Both fields optional. */
|
||||
export interface AdminRoleUpdate {
|
||||
is_moderator?: boolean;
|
||||
is_admin?: boolean;
|
||||
}
|
||||
|
||||
export interface ProfileUpdateRequest {
|
||||
// All optional — omit a field to leave it alone. Pass `null` to clear.
|
||||
display_name?: string | null;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue