/* ═══════════════════════════════════════════════════════════════ PANEL ROUTES — server/src/routes/panel.js GET /api/admin/panel/overview PATCH /api/admin/panel/users/:id role, is_active DELETE /api/admin/panel/users/:id/sessions sign out everywhere Everything here is superadmin-only, enforced once at the top rather than per route — there's no read here that an ordinary admin should have either. Account records and live session counts are a different class of thing from content. Two rules run through the writes, both about not locking everyone out of the building: * nobody edits their own role or active flag, so a misclick can't demote the person making it; * the last active superadmin can't be demoted or disabled. Changing a role or disabling an account drops that person's live sessions immediately, the same way admin-cli.js does. Leaving a 30-day cookie valid after revoking the access it represents is the whole point of having the button. ═══════════════════════════════════════════════════════════════ */ import { Hono } from "hono"; import { requireAuth, requireRole, ROLES } from "../auth.js"; const panel = new Hono(); panel.use("*", requireAuth); panel.use("*", requireRole("superadmin")); const NO_STORE = { "Cache-Control": "no-store" }; /* The counts on the overview. Table name is a literal from this list, never anything off the wire. */ const CONTENT_TABLES = [ ["Events", "events"], ["Organizations", "organizations"], ["People", "people"], ["Teams", "teams"], ["Awards", "awards"], ["Timeline entries", "timeline_entries"], ["Feedback", "feedback"], ]; /* ── GET /api/admin/panel/overview ─────────────────────────────── */ panel.get("/overview", (c) => { const db = c.get("db"); const users = db .prepare( `SELECT u.id, u.email, u.name, u.role, u.is_active, u.created_at, u.last_login_at, (SELECT COUNT(*) FROM sessions s WHERE s.user_id = u.id AND s.expires_at > datetime('now')) AS sessions FROM admin_users u ORDER BY u.role DESC, u.email`, ) .all(); const content = CONTENT_TABLES.map(([label, table]) => ({ label, count: count(db, table), })); return c.json( { system: { schemaVersion: db.prepare("PRAGMA user_version").get().user_version, dbPath: process.env.DB_PATH ?? null, nodeVersion: process.version, platform: `${process.platform} ${process.arch}`, uptimeSeconds: Math.round(process.uptime()), startedAt: new Date(Date.now() - process.uptime() * 1000).toISOString(), sessions: count(db, "sessions", "expires_at > datetime('now')"), roles: ROLES, }, content, users, }, 200, NO_STORE, ); }); /* A table that hasn't been created yet shouldn't take the whole page down — the panel is where you go when something is wrong. */ function count(db, table, where) { try { const sql = `SELECT COUNT(*) AS n FROM ${table}${where ? ` WHERE ${where}` : ""}`; return db.prepare(sql).get().n; } catch { return null; } } /* ── PATCH /api/admin/panel/users/:id ───────────────────────────── */ panel.patch("/users/:id", async (c) => { const db = c.get("db"); const me = c.get("user"); const id = Number(c.req.param("id")); if (!Number.isInteger(id)) return c.json({ error: "Bad id." }, 400); if (id === me.id) { return c.json( { error: "You can't change your own role or access. Ask another superadmin." }, 403, ); } let body; try { body = await c.req.json(); } catch { return c.json({ error: "Expected a JSON body." }, 400); } const target = db .prepare("SELECT id, email, role, is_active FROM admin_users WHERE id = ?") .get(id); if (!target) return c.json({ error: "No such account." }, 404); const sets = []; const params = []; const losingSuper = target.role === "superadmin" && ((body.role !== undefined && body.role !== "superadmin") || (body.is_active !== undefined && Number(body.is_active) === 0)); if (losingSuper && activeSupers(db) <= 1) { return c.json( { error: "That's the last active superadmin. Promote someone else first." }, 409, ); } if (body.role !== undefined) { if (!ROLES.includes(body.role)) return c.json({ error: "Unknown role." }, 422); sets.push("role = ?"); params.push(body.role); } if (body.is_active !== undefined) { sets.push("is_active = ?"); params.push(Number(body.is_active) ? 1 : 0); } if (sets.length === 0) return c.json({ error: "Nothing to change." }, 400); const tx = db.transaction(() => { db.prepare(`UPDATE admin_users SET ${sets.join(", ")} WHERE id = ?`).run( ...params, id, ); // Whatever changed, the access they're holding no longer // matches the row. Make them sign in again. db.prepare("DELETE FROM sessions WHERE user_id = ?").run(id); }); tx(); console.log( `account ${target.email} updated by ${me.email}: ${JSON.stringify(body)}`, ); return c.json({ user: userRow(db, id) }, 200, NO_STORE); }); /* ── DELETE /api/admin/panel/users/:id/sessions ─────────────────── */ panel.delete("/users/:id/sessions", (c) => { const db = c.get("db"); const id = Number(c.req.param("id")); if (!Number.isInteger(id)) return c.json({ error: "Bad id." }, 400); const target = db.prepare("SELECT email FROM admin_users WHERE id = ?").get(id); if (!target) return c.json({ error: "No such account." }, 404); const { changes } = db.prepare("DELETE FROM sessions WHERE user_id = ?").run(id); console.log( `${changes} session(s) for ${target.email} revoked by ${c.get("user").email}`, ); return c.json({ user: userRow(db, id), revoked: changes }, 200, NO_STORE); }); function activeSupers(db) { return db .prepare( "SELECT COUNT(*) AS n FROM admin_users WHERE role = 'superadmin' AND is_active = 1", ) .get().n; } function userRow(db, id) { return db .prepare( `SELECT u.id, u.email, u.name, u.role, u.is_active, u.created_at, u.last_login_at, (SELECT COUNT(*) FROM sessions s WHERE s.user_id = u.id AND s.expires_at > datetime('now')) AS sessions FROM admin_users u WHERE u.id = ?`, ) .get(id); } export default panel;