Add admin user management
This commit is contained in:
@@ -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("=");
|
||||
|
||||
Reference in New Issue
Block a user