442 lines
15 KiB
JavaScript
442 lines
15 KiB
JavaScript
(() => {
|
|
const PAGE_SIZE = 10;
|
|
|
|
const ICONS = {
|
|
user: `<svg class="usersMirIcon" width="20" height="20" viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="7" r="3.5" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M4 17c0-3.3 2.7-6 6-6s6 2.7 6 6" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>`,
|
|
edit: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M9.5 2.5l2 2L5 11H3v-2L9.5 2.5z" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>`,
|
|
delete: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M4 4l6 6M10 4l-6 6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>`,
|
|
};
|
|
|
|
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, ">")
|
|
.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()
|
|
? `<div class="mapsMirRowActions">
|
|
<button type="button" class="mapsMirIconBtn" data-edit="${escapeHtml(user.id)}" title="${escapeHtml(t("common.edit"))}">${ICONS.edit}</button>
|
|
<button type="button" class="mapsMirIconBtn mapsMirIconBtn--danger" data-delete="${escapeHtml(user.id)}" title="${escapeHtml(t("common.delete"))}" ${isSelf ? "disabled" : ""}>${ICONS.delete}</button>
|
|
</div>`
|
|
: "";
|
|
|
|
const disabledTag = user.enabled === false ? ` <span class="usersMirDisabledTag">${escapeHtml(t("common.disabled"))}</span>` : "";
|
|
|
|
tr.innerHTML = `
|
|
<td class="usersMirCellIcon">${ICONS.user}</td>
|
|
<td class="usersMirCellName">
|
|
<button type="button" class="mapsMirNameLink usersMirNameLink" data-edit="${escapeHtml(user.id)}">${escapeHtml(user.display_name || user.username)}</button>${disabledTag}
|
|
</td>
|
|
<td>${escapeHtml(user.username || "—")}</td>
|
|
<td>${escapeHtml(user.group_name || user.group_id || "—")}</td>
|
|
<td>${escapeHtml(user.email || "—")}</td>
|
|
<td>${escapeHtml(pinLabel(user))}</td>
|
|
<td class="mapsMirCellActions">${actions}</td>`;
|
|
|
|
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 };
|
|
})();
|