import { db } from "./db.mjs"; import { requirePermission, publicUser } from "./auth.mjs"; import { json, readBody } from "./http.mjs"; import { permissions } from "./permissions.mjs"; import { hashPassword } from "./security.mjs"; const roles = new Set(["system_admin", "admin", "editor", "viewer", "user"]); const statuses = new Set(["active", "disabled"]); function listUsers() { return db .prepare(` SELECT id, email, name, avatar_url, role, status, email_verified, created_at, updated_at, last_login_at FROM users ORDER BY created_at DESC, id DESC `) .all() .map(publicUser); } function validateEmail(email) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); } async function handleListUsers(request, response) { if (!requirePermission(request, response, permissions.usersManage)) return; json(response, 200, { users: listUsers() }); } async function handleCreateUser(request, response) { if (!requirePermission(request, response, permissions.usersManage)) return; const body = JSON.parse((await readBody(request)) || "{}"); const email = String(body.email || "").trim().toLowerCase(); const name = String(body.name || "").trim(); const password = String(body.password || ""); const role = String(body.role || "user"); if (!validateEmail(email)) { json(response, 400, { error: "A valid email is required" }); return; } if (!name) { json(response, 400, { error: "Name is required" }); return; } if (password.length < 8) { json(response, 400, { error: "Password must be at least 8 characters" }); return; } if (!roles.has(role)) { json(response, 400, { error: "Invalid role" }); return; } const existing = db.prepare("SELECT id FROM users WHERE email = ?").get(email); if (existing) { json(response, 409, { error: "Email already exists" }); return; } const result = db .prepare("INSERT INTO users (email, password_hash, name, role, email_verified) VALUES (?, ?, ?, ?, 1)") .run(email, hashPassword(password), name, role); json(response, 201, { user: publicUser(db.prepare("SELECT * FROM users WHERE id = ?").get(result.lastInsertRowid)) }); } async function handleUpdateUser(request, response, userId) { const actor = requirePermission(request, response, permissions.usersManage); if (!actor) return; const target = db.prepare("SELECT * FROM users WHERE id = ?").get(userId); if (!target) { json(response, 404, { error: "User not found" }); return; } const body = JSON.parse((await readBody(request)) || "{}"); const nextRole = body.role === undefined ? target.role : String(body.role); const nextStatus = body.status === undefined ? target.status || "active" : String(body.status); if (!roles.has(nextRole)) { json(response, 400, { error: "Invalid role" }); return; } if (!statuses.has(nextStatus)) { json(response, 400, { error: "Invalid status" }); return; } if (Number(actor.id) === Number(userId) && nextStatus !== "active") { json(response, 400, { error: "You cannot disable your own account" }); return; } if (target.role === "system_admin" && nextRole !== "system_admin") { const adminCount = db.prepare("SELECT COUNT(*) AS count FROM users WHERE role = 'system_admin'").get().count; if (adminCount <= 1) { json(response, 400, { error: "At least one system admin is required" }); return; } } db.prepare("UPDATE users SET role = ?, status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run( nextRole, nextStatus, userId, ); json(response, 200, { user: publicUser(db.prepare("SELECT * FROM users WHERE id = ?").get(userId)) }); } export async function handleUsersApi(request, response, url) { try { if (url.pathname === "/api/admin/users" && request.method === "GET") { await handleListUsers(request, response); return true; } if (url.pathname === "/api/admin/users" && request.method === "POST") { await handleCreateUser(request, response); return true; } const match = url.pathname.match(/^\/api\/admin\/users\/(\d+)$/); if (match && request.method === "PATCH") { await handleUpdateUser(request, response, Number(match[1])); return true; } return false; } catch (error) { json(response, 500, { error: error instanceof Error ? error.message : "Unknown users API error", }); return true; } }