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;
|
||||
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);
|
||||
|
||||
+18
-1
@@ -192,11 +192,28 @@
|
||||
<button id="refreshAiModels" type="button">Obnovit</button>
|
||||
</span>
|
||||
</label>
|
||||
<div class="ai-actions">
|
||||
<div class="ai-actions">
|
||||
<button id="saveAiSettings" type="button">Ulozit AI nastaveni</button>
|
||||
<button id="testOpenAiKey" type="button">Otestovat klic</button>
|
||||
</div>
|
||||
</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>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
+123
-1
@@ -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) => `
|
||||
<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() {
|
||||
const conversations = filteredConversations();
|
||||
$("#conversationList").innerHTML = conversations.length ? conversations.map((item) => `
|
||||
|
||||
Reference in New Issue
Block a user