v1.5 - history and timeline as well as many datastructure updates added, polished, fixes
This commit is contained in:
parent
1f0aa3078f
commit
1d84400aef
63 changed files with 7927 additions and 208 deletions
215
server/src/routes/panel.js
Normal file
215
server/src/routes/panel.js
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue