diff --git a/public/admin.css b/public/admin.css index 0cc27fe..847a862 100644 --- a/public/admin.css +++ b/public/admin.css @@ -274,6 +274,54 @@ label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; } background: #edf3ef; 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 { padding: 10px 14px; border-bottom: 1px solid var(--line); diff --git a/public/admin.html b/public/admin.html index 46a5ae2..1e7daa9 100644 --- a/public/admin.html +++ b/public/admin.html @@ -192,11 +192,28 @@ -
+
+
+ BO uzivatele +
+ + + + +
+
+ +
diff --git a/public/admin.js b/public/admin.js index e355454..0cc5be1 100644 --- a/public/admin.js +++ b/public/admin.js @@ -19,7 +19,8 @@ const state = { attentionOn: false, originalTitle: document.title, originalFavicon: document.querySelector("link[rel='icon']")?.href || "/favicon.svg", - aiModels: [] + aiModels: [], + adminUsers: [] }; const $ = (selector) => document.querySelector(selector); @@ -87,6 +88,8 @@ $("#openaiApiKey").addEventListener("input", markAiSettingsDirty); $("#refreshAiModels").addEventListener("click", loadAiModels); $("#saveAiSettings").addEventListener("click", saveTranslationSettings); $("#testOpenAiKey").addEventListener("click", testOpenAiKey); +$("#adminUserForm").addEventListener("submit", createAdminUser); +$("#adminUsersList").addEventListener("click", handleAdminUserClick); document.querySelectorAll("[data-settings-tab]").forEach((button) => { button.addEventListener("click", () => { const tab = button.dataset.settingsTab; @@ -290,6 +293,7 @@ async function showApp() { $("#appView").classList.remove("hidden"); await loadBootstrap(); await loadAiModels().catch(() => {}); + await loadAdminUsers().catch(() => {}); await Promise.all([loadConversations(), loadVisitors()]); connectEvents(); } @@ -487,6 +491,124 @@ function fallbackAiModels() { 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) => ` +
+
+ ${escapeHtml(user.displayName || user.username)} + ${escapeHtml(user.username)} · ${user.isSystem ? "systemovy .env ucet" : "BO ucet"} +
+ ${user.hasPassword ? "••••••••" : "bez hesla"} + + + +
+ `).join("") : `

Zatim tu nejsou zadni BO uzivatele.

`; +} + +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() { const conversations = filteredConversations(); $("#conversationList").innerHTML = conversations.length ? conversations.map((item) => ` diff --git a/server.js b/server.js index 8bd6e2e..6406afd 100644 --- a/server.js +++ b/server.js @@ -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("=");