Add admin user management

This commit is contained in:
rajchales-monito
2026-07-29 22:27:59 +02:00
parent 913b576efd
commit cac3deb11a
4 changed files with 319 additions and 8 deletions
+130 -6
View File
@@ -85,6 +85,10 @@ async function adminApi(req, res, url) {
if (url.pathname === "/api/admin/bootstrap" && req.method === "GET") return adminBootstrap(res);
if (url.pathname === "/api/admin/events" && req.method === "GET") return eventStream(req, res, url, "admin");
if (url.pathname === "/api/admin/site" && req.method === "PATCH") return updateSite(req, res);
if (url.pathname === "/api/admin/users" && req.method === "GET") return adminUsers(res);
if (url.pathname === "/api/admin/users" && req.method === "POST") return createAdminUser(req, res);
if (url.pathname.match(/^\/api\/admin\/users\/\d+$/) && req.method === "PATCH") return updateAdminUser(req, res, url);
if (url.pathname.match(/^\/api\/admin\/users\/\d+$/) && req.method === "DELETE") return deleteAdminUser(req, res, url);
if (url.pathname === "/api/admin/ai/models" && req.method === "GET") return adminAiModels(res);
if (url.pathname === "/api/admin/ai/test" && req.method === "POST") return adminAiTest(res);
if (url.pathname === "/api/admin/conversations" && req.method === "GET") return adminConversations(res);
@@ -124,7 +128,10 @@ function initDb() {
company_id INTEGER NOT NULL REFERENCES companies(id),
username TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
password_hash TEXT,
is_system INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS conversations (
@@ -229,6 +236,9 @@ function initDb() {
ensureColumn("messages", "translated_language", "TEXT");
ensureColumn("messages", "translation_status", "TEXT");
ensureColumn("messages", "translation_error", "TEXT");
ensureColumn("admin_users", "password_hash", "TEXT");
ensureColumn("admin_users", "is_system", "INTEGER NOT NULL DEFAULT 0");
ensureColumn("admin_users", "updated_at", "TEXT");
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_client_id ON messages(conversation_id, client_message_id) WHERE client_message_id IS NOT NULL");
}
@@ -262,9 +272,13 @@ function seedDefaults() {
for (const user of configuredUsers()) {
db.prepare(`
INSERT OR IGNORE INTO admin_users (company_id, username, display_name)
VALUES (?, ?, ?)
`).run(companyId, user.username, user.username);
INSERT INTO admin_users (company_id, username, display_name, is_system, updated_at)
VALUES (?, ?, ?, 1, CURRENT_TIMESTAMP)
ON CONFLICT(username) DO UPDATE SET
display_name = excluded.display_name,
is_system = 1,
updated_at = CURRENT_TIMESTAMP
`).run(companyId, user.username, user.displayName || user.username);
}
}
@@ -319,12 +333,21 @@ function configuredUsers() {
}
function adminProfile(username) {
return configuredUsers().find((user) => user.username === username) || { username, displayName: username };
const configured = configuredUsers().find((user) => user.username === username);
if (configured) return { username: configured.username, displayName: configured.displayName };
const row = db.prepare("SELECT username, display_name FROM admin_users WHERE username = ?").get(username);
return row ? { username: row.username, displayName: row.display_name } : { username, displayName: username };
}
async function adminLogin(req, res) {
const body = await readJson(req);
const match = configuredUsers().find((u) => u.username === body.username && u.password === body.password);
const username = String(body.username || "").trim();
const password = String(body.password || "");
const configured = configuredUsers().find((u) => u.username === username && u.password === password);
const dbUser = configured ? null : db.prepare("SELECT * FROM admin_users WHERE username = ? AND password_hash IS NOT NULL").get(username);
const match = configured || (dbUser && verifyPassword(password, dbUser.password_hash)
? { username: dbUser.username, displayName: dbUser.display_name }
: null);
if (!match) return json(res, 401, { error: "bad_credentials" });
const token = signSession(match.username);
res.setHeader("Set-Cookie", `${COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${60 * 60 * 24 * 14}`);
@@ -453,6 +476,76 @@ async function updateSite(req, res) {
return json(res, 200, { site: formatSite(updated), snippet: snippetFor(updated) });
}
function adminUsers(res) {
return json(res, 200, { users: listAdminUsers() });
}
async function createAdminUser(req, res) {
const body = await readJson(req);
const companyId = ensureCompany();
const username = normalizeAdminUsername(body.username);
const displayName = normalizeDisplayName(body.displayName) || username;
const password = String(body.password || "");
if (!username) return json(res, 400, { error: "bad_username" });
if (!validAdminPassword(password)) return json(res, 400, { error: "bad_password" });
try {
db.prepare(`
INSERT INTO admin_users (company_id, username, display_name, password_hash, is_system, updated_at)
VALUES (?, ?, ?, ?, 0, CURRENT_TIMESTAMP)
`).run(companyId, username, displayName, hashPassword(password));
return json(res, 201, { users: listAdminUsers() });
} catch (error) {
if (String(error.message || "").includes("UNIQUE")) return json(res, 409, { error: "username_exists" });
throw error;
}
}
async function updateAdminUser(req, res, url) {
const body = await readJson(req);
const id = Number(url.pathname.split("/").pop());
const user = db.prepare("SELECT * FROM admin_users WHERE id = ?").get(id);
if (!user) return json(res, 404, { error: "user_not_found" });
const displayName = normalizeDisplayName(body.displayName);
const password = String(body.password || "");
if (displayName) {
if (user.is_system) return json(res, 400, { error: "system_user_profile_env" });
db.prepare("UPDATE admin_users SET display_name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(displayName, id);
}
if (password) {
if (user.is_system) return json(res, 400, { error: "system_user_password_env" });
if (!validAdminPassword(password)) return json(res, 400, { error: "bad_password" });
db.prepare("UPDATE admin_users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(hashPassword(password), id);
}
return json(res, 200, { users: listAdminUsers() });
}
function deleteAdminUser(req, res, url) {
const id = Number(url.pathname.split("/").pop());
const user = db.prepare("SELECT * FROM admin_users WHERE id = ?").get(id);
if (!user) return json(res, 404, { error: "user_not_found" });
if (user.is_system) return json(res, 400, { error: "system_user_protected" });
if (user.username === req.adminUser.username) return json(res, 400, { error: "cannot_delete_current_user" });
db.prepare("DELETE FROM admin_users WHERE id = ?").run(id);
return json(res, 200, { users: listAdminUsers() });
}
function listAdminUsers() {
return db.prepare(`
SELECT id, username, display_name, password_hash IS NOT NULL AS has_password, is_system, created_at, updated_at
FROM admin_users
ORDER BY username COLLATE NOCASE ASC
`).all().map((user) => ({
id: user.id,
username: user.username,
displayName: user.display_name,
hasPassword: Boolean(user.has_password || user.is_system),
isSystem: Boolean(user.is_system),
createdAt: user.created_at,
updatedAt: user.updated_at
}));
}
async function adminAiModels(res) {
const settings = getTranslationSettings();
const apiKey = settings.openaiApiKey;
@@ -1335,6 +1428,37 @@ function verifySession(token) {
return data.exp > Date.now() ? data.username : null;
}
function normalizeAdminUsername(value) {
const text = String(value || "").trim().toLowerCase();
return /^[a-z0-9._@-]{3,60}$/.test(text) ? text : "";
}
function normalizeDisplayName(value) {
return String(value || "").trim().slice(0, 80);
}
function validAdminPassword(value) {
const text = String(value || "");
return text.length >= 8 && text.length <= 200;
}
function hashPassword(password) {
const salt = crypto.randomBytes(16).toString("hex");
const iterations = 120000;
const hash = crypto.pbkdf2Sync(String(password), salt, iterations, 32, "sha256").toString("hex");
return `pbkdf2_sha256$${iterations}$${salt}$${hash}`;
}
function verifyPassword(password, storedHash) {
const [scheme, iterationsText, salt, hash] = String(storedHash || "").split("$");
if (scheme !== "pbkdf2_sha256" || !salt || !hash) return false;
const iterations = Number(iterationsText);
if (!Number.isFinite(iterations)) return false;
const candidate = crypto.pbkdf2Sync(String(password), salt, iterations, 32, "sha256");
const expected = Buffer.from(hash, "hex");
return expected.length === candidate.length && crypto.timingSafeEqual(candidate, expected);
}
function parseCookies(header) {
return Object.fromEntries(header.split(";").filter(Boolean).map((part) => {
const [key, ...value] = part.trim().split("=");