NGU-Web/server/src/routes/panel.js

215 lines
6.7 KiB
JavaScript

/* ═══════════════════════════════════════════════════════════════
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;