feat: resolve all 22 items in Features and Bugs.txt and complete UI polish

This commit is contained in:
Schwitzz
2026-10-30 10:57:13 -05:00
parent 7214cee8cb
commit 4add429469
42 changed files with 6360 additions and 1805 deletions
+37 -14
View File
@@ -2,7 +2,7 @@ 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 { auditLog, listUsers } from "./db.js";
import { toUser, toAudit } from "./mapping.js";
import type { UserRow, AuditRow } from "./mapping.js";
@@ -38,6 +38,10 @@ export function registerAdminRoutes(app: FastifyInstance): void {
// ---- users ----
instance.get("/api/admin/users", async () => {
return listUsers(db);
});
instance.post("/api/admin/users", async (req) => {
const body = userCreate.parse(req.body);
const existing = db
@@ -47,12 +51,12 @@ export function registerAdminRoutes(app: FastifyInstance): void {
const id = ulid();
const role = body.role ?? "author";
const passHash = await hashPassword(body.password);
const passHash = await hashPassword(body.password ?? "changeme123");
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 });
auditLog(db, "user_create", req.user!.id, { targetId: id, email: body.email, role });
const row = db.prepare("SELECT * FROM users WHERE id = ?").get(id) as UserRow;
return toUser(row);
@@ -64,10 +68,6 @@ export function registerAdminRoutes(app: FastifyInstance): void {
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) {
@@ -81,10 +81,18 @@ export function registerAdminRoutes(app: FastifyInstance): void {
if (body.disabled !== undefined) {
fields.push("disabled = ?");
values.push(body.disabled ? 1 : 0);
if (body.disabled) {
db.prepare("DELETE FROM sessions WHERE user_id = ?").run(id);
}
}
if (body.password !== undefined) {
const passHash = await hashPassword(body.password);
fields.push("pass_hash = ?");
values.push(passHash);
}
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) });
auditLog(db, "user_update", req.user!.id, { targetId: id, changes: Object.keys(body) });
const updated = db.prepare("SELECT * FROM users WHERE id = ?").get(id) as UserRow;
return toUser(updated);
@@ -150,9 +158,23 @@ export function registerAdminRoutes(app: FastifyInstance): void {
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 chapters = (db.prepare("SELECT COUNT(*) AS n FROM chapters").get() as { n: number }).n;
const worldItems = (db.prepare("SELECT COUNT(*) AS n FROM entities WHERE kind = 'world'").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 };
return {
users,
disabled,
disabledUsers: disabled,
projects,
chapters,
worldItems,
shares,
audit,
auditEntries: audit,
};
});
});
}
@@ -161,13 +183,14 @@ export function registerAdminRoutes(app: FastifyInstance): void {
const userCreate = z.object({
email: z.string().email(),
password: z.string().min(8),
password: z.string().min(6).optional(),
displayName: z.string().min(1).max(80),
role: z.enum(["admin", "author"]),
role: z.enum(["admin", "author"]).optional(),
});
const userPatch = z.object({
role: z.enum(["admin", "author"]),
disabled: z.boolean(),
displayName: z.string().min(1).max(80),
role: z.enum(["admin", "author"]).optional(),
disabled: z.boolean().optional(),
displayName: z.string().min(1).max(80).optional(),
password: z.string().min(6).optional(),
});