215 lines
6.7 KiB
JavaScript
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;
|