diff --git a/CLAUDE.md b/CLAUDE.md index 2d5deb2..4fdef4a 100644 --- a/CLAUDE.md +++ b/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 diff --git a/README.md b/README.md index 2ec0167..ee0950a 100644 --- a/README.md +++ b/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 `//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 diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 9cc68cb..2500b14 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -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 `//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) + * //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' // "//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'; }
diff --git a/frontend/src/components/ActivityPermalink.svelte b/frontend/src/components/ActivityPermalink.svelte new file mode 100644 index 0000000..6f1b7d7 --- /dev/null +++ b/frontend/src/components/ActivityPermalink.svelte @@ -0,0 +1,57 @@ + + +
+ {#if onBack} +
+ +
+ {/if} + + {#if loading} +

Laster …

+ {:else if notFound} +
+

Fant ikke aktiviteten

+

+ Lenken peker ikke til noen aktivitet — den kan være slettet, eller + privat og bare synlig for eieren. +

+
+ {:else if error} +

{error}

+ {:else if activity} + + {/if} +
diff --git a/frontend/src/components/ActivityRow.svelte b/frontend/src/components/ActivityRow.svelte index 161a834..6efbc71 100644 --- a/frontend/src/components/ActivityRow.svelte +++ b/frontend/src/components/ActivityRow.svelte @@ -103,6 +103,25 @@ await api.deleteActivity(activity.id); onDeleted(activity.id); } + + /** + * Share-link: copy /a/ (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); + } + } {#snippet locationLine(label: string, lat: number | null, lng: number | null)} @@ -164,14 +183,16 @@ {/if} {/if} - {#if canEdit || canDelete} -
- {#if canEdit && onEdit} - - {/if} - {#if canDelete} - - {/if} -
- {/if} +
+ {#if canEdit && onEdit} + + {/if} + {#if canDelete} + + {/if} + +
diff --git a/frontend/src/components/Admin.svelte b/frontend/src/components/Admin.svelte new file mode 100644 index 0000000..32ade6e --- /dev/null +++ b/frontend/src/components/Admin.svelte @@ -0,0 +1,126 @@ + + +
+
+

Administrasjon

+ +
+ +

+ Administratorer kan utnevne moderatorer og andre administratorer. Du kan + ikke fjerne den siste administratoren — bruk sqlite3 direkte + om du må. +

+ + {#if error}{/if} + + {#if loading} +

Laster …

+ {:else} +
+ + + + + + + + + + + {#each users as u (u.id)} + + + + + + + {/each} + +
BrukerOpprettetModeratorAdmin
+
{u.display_name?.trim() || u.email}
+
{u.email}{u.username ? ` · @${u.username}` : ''}
+
{formatDate(u.created_at)} + + + +
+
+ {/if} +
diff --git a/frontend/src/components/Home.svelte b/frontend/src/components/Home.svelte index e65137f..c44493f 100644 --- a/frontend/src/components/Home.svelte +++ b/frontend/src/components/Home.svelte @@ -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 @@
- {#if session.user} + {#if publicOnly} +

+ Offentlige og halv-offentlige aktiviteter. Logg inn for å se og legge + til dine egne. +

+ {:else if session.user}

Velkommen, {session.user.display_name?.trim() || session.user.email}. Her er aktivitetene dine for vinteren. @@ -101,11 +117,6 @@ {#if !showForm && !editing} {/if} - {:else} -

- Offentlige og halv-offentlige aktiviteter. Logg inn for å legge til - dine egne — privat eller offentlig. -

{/if}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 24a9ac4..177868a 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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('/activities'), + getActivity: (id: string) => + http(`/activities/${encodeURIComponent(id)}`), createActivity: (body: CreateActivityRequest) => http('/activities', { method: 'POST', body: JSON.stringify(body) }), updateActivity: (id: string, body: UpdateActivityRequest) => @@ -83,4 +86,11 @@ export const api = { submitFeedback: (body: FeedbackSubmitRequest) => http('/feedback', { method: 'POST', body: JSON.stringify(body) }), listFeedback: () => http('/feedback'), + + // --- admin (admin role only) ---------------------------------------------- + adminListUsers: () => http('/admin/users'), + adminSetRole: (id: string, body: AdminRoleUpdate) => + http(`/admin/users/${encodeURIComponent(id)}/role`, { + method: 'PATCH', body: JSON.stringify(body), + }), }; diff --git a/server/activities.ts b/server/activities.ts index b118536..c51386e 100644 --- a/server/activities.ts +++ b/server/activities.ts @@ -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'); diff --git a/server/admin.ts b/server/admin.ts new file mode 100644 index 0000000..ee5cae1 --- /dev/null +++ b/server/admin.ts @@ -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)); +}); diff --git a/server/auth.ts b/server/auth.ts index a326e6a..ed04a4d 100644 --- a/server/auth.ts +++ b/server/auth.ts @@ -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, }; diff --git a/server/db.ts b/server/db.ts index 78d876c..1604e54 100644 --- a/server/db.ts +++ b/server/db.ts @@ -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 "//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 diff --git a/server/feedback.ts b/server/feedback.ts index 0dbb97a..5e51cd7 100644 --- a/server/feedback.ts +++ b/server/feedback.ts @@ -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; diff --git a/server/index.ts b/server/index.ts index ab7b62f..4584d04 100644 --- a/server/index.ts +++ b/server/index.ts @@ -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 diff --git a/server/roles.ts b/server/roles.ts new file mode 100644 index 0000000..4a719b1 --- /dev/null +++ b/server/roles.ts @@ -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; +} diff --git a/shared/types.ts b/shared/types.ts index 48e2fe3..b5f2e87 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -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;