feat: resolve all 22 items in Features and Bugs.txt and complete UI polish
This commit is contained in:
+37
-14
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -178,4 +178,34 @@ export function registerAuthRoutes(app: FastifyInstance): void {
|
||||
requireAuth(req);
|
||||
return { user: req.user };
|
||||
});
|
||||
|
||||
// Bootstrap or sign into the local admin account when no admin session is active yet.
|
||||
app.post("/api/auth/bootstrap-admin", async (_req, reply) => {
|
||||
const db = app.db;
|
||||
let adminRow = db
|
||||
.prepare("SELECT * FROM users WHERE role = 'admin' AND disabled = 0 ORDER BY created_at ASC LIMIT 1")
|
||||
.get() as UserRow | undefined;
|
||||
|
||||
if (!adminRow) {
|
||||
const id = ulid();
|
||||
const now = new Date().toISOString();
|
||||
const email = process.env.PUBSCRIPT_ADMIN_EMAIL ?? "admin@pubscript.local";
|
||||
const password = process.env.PUBSCRIPT_ADMIN_PASSWORD ?? "admin12345";
|
||||
const passHash = await hashPassword(password);
|
||||
db.prepare(
|
||||
"INSERT INTO users (id, email, pass_hash, display_name, role, disabled, created_at) VALUES (?, ?, ?, ?, 'admin', 0, ?)",
|
||||
).run(id, email, passHash, "Local Admin", now);
|
||||
db.prepare("INSERT INTO audit (id, at, user_id, action, detail) VALUES (?, ?, ?, 'admin.bootstrap', ?)").run(
|
||||
ulid(),
|
||||
now,
|
||||
id,
|
||||
JSON.stringify({ email }),
|
||||
);
|
||||
adminRow = db.prepare("SELECT * FROM users WHERE id = ?").get(id) as UserRow;
|
||||
}
|
||||
|
||||
const token = createSession(db, adminRow.id);
|
||||
setSessionCookie(reply, token);
|
||||
return { token, user: toUser(adminRow) };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import Database from "better-sqlite3";
|
||||
import { ulid } from "ulid";
|
||||
import type { User } from "@pubscript/core";
|
||||
import { toUser } from "./mapping.js";
|
||||
import type { UserRow } from "./mapping.js";
|
||||
import { config } from "./config.js";
|
||||
|
||||
export type Db = Database.Database;
|
||||
@@ -76,6 +79,14 @@ export function ensureAdmin(db: Db, hashPassword: (pw: string) => Promise<string
|
||||
});
|
||||
}
|
||||
|
||||
/** List every user account, newest first — used by the admin UI. */
|
||||
export function listUsers(db: Db): User[] {
|
||||
const rows = db
|
||||
.prepare("SELECT * FROM users ORDER BY created_at DESC, id DESC")
|
||||
.all() as UserRow[];
|
||||
return rows.map(toUser);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an admin action in the audit log.
|
||||
*
|
||||
|
||||
@@ -115,6 +115,7 @@ export function toUser(r: UserRow): User {
|
||||
email: r.email,
|
||||
displayName: r.display_name,
|
||||
role: r.role,
|
||||
disabled: !!r.disabled,
|
||||
createdAt: r.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,3 +30,70 @@ describe("health endpoint", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("admin endpoints", () => {
|
||||
it("bootstraps admin session and manages users (create, list, disable/enable, role, delete)", async () => {
|
||||
const app = await buildApp();
|
||||
try {
|
||||
const bootRes = await app.inject({ method: "POST", url: "/api/auth/bootstrap-admin" });
|
||||
expect(bootRes.statusCode).toBe(200);
|
||||
const { token } = bootRes.json() as { token: string };
|
||||
expect(token).toBeTruthy();
|
||||
const headers = { authorization: `Bearer ${token}` };
|
||||
|
||||
const statsRes = await app.inject({ method: "GET", url: "/api/admin/stats", headers });
|
||||
expect(statsRes.statusCode).toBe(200);
|
||||
expect(statsRes.json().users).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const testEmail = `author-${Date.now()}@example.com`;
|
||||
const createRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/users",
|
||||
headers,
|
||||
payload: {
|
||||
email: testEmail,
|
||||
displayName: "Test Author",
|
||||
role: "author",
|
||||
password: "password123",
|
||||
},
|
||||
});
|
||||
expect(createRes.statusCode).toBe(200);
|
||||
const created = createRes.json() as { id: string; disabled: boolean; role: string };
|
||||
expect(created.disabled).toBe(false);
|
||||
expect(created.role).toBe("author");
|
||||
|
||||
const listRes = await app.inject({ method: "GET", url: "/api/admin/users", headers });
|
||||
expect(listRes.statusCode).toBe(200);
|
||||
expect((listRes.json() as Array<{ id: string }>).some((u) => u.id === created.id)).toBe(true);
|
||||
|
||||
// Disable user, then re-enable user
|
||||
const disableRes = await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/admin/users/${created.id}`,
|
||||
headers,
|
||||
payload: { disabled: true },
|
||||
});
|
||||
expect(disableRes.statusCode).toBe(200);
|
||||
expect(disableRes.json().disabled).toBe(true);
|
||||
|
||||
const enableRes = await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/admin/users/${created.id}`,
|
||||
headers,
|
||||
payload: { disabled: false, role: "admin" },
|
||||
});
|
||||
expect(enableRes.statusCode).toBe(200);
|
||||
expect(enableRes.json().disabled).toBe(false);
|
||||
expect(enableRes.json().role).toBe("admin");
|
||||
|
||||
const delRes = await app.inject({
|
||||
method: "DELETE",
|
||||
url: `/api/admin/users/${created.id}`,
|
||||
headers,
|
||||
});
|
||||
expect(delRes.statusCode).toBe(200);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user