Add admin user management
This commit is contained in:
@@ -274,6 +274,54 @@ label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; }
|
|||||||
background: #edf3ef;
|
background: #edf3ef;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
|
.admin-users-settings {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
.admin-user-form {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.admin-user-form label:nth-child(3),
|
||||||
|
.admin-user-form button {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
.admin-users-list {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.admin-user-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto auto auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.admin-user-item div {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.admin-user-item span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.password-mask {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
.admin-user-item button:disabled {
|
||||||
|
opacity: .45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
#adminUsersStatus {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
.list-tools {
|
.list-tools {
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
|
|||||||
+18
-1
@@ -192,11 +192,28 @@
|
|||||||
<button id="refreshAiModels" type="button">Obnovit</button>
|
<button id="refreshAiModels" type="button">Obnovit</button>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="ai-actions">
|
<div class="ai-actions">
|
||||||
<button id="saveAiSettings" type="button">Ulozit AI nastaveni</button>
|
<button id="saveAiSettings" type="button">Ulozit AI nastaveni</button>
|
||||||
<button id="testOpenAiKey" type="button">Otestovat klic</button>
|
<button id="testOpenAiKey" type="button">Otestovat klic</button>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
<fieldset class="effect-settings admin-users-settings">
|
||||||
|
<legend>BO uzivatele</legend>
|
||||||
|
<form id="adminUserForm" class="admin-user-form">
|
||||||
|
<label>Login
|
||||||
|
<input name="username" autocomplete="off" placeholder="petr" required>
|
||||||
|
</label>
|
||||||
|
<label>Jmeno
|
||||||
|
<input name="displayName" autocomplete="off" placeholder="Petr">
|
||||||
|
</label>
|
||||||
|
<label>Heslo
|
||||||
|
<input name="password" type="password" autocomplete="new-password" placeholder="min. 8 znaku" required>
|
||||||
|
</label>
|
||||||
|
<button type="submit">Pridat uzivatele</button>
|
||||||
|
</form>
|
||||||
|
<div id="adminUsersList" class="admin-users-list"></div>
|
||||||
|
<small id="adminUsersStatus"></small>
|
||||||
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
+123
-1
@@ -19,7 +19,8 @@ const state = {
|
|||||||
attentionOn: false,
|
attentionOn: false,
|
||||||
originalTitle: document.title,
|
originalTitle: document.title,
|
||||||
originalFavicon: document.querySelector("link[rel='icon']")?.href || "/favicon.svg",
|
originalFavicon: document.querySelector("link[rel='icon']")?.href || "/favicon.svg",
|
||||||
aiModels: []
|
aiModels: [],
|
||||||
|
adminUsers: []
|
||||||
};
|
};
|
||||||
|
|
||||||
const $ = (selector) => document.querySelector(selector);
|
const $ = (selector) => document.querySelector(selector);
|
||||||
@@ -87,6 +88,8 @@ $("#openaiApiKey").addEventListener("input", markAiSettingsDirty);
|
|||||||
$("#refreshAiModels").addEventListener("click", loadAiModels);
|
$("#refreshAiModels").addEventListener("click", loadAiModels);
|
||||||
$("#saveAiSettings").addEventListener("click", saveTranslationSettings);
|
$("#saveAiSettings").addEventListener("click", saveTranslationSettings);
|
||||||
$("#testOpenAiKey").addEventListener("click", testOpenAiKey);
|
$("#testOpenAiKey").addEventListener("click", testOpenAiKey);
|
||||||
|
$("#adminUserForm").addEventListener("submit", createAdminUser);
|
||||||
|
$("#adminUsersList").addEventListener("click", handleAdminUserClick);
|
||||||
document.querySelectorAll("[data-settings-tab]").forEach((button) => {
|
document.querySelectorAll("[data-settings-tab]").forEach((button) => {
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
const tab = button.dataset.settingsTab;
|
const tab = button.dataset.settingsTab;
|
||||||
@@ -290,6 +293,7 @@ async function showApp() {
|
|||||||
$("#appView").classList.remove("hidden");
|
$("#appView").classList.remove("hidden");
|
||||||
await loadBootstrap();
|
await loadBootstrap();
|
||||||
await loadAiModels().catch(() => {});
|
await loadAiModels().catch(() => {});
|
||||||
|
await loadAdminUsers().catch(() => {});
|
||||||
await Promise.all([loadConversations(), loadVisitors()]);
|
await Promise.all([loadConversations(), loadVisitors()]);
|
||||||
connectEvents();
|
connectEvents();
|
||||||
}
|
}
|
||||||
@@ -487,6 +491,124 @@ function fallbackAiModels() {
|
|||||||
return ["gpt-5-mini", "gpt-5-nano", "gpt-5.1", "gpt-4.1-mini"];
|
return ["gpt-5-mini", "gpt-5-nano", "gpt-5.1", "gpt-4.1-mini"];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadAdminUsers() {
|
||||||
|
const data = await api("/api/admin/users");
|
||||||
|
state.adminUsers = data.users || [];
|
||||||
|
renderAdminUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createAdminUser(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = event.currentTarget;
|
||||||
|
const data = new FormData(form);
|
||||||
|
setAdminUsersStatus("Ukladam uzivatele...");
|
||||||
|
const result = await api("/api/admin/users", {
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
username: data.get("username"),
|
||||||
|
displayName: data.get("displayName"),
|
||||||
|
password: data.get("password")
|
||||||
|
}
|
||||||
|
}).catch((error) => error);
|
||||||
|
if (result?.users) {
|
||||||
|
state.adminUsers = result.users;
|
||||||
|
form.reset();
|
||||||
|
renderAdminUsers();
|
||||||
|
setAdminUsersStatus("Uzivatel pridan.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAdminUsersStatus(adminUserError(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAdminUserClick(event) {
|
||||||
|
const resetButton = event.target.closest("[data-reset-admin-user]");
|
||||||
|
const renameButton = event.target.closest("[data-rename-admin-user]");
|
||||||
|
const deleteButton = event.target.closest("[data-delete-admin-user]");
|
||||||
|
if (renameButton) {
|
||||||
|
const id = renameButton.dataset.renameAdminUser;
|
||||||
|
const user = state.adminUsers.find((item) => String(item.id) === String(id));
|
||||||
|
if (!user || user.isSystem) return;
|
||||||
|
const displayName = window.prompt(`Jmeno operatora pro ${user.username}`, user.displayName || user.username);
|
||||||
|
if (displayName === null) return;
|
||||||
|
setAdminUsersStatus("Ukladam jmeno...");
|
||||||
|
const result = await api(`/api/admin/users/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: { displayName }
|
||||||
|
}).catch((error) => error);
|
||||||
|
if (result?.users) {
|
||||||
|
state.adminUsers = result.users;
|
||||||
|
renderAdminUsers();
|
||||||
|
setAdminUsersStatus("Jmeno ulozeno.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAdminUsersStatus(adminUserError(result));
|
||||||
|
}
|
||||||
|
if (resetButton) {
|
||||||
|
const id = resetButton.dataset.resetAdminUser;
|
||||||
|
const user = state.adminUsers.find((item) => String(item.id) === String(id));
|
||||||
|
if (!user || user.isSystem) return;
|
||||||
|
const password = window.prompt(`Nove heslo pro ${user.username} (min. 8 znaku)`);
|
||||||
|
if (!password) return;
|
||||||
|
setAdminUsersStatus("Menim heslo...");
|
||||||
|
const result = await api(`/api/admin/users/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: { password }
|
||||||
|
}).catch((error) => error);
|
||||||
|
if (result?.users) {
|
||||||
|
state.adminUsers = result.users;
|
||||||
|
renderAdminUsers();
|
||||||
|
setAdminUsersStatus("Heslo zmeneno.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAdminUsersStatus(adminUserError(result));
|
||||||
|
}
|
||||||
|
if (deleteButton) {
|
||||||
|
const id = deleteButton.dataset.deleteAdminUser;
|
||||||
|
const user = state.adminUsers.find((item) => String(item.id) === String(id));
|
||||||
|
if (!user || user.isSystem) return;
|
||||||
|
if (!window.confirm(`Smazat BO uzivatele ${user.username}?`)) return;
|
||||||
|
setAdminUsersStatus("Mazu uzivatele...");
|
||||||
|
const result = await api(`/api/admin/users/${id}`, { method: "DELETE" }).catch((error) => error);
|
||||||
|
if (result?.users) {
|
||||||
|
state.adminUsers = result.users;
|
||||||
|
renderAdminUsers();
|
||||||
|
setAdminUsersStatus("Uzivatel smazan.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAdminUsersStatus(adminUserError(result));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAdminUsers() {
|
||||||
|
$("#adminUsersList").innerHTML = state.adminUsers.length ? state.adminUsers.map((user) => `
|
||||||
|
<article class="admin-user-item">
|
||||||
|
<div>
|
||||||
|
<strong>${escapeHtml(user.displayName || user.username)}</strong>
|
||||||
|
<span>${escapeHtml(user.username)} · ${user.isSystem ? "systemovy .env ucet" : "BO ucet"}</span>
|
||||||
|
</div>
|
||||||
|
<span class="password-mask" title="Heslo se nezobrazuje">${user.hasPassword ? "••••••••" : "bez hesla"}</span>
|
||||||
|
<button type="button" data-rename-admin-user="${user.id}" ${user.isSystem ? "disabled" : ""}>Jmeno</button>
|
||||||
|
<button type="button" data-reset-admin-user="${user.id}" ${user.isSystem ? "disabled" : ""}>Reset hesla</button>
|
||||||
|
<button type="button" data-delete-admin-user="${user.id}" ${user.isSystem ? "disabled" : ""}>Smazat</button>
|
||||||
|
</article>
|
||||||
|
`).join("") : `<p class="empty">Zatim tu nejsou zadni BO uzivatele.</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function adminUserError(error) {
|
||||||
|
if (error?.error === "bad_username") return "Login musi mit 3-60 znaku: pismena, cisla, tecka, pomlcka, podtrzitko nebo @.";
|
||||||
|
if (error?.error === "bad_password") return "Heslo musi mit aspon 8 znaku.";
|
||||||
|
if (error?.error === "username_exists") return "Tento login uz existuje.";
|
||||||
|
if (error?.error === "system_user_password_env") return "Systemovy .env ucet se meni na serveru v .env.";
|
||||||
|
if (error?.error === "system_user_profile_env") return "Systemovy .env ucet se prejmenovava na serveru v .env.";
|
||||||
|
if (error?.error === "system_user_protected") return "Systemovy .env ucet nejde smazat.";
|
||||||
|
if (error?.error === "cannot_delete_current_user") return "Nemuzes smazat uzivatele, pod kterym jsi prihlaseny.";
|
||||||
|
return `Akce se nepodarila (${error?.error || "neznama chyba"}).`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAdminUsersStatus(message) {
|
||||||
|
$("#adminUsersStatus").textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
function renderConversations() {
|
function renderConversations() {
|
||||||
const conversations = filteredConversations();
|
const conversations = filteredConversations();
|
||||||
$("#conversationList").innerHTML = conversations.length ? conversations.map((item) => `
|
$("#conversationList").innerHTML = conversations.length ? conversations.map((item) => `
|
||||||
|
|||||||
@@ -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/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/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/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/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/ai/test" && req.method === "POST") return adminAiTest(res);
|
||||||
if (url.pathname === "/api/admin/conversations" && req.method === "GET") return adminConversations(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),
|
company_id INTEGER NOT NULL REFERENCES companies(id),
|
||||||
username TEXT NOT NULL UNIQUE,
|
username TEXT NOT NULL UNIQUE,
|
||||||
display_name TEXT NOT NULL,
|
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 (
|
CREATE TABLE IF NOT EXISTS conversations (
|
||||||
@@ -229,6 +236,9 @@ function initDb() {
|
|||||||
ensureColumn("messages", "translated_language", "TEXT");
|
ensureColumn("messages", "translated_language", "TEXT");
|
||||||
ensureColumn("messages", "translation_status", "TEXT");
|
ensureColumn("messages", "translation_status", "TEXT");
|
||||||
ensureColumn("messages", "translation_error", "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");
|
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()) {
|
for (const user of configuredUsers()) {
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT OR IGNORE INTO admin_users (company_id, username, display_name)
|
INSERT INTO admin_users (company_id, username, display_name, is_system, updated_at)
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?, ?, 1, CURRENT_TIMESTAMP)
|
||||||
`).run(companyId, user.username, user.username);
|
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) {
|
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) {
|
async function adminLogin(req, res) {
|
||||||
const body = await readJson(req);
|
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" });
|
if (!match) return json(res, 401, { error: "bad_credentials" });
|
||||||
const token = signSession(match.username);
|
const token = signSession(match.username);
|
||||||
res.setHeader("Set-Cookie", `${COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${60 * 60 * 24 * 14}`);
|
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) });
|
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) {
|
async function adminAiModels(res) {
|
||||||
const settings = getTranslationSettings();
|
const settings = getTranslationSettings();
|
||||||
const apiKey = settings.openaiApiKey;
|
const apiKey = settings.openaiApiKey;
|
||||||
@@ -1335,6 +1428,37 @@ function verifySession(token) {
|
|||||||
return data.exp > Date.now() ? data.username : null;
|
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) {
|
function parseCookies(header) {
|
||||||
return Object.fromEntries(header.split(";").filter(Boolean).map((part) => {
|
return Object.fromEntries(header.split(";").filter(Boolean).map((part) => {
|
||||||
const [key, ...value] = part.trim().split("=");
|
const [key, ...value] = part.trim().split("=");
|
||||||
|
|||||||
Reference in New Issue
Block a user