(() => { const PAGE_SIZE = 10; const ICONS = { user: ``, edit: ``, delete: ``, }; const el = (id) => document.getElementById(id); const t = (key, vars) => window.I18n?.t(key, vars) ?? key; const listEl = el("userList"); const emptyEl = el("userListEmpty"); const tableEl = el("usersTable"); const createBtnEl = el("userCreateBtn"); const filterInputEl = el("usersFilterInput"); const filterCountEl = el("usersFilterCount"); const pageLabelEl = el("usersPageLabel"); const dialogEl = el("userEditDialog"); const formEl = el("userEditForm"); const titleEl = el("userEditTitle"); const deleteBtnEl = el("userEditDeleteBtn"); const passwordFieldEl = el("userEditPasswordField"); const passwordHintEl = el("userEditPasswordHint"); const pinSectionEl = el("userEditPinSection"); const pinFieldEl = el("userEditPinField"); const pinHintEl = el("userEditPinHint"); const deleteConfirmDialogEl = el("userDeleteConfirmDialog"); const deleteConfirmTextEl = el("userDeleteConfirmText"); const fields = { displayName: el("userEditDisplayName"), username: el("userEditUsername"), password: el("userEditPassword"), email: el("userEditEmail"), group: el("userEditGroup"), enabled: el("userEditEnabled"), pinEnabled: el("userEditPinEnabled"), pin: el("userEditPin"), }; const store = { users: [], groups: [], editingId: null, pendingDeleteId: null, filter: "", page: 1, }; function canWrite() { if (!window.AuthApp?.canWrite) return true; return window.AuthApp.canWrite("users"); } function currentUserId() { return window.AuthApp?.getUser?.()?.id || ""; } function escapeHtml(str) { return String(str) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } async function apiJson(url, opts = {}) { const res = await fetch(url, { credentials: "include", ...opts }); const text = await res.text(); let data = null; try { data = text ? JSON.parse(text) : null; } catch { data = null; } if (!res.ok) throw new Error((data && data.error) || text || res.statusText); return data; } async function refreshAll() { const [usersData, groupsData] = await Promise.all([ apiJson("/api/users"), apiJson("/api/user_groups"), ]); store.users = Array.isArray(usersData.users) ? usersData.users : []; store.groups = Array.isArray(groupsData.groups) ? groupsData.groups : []; } function groupById(id) { return store.groups.find((g) => g.id === id) || null; } function groupAllowsPin(groupId) { return !!groupById(groupId)?.allow_pin; } function filteredUsers() { const q = store.filter.trim().toLowerCase(); let items = [...store.users].sort((a, b) => (a.display_name || a.username || "").localeCompare(b.display_name || b.username || ""), ); if (q) { items = items.filter((u) => { const name = (u.display_name || "").toLowerCase(); const username = (u.username || "").toLowerCase(); const email = (u.email || "").toLowerCase(); const group = (u.group_name || "").toLowerCase(); return name.includes(q) || username.includes(q) || email.includes(q) || group.includes(q); }); } return items; } function pageCount(total) { return Math.max(1, Math.ceil(total / PAGE_SIZE)); } function pagedItems(items) { const totalPages = pageCount(items.length); if (store.page > totalPages) store.page = totalPages; if (store.page < 1) store.page = 1; const start = (store.page - 1) * PAGE_SIZE; return items.slice(start, start + PAGE_SIZE); } function updatePagerUi(totalItems) { const totalPages = pageCount(totalItems); if (filterCountEl) filterCountEl.textContent = t("users.itemsFound", { n: totalItems }); if (pageLabelEl) pageLabelEl.textContent = t("users.pageOf", { page: store.page, total: totalPages }); const atStart = store.page <= 1; const atEnd = store.page >= totalPages; el("usersPageFirst")?.toggleAttribute("disabled", atStart); el("usersPagePrev")?.toggleAttribute("disabled", atStart); el("usersPageNext")?.toggleAttribute("disabled", atEnd); el("usersPageLast")?.toggleAttribute("disabled", atEnd); } function pinLabel(user) { if (!user.has_pin) return t("users.pinNo"); return t("users.pinYes"); } function renderList() { if (!listEl) return; const items = filteredUsers(); const pageItems = pagedItems(items); updatePagerUi(items.length); listEl.innerHTML = ""; const showEmpty = items.length === 0; if (tableEl) tableEl.hidden = showEmpty; if (emptyEl) { emptyEl.hidden = !showEmpty; emptyEl.textContent = store.filter.trim() ? t("users.emptyFilter") : t("users.empty"); } pageItems.forEach((user) => { const tr = document.createElement("tr"); tr.className = "mapsMirRow usersMirRow"; tr.dataset.id = user.id; const isSelf = user.id === currentUserId(); const actions = canWrite() ? `
` : ""; const disabledTag = user.enabled === false ? ` ${escapeHtml(t("common.disabled"))}` : ""; tr.innerHTML = ` ${ICONS.user} ${disabledTag} ${escapeHtml(user.username || "—")} ${escapeHtml(user.group_name || user.group_id || "—")} ${escapeHtml(user.email || "—")} ${escapeHtml(pinLabel(user))} ${actions}`; tr.querySelectorAll("[data-edit]").forEach((btn) => { btn.addEventListener("click", () => openDialog(btn.dataset.edit)); }); tr.querySelector("[data-delete]")?.addEventListener("click", () => openDeleteConfirm(user.id)); tr.addEventListener("dblclick", () => { if (canWrite()) openDialog(user.id); }); listEl.appendChild(tr); }); if (createBtnEl) createBtnEl.hidden = !canWrite(); } function fillGroupSelect(value) { if (!fields.group) return; fields.group.innerHTML = ""; store.groups.forEach((g) => { const o = document.createElement("option"); o.value = g.id; o.textContent = g.name || g.id; if (g.id === value) o.selected = true; fields.group.appendChild(o); }); } function syncPinUi() { const groupId = fields.group?.value || ""; const allowPin = groupAllowsPin(groupId); if (pinSectionEl) pinSectionEl.hidden = !allowPin; if (pinHintEl) pinHintEl.hidden = allowPin; if (!allowPin) { if (fields.pinEnabled) fields.pinEnabled.checked = false; if (pinFieldEl) pinFieldEl.hidden = true; if (fields.pin) fields.pin.value = ""; return; } const pinOn = fields.pinEnabled?.checked; if (pinFieldEl) pinFieldEl.hidden = !pinOn; if (!pinOn && fields.pin) fields.pin.value = ""; } function openDialog(id = null) { store.editingId = id; const existing = id ? store.users.find((x) => x.id === id) : null; const isEdit = !!existing; if (titleEl) titleEl.textContent = isEdit ? t("users.editTitle") : t("users.createTitle"); if (fields.displayName) fields.displayName.value = existing?.display_name || ""; if (fields.username) fields.username.value = existing?.username || ""; if (fields.email) fields.email.value = existing?.email || ""; if (fields.enabled) fields.enabled.checked = existing?.enabled !== false; if (fields.password) fields.password.value = ""; fillGroupSelect(existing?.group_id || store.groups[0]?.id || ""); if (fields.pinEnabled) fields.pinEnabled.checked = !!existing?.has_pin; if (fields.pin) fields.pin.value = ""; if (passwordFieldEl) passwordFieldEl.hidden = isEdit; if (passwordHintEl) passwordHintEl.hidden = !isEdit; if (fields.password) fields.password.required = !isEdit; const ro = !canWrite(); Object.values(fields).forEach((node) => { if (!node) return; node.disabled = ro; }); if (deleteBtnEl) { const isSelf = existing?.id === currentUserId(); deleteBtnEl.hidden = !isEdit || ro || isSelf; } syncPinUi(); dialogEl?.showModal(); } function readPayload(isCreate) { const payload = { display_name: fields.displayName?.value.trim() || "", username: fields.username?.value.trim() || "", email: fields.email?.value.trim() || "", group_id: fields.group?.value || "", enabled: fields.enabled?.checked !== false, }; if (isCreate) payload.password = fields.password?.value || ""; if (fields.pinEnabled?.checked && groupAllowsPin(payload.group_id)) { const pin = (fields.pin?.value || "").trim(); if (pin) { payload.pin = pin; } else if (!store.editingId) { payload.pin = null; } } else { payload.pin = null; } return payload; } async function saveDialog() { if (!canWrite()) return; const isCreate = !store.editingId; const payload = readPayload(isCreate); if (!payload.display_name || !payload.username || !payload.group_id) { alert(t("users.error.missing")); return; } if (isCreate && !payload.password) { alert(t("users.error.passwordRequired")); return; } if (fields.pinEnabled?.checked && groupAllowsPin(payload.group_id)) { const pin = (fields.pin?.value || "").trim(); const needPin = isCreate || !store.users.find((u) => u.id === store.editingId)?.has_pin; if (needPin && pin.length !== 4) { alert(t("users.error.pinInvalid")); return; } } try { if (store.editingId) { const updatePayload = { ...payload }; delete updatePayload.password; await apiJson(`/api/users/${encodeURIComponent(store.editingId)}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(updatePayload), }); } else { await apiJson("/api/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); } await refreshAll(); renderList(); dialogEl?.close(); } catch (e) { alert(e.message); } } function openDeleteConfirm(id) { const user = store.users.find((x) => x.id === id); if (!user || !canWrite() || user.id === currentUserId()) return; store.pendingDeleteId = id; if (deleteConfirmTextEl) { deleteConfirmTextEl.textContent = t("users.deleteConfirmText", { name: user.display_name || user.username, }); } deleteConfirmDialogEl?.showModal(); } function openDeleteConfirmFromDialog() { const id = store.editingId; if (!id) return; openDeleteConfirm(id); } async function confirmDelete() { const id = store.pendingDeleteId || store.editingId; if (!id || !canWrite()) return; try { await apiJson(`/api/users/${encodeURIComponent(id)}`, { method: "DELETE" }); deleteConfirmDialogEl?.close(); dialogEl?.close(); store.editingId = null; store.pendingDeleteId = null; await refreshAll(); renderList(); } catch (e) { alert(e.message); } } function clearFilters() { store.filter = ""; store.page = 1; if (filterInputEl) filterInputEl.value = ""; renderList(); } function bindEvents() { createBtnEl?.addEventListener("click", () => openDialog(null)); formEl?.addEventListener("submit", (evt) => { evt.preventDefault(); saveDialog(); }); el("userEditCancelBtn")?.addEventListener("click", () => dialogEl?.close()); dialogEl?.addEventListener("cancel", (evt) => { evt.preventDefault(); dialogEl?.close(); }); fields.group?.addEventListener("change", syncPinUi); fields.pinEnabled?.addEventListener("change", syncPinUi); deleteBtnEl?.addEventListener("click", openDeleteConfirmFromDialog); el("userDeleteCancelBtn")?.addEventListener("click", () => { store.pendingDeleteId = null; deleteConfirmDialogEl?.close(); }); el("userDeleteYesBtn")?.addEventListener("click", confirmDelete); deleteConfirmDialogEl?.addEventListener("cancel", (evt) => { evt.preventDefault(); store.pendingDeleteId = null; deleteConfirmDialogEl?.close(); }); filterInputEl?.addEventListener("input", () => { store.filter = filterInputEl.value; store.page = 1; renderList(); }); el("usersClearFiltersBtn")?.addEventListener("click", clearFilters); el("usersPageFirst")?.addEventListener("click", () => { store.page = 1; renderList(); }); el("usersPagePrev")?.addEventListener("click", () => { store.page = Math.max(1, store.page - 1); renderList(); }); el("usersPageNext")?.addEventListener("click", () => { store.page += 1; renderList(); }); el("usersPageLast")?.addEventListener("click", () => { store.page = pageCount(filteredUsers().length); renderList(); }); el("usersHelpBtn")?.addEventListener("click", () => alert(t("users.helpBody"))); window.addEventListener("lm:locale-change", () => renderList()); } async function onPageShow() { if (!window.AuthApp?.canAccessPage?.("users")) return; document.body.classList.toggle("auth-readonly-users", !canWrite()); try { await refreshAll(); renderList(); } catch (e) { if (emptyEl) { emptyEl.hidden = false; emptyEl.textContent = e.message; } if (tableEl) tableEl.hidden = true; } } function onPageHide() { dialogEl?.close(); deleteConfirmDialogEl?.close(); } bindEvents(); window.UsersApp = { onPageShow, onPageHide }; })();