update User, User group, IO modules
Some checks failed
Test / test (push) Has been cancelled

This commit is contained in:
2026-06-23 11:06:26 +07:00
parent 523f98b74c
commit 50a2587cef
32 changed files with 4728 additions and 52 deletions

441
www/users.js Normal file
View File

@@ -0,0 +1,441 @@
(() => {
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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 };
})();