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
|
|
@ -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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue