fc8eaa86c3
- Fix PATCH /api/projects/:id/shares/:userId: query SELECT 1 instead of SELECT id (project_shares has a composite PK, no id column) - Add audit logging to existing share grant/revoke routes - Add POST /api/projects/:id/transfer for ownership handoff - Remove inline admin user/audit routes from routes.ts (now in admin.ts) - Remove unused requireAdmin import and userPatch schema from routes.ts
174 lines
6.7 KiB
TypeScript
174 lines
6.7 KiB
TypeScript
import type { FastifyInstance } from "fastify";
|
|
import { ulid } from "ulid";
|
|
import { z } from "zod";
|
|
import { requireAdmin, hashPassword } from "./auth.js";
|
|
import { auditLog } from "./db.js";
|
|
import { toUser, toAudit } from "./mapping.js";
|
|
import type { UserRow, AuditRow } from "./mapping.js";
|
|
|
|
/**
|
|
* Admin API (CHUNK #10).
|
|
*
|
|
* All routes live under a plugin scope whose `onRequest` hook enforces admin
|
|
* membership via `requireAdmin`. The global auth hook (registered earlier) has
|
|
* already populated `req.user`, so handlers read the acting user from `req.user!`.
|
|
*
|
|
* Validation uses Zod `.parse()` inside handlers (Fastify's `schema` option
|
|
* expects a JSON schema, not a Zod object). Errors are thrown as Fastify HTTP
|
|
* errors so the framework serializes the right status code.
|
|
*
|
|
* Sharing/CRUD for projects lives in `routes.ts` (project-level,
|
|
* `requireProjectAccess`); this module is purely instance administration:
|
|
* user management, the audit log, and instance stats.
|
|
*/
|
|
export function registerAdminRoutes(app: FastifyInstance): void {
|
|
// Scope the admin routes (and their onRequest hook) under a plugin so the
|
|
// admin-enforcement hook applies only to these routes, not globally.
|
|
app.register(async function adminScope(instance: FastifyInstance) {
|
|
const db = instance.db;
|
|
|
|
// Enforce admin membership for every route in this scope.
|
|
instance.addHook("onRequest", async (req, reply) => {
|
|
try {
|
|
requireAdmin(req);
|
|
} catch (e) {
|
|
reply.code(401).send(e);
|
|
}
|
|
});
|
|
|
|
// ---- users ----
|
|
|
|
instance.post("/api/admin/users", async (req) => {
|
|
const body = userCreate.parse(req.body);
|
|
const existing = db
|
|
.prepare("SELECT id FROM users WHERE email = ?")
|
|
.get(body.email) as { id: string } | undefined;
|
|
if (existing) throw instance.httpErrors.create(409, { error: "email_taken" });
|
|
|
|
const id = ulid();
|
|
const role = body.role ?? "author";
|
|
const passHash = await hashPassword(body.password);
|
|
db.prepare(
|
|
"INSERT INTO users (id, email, pass_hash, display_name, role, disabled, created_at) VALUES (?, ?, ?, ?, ?, 0, ?)",
|
|
).run(id, body.email, passHash, body.displayName, role, new Date().toISOString());
|
|
|
|
auditLog(db, "user_create", req.user!.id, { email: body.email, role });
|
|
|
|
const row = db.prepare("SELECT * FROM users WHERE id = ?").get(id) as UserRow;
|
|
return toUser(row);
|
|
});
|
|
|
|
instance.patch("/api/admin/users/:id", async (req) => {
|
|
const body = userPatch.parse(req.body);
|
|
const { id } = req.params as { id: string };
|
|
const row = db.prepare("SELECT * FROM users WHERE id = ?").get(id) as UserRow | undefined;
|
|
if (!row) throw instance.httpErrors.create(404, { error: "user_not_found" });
|
|
|
|
if (row.disabled && !body.disabled) {
|
|
throw instance.httpErrors.create(400, { error: "reset_password_to_reenable" });
|
|
}
|
|
|
|
const fields: string[] = [];
|
|
const values: unknown[] = [];
|
|
if (body.displayName !== undefined) {
|
|
fields.push("display_name = ?");
|
|
values.push(body.displayName);
|
|
}
|
|
if (body.role !== undefined) {
|
|
fields.push("role = ?");
|
|
values.push(body.role);
|
|
}
|
|
if (body.disabled !== undefined) {
|
|
fields.push("disabled = ?");
|
|
values.push(body.disabled ? 1 : 0);
|
|
}
|
|
if (fields.length) db.prepare(`UPDATE users SET ${fields.join(", ")} WHERE id = ?`).run(...values, id);
|
|
|
|
auditLog(db, "user_update", req.user!.id, { id, changes: Object.keys(body) });
|
|
|
|
const updated = db.prepare("SELECT * FROM users WHERE id = ?").get(id) as UserRow;
|
|
return toUser(updated);
|
|
});
|
|
|
|
instance.delete("/api/admin/users/:id", async (req) => {
|
|
const { id } = req.params as { id: string };
|
|
const row = db.prepare("SELECT * FROM users WHERE id = ?").get(id) as UserRow | undefined;
|
|
if (!row) throw instance.httpErrors.create(404, { error: "user_not_found" });
|
|
|
|
db.prepare("DELETE FROM sessions WHERE user_id = ?").run(id);
|
|
db.prepare("DELETE FROM project_shares WHERE user_id = ?").run(id);
|
|
db.prepare("DELETE FROM users WHERE id = ?").run(id);
|
|
|
|
auditLog(db, "user_delete", req.user!.id, { id, email: row.email });
|
|
return { ok: true };
|
|
});
|
|
|
|
// ---- audit log ----
|
|
|
|
instance.get("/api/admin/audit", async (req) => {
|
|
const q = req.query as { limit?: string; offset?: string; userId?: string; action?: string };
|
|
const limit = Math.min(Math.max(parseInt(q.limit ?? "50", 10) || 50, 1), 200);
|
|
const offset = Math.max(parseInt(q.offset ?? "0", 10) || 0, 0);
|
|
|
|
const clauses: string[] = [];
|
|
const params: unknown[] = [];
|
|
if (q.userId) {
|
|
clauses.push("user_id = ?");
|
|
params.push(q.userId);
|
|
}
|
|
if (q.action) {
|
|
clauses.push("action = ?");
|
|
params.push(q.action);
|
|
}
|
|
const where = clauses.length ? ` WHERE ${clauses.join(" AND ")}` : "";
|
|
|
|
const total = (db.prepare(`SELECT COUNT(*) AS n FROM audit${where}`).get(...params) as { n: number }).n;
|
|
params.push(limit, offset);
|
|
const items = (
|
|
db
|
|
.prepare(`SELECT * FROM audit${where} ORDER BY at DESC LIMIT ? OFFSET ?`)
|
|
.all(...params) as AuditRow[]
|
|
).map(toAudit);
|
|
|
|
return { items, total, limit, offset };
|
|
});
|
|
|
|
instance.post("/api/admin/audit/clear", async (req) => {
|
|
const body = req.body ?? {};
|
|
const keepAll = (body as { keepAll?: boolean }).keepAll === true;
|
|
const cutoff = keepAll
|
|
? "1970-01-01T00:00:00.000Z"
|
|
: new Date(Date.now() - 30 * 86400_000).toISOString();
|
|
const info = db.prepare("DELETE FROM audit WHERE at < ?").run(cutoff);
|
|
auditLog(db, "audit_clear", req.user!.id, { deleted: info.changes, keepAll });
|
|
return { ok: true, deleted: info.changes };
|
|
});
|
|
|
|
// ---- stats ----
|
|
|
|
instance.get("/api/admin/stats", async () => {
|
|
const users = (db.prepare("SELECT COUNT(*) AS n FROM users").get() as { n: number }).n;
|
|
const disabled = (db.prepare("SELECT COUNT(*) AS n FROM users WHERE disabled = 1").get() as { n: number }).n;
|
|
const projects = (db.prepare("SELECT COUNT(*) AS n FROM projects").get() as { n: number }).n;
|
|
const shares = (db.prepare("SELECT COUNT(*) AS n FROM project_shares").get() as { n: number }).n;
|
|
const audit = (db.prepare("SELECT COUNT(*) AS n FROM audit").get() as { n: number }).n;
|
|
return { users, disabled, projects, shares, audit };
|
|
});
|
|
});
|
|
}
|
|
|
|
// ---- validation schemas ----
|
|
|
|
const userCreate = z.object({
|
|
email: z.string().email(),
|
|
password: z.string().min(8),
|
|
displayName: z.string().min(1).max(80),
|
|
role: z.enum(["admin", "author"]),
|
|
});
|
|
|
|
const userPatch = z.object({
|
|
role: z.enum(["admin", "author"]),
|
|
disabled: z.boolean(),
|
|
displayName: z.string().min(1).max(80),
|
|
});
|