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

407
www/user-groups.js Normal file
View File

@@ -0,0 +1,407 @@
(() => {
const PAGE_SIZE = 10;
const PERM_RESOURCES = [
{ key: "dashboard", labelKey: "userGroups.perm.dashboard" },
{ key: "config", labelKey: "userGroups.perm.config" },
{ key: "maps", labelKey: "userGroups.perm.maps" },
{ key: "missions", labelKey: "userGroups.perm.missions" },
{ key: "sounds", labelKey: "userGroups.perm.sounds" },
{ key: "integrations", labelKey: "userGroups.perm.integrations" },
{ key: "users", labelKey: "userGroups.perm.users" },
];
const ICONS = {
group: `<svg class="userGroupsMirIcon" width="20" height="20" viewBox="0 0 20 20" aria-hidden="true"><circle cx="7" cy="7" r="2.8" fill="none" stroke="currentColor" stroke-width="1.3"/><circle cx="13" cy="7" r="2.8" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M3.5 16c0-2.5 1.6-4.5 3.5-4.5s3.5 2 3.5 4.5M10 16c0-2.5 1.6-4.5 3.5-4.5s3.5 2 3.5 4.5" fill="none" stroke="currentColor" stroke-width="1.3" 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("userGroupList");
const emptyEl = el("userGroupListEmpty");
const tableEl = el("userGroupsTable");
const filterInputEl = el("userGroupsFilterInput");
const filterCountEl = el("userGroupsFilterCount");
const pageLabelEl = el("userGroupsPageLabel");
const dialogEl = el("userGroupEditDialog");
const formEl = el("userGroupEditForm");
const titleEl = el("userGroupEditTitle");
const deleteBtnEl = el("userGroupEditDeleteBtn");
const permsBodyEl = el("userGroupEditPermsBody");
const deleteConfirmDialogEl = el("userGroupDeleteConfirmDialog");
const deleteConfirmTextEl = el("userGroupDeleteConfirmText");
const fields = {
name: el("userGroupEditName"),
allowPin: el("userGroupEditAllowPin"),
};
const store = {
groups: [],
editingId: null,
pendingDeleteId: null,
filter: "",
page: 1,
permSelects: {},
};
function canWrite() {
if (!window.AuthApp?.canWrite) return true;
return window.AuthApp.canWrite("users");
}
function isDistributor() {
return window.AuthApp?.getUser?.()?.group_id === "group_distributors";
}
function canManageGroup(group) {
if (!group) return canWrite();
if (isDistributor()) return canWrite();
return canWrite() && group.id !== "group_distributors" && group.id !== "group_administrators";
}
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 data = await apiJson("/api/user_groups");
store.groups = Array.isArray(data.groups) ? data.groups : [];
}
function groupById(id) {
return store.groups.find((g) => g.id === id) || null;
}
function filteredGroups() {
const q = store.filter.trim().toLowerCase();
let items = [...store.groups].sort((a, b) => (a.name || "").localeCompare(b.name || ""));
if (q) {
items = items.filter((g) => (g.name || "").toLowerCase().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("userGroups.itemsFound", { n: totalItems });
if (pageLabelEl) pageLabelEl.textContent = t("userGroups.pageOf", { page: store.page, total: totalPages });
const atStart = store.page <= 1;
const atEnd = store.page >= totalPages;
el("userGroupsPageFirst")?.toggleAttribute("disabled", atStart);
el("userGroupsPagePrev")?.toggleAttribute("disabled", atStart);
el("userGroupsPageNext")?.toggleAttribute("disabled", atEnd);
el("userGroupsPageLast")?.toggleAttribute("disabled", atEnd);
}
function permSummary(group) {
const perms = group.permissions || {};
const writeCount = PERM_RESOURCES.filter((r) => perms[r.key] === "write").length;
const readCount = PERM_RESOURCES.filter((r) => perms[r.key] === "read").length;
if (writeCount === PERM_RESOURCES.length) return t("userGroups.permSummaryAllWrite");
if (writeCount === 0 && readCount === 0) return t("userGroups.permSummaryNone");
return t("userGroups.permSummaryMixed", { write: writeCount, read: readCount });
}
function renderList() {
if (!listEl) return;
const items = filteredGroups();
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("userGroups.emptyFilter") : t("userGroups.empty");
}
pageItems.forEach((group) => {
const tr = document.createElement("tr");
tr.className = "mapsMirRow userGroupsMirRow";
tr.dataset.id = group.id;
const manageable = canManageGroup(group);
const canDelete = manageable && !group.builtin && (group.user_count || 0) === 0;
const actions = canWrite()
? `<div class="mapsMirRowActions">
<button type="button" class="mapsMirIconBtn userGroupEditBtn" data-edit="${escapeHtml(group.id)}" title="${escapeHtml(t("common.edit"))}" ${manageable ? "" : "disabled"}>${ICONS.edit}</button>
<button type="button" class="mapsMirIconBtn mapsMirIconBtn--danger userGroupDeleteBtn" data-delete="${escapeHtml(group.id)}" title="${escapeHtml(t("common.delete"))}" ${canDelete ? "" : "disabled"}>${ICONS.delete}</button>
</div>`
: "";
const pinLabel = group.allow_pin ? t("userGroups.pinYes") : t("userGroups.pinNo");
const usersLabel = t("userGroups.userCount", { n: group.user_count || 0 });
tr.innerHTML = `
<td class="userGroupsMirCellIcon">${ICONS.group}</td>
<td class="userGroupsMirCellName">
<button type="button" class="mapsMirNameLink userGroupsMirNameLink" data-edit="${escapeHtml(group.id)}" ${manageable ? "" : "disabled"}>${escapeHtml(group.name || "—")}</button>
</td>
<td>${escapeHtml(usersLabel)}</td>
<td>${escapeHtml(pinLabel)}</td>
<td class="userGroupsMirCellPerms">${escapeHtml(permSummary(group))}</td>
<td class="mapsMirCellFunctions">${actions}</td>
`;
listEl.appendChild(tr);
});
}
function buildPermRows(permissions = {}) {
if (!permsBodyEl) return;
permsBodyEl.innerHTML = "";
store.permSelects = {};
PERM_RESOURCES.forEach((res) => {
const tr = document.createElement("tr");
const select = document.createElement("select");
select.className = "userGroupsPermSelect";
select.dataset.resource = res.key;
["none", "read", "write"].forEach((level) => {
const opt = document.createElement("option");
opt.value = level;
opt.textContent = t(`userGroups.permLevel.${level}`);
select.appendChild(opt);
});
select.value = permissions[res.key] || "none";
store.permSelects[res.key] = select;
tr.innerHTML = `<th scope="row">${escapeHtml(t(res.labelKey))}</th>`;
const td = document.createElement("td");
td.appendChild(select);
tr.appendChild(td);
permsBodyEl.appendChild(tr);
});
}
function readPermissionsFromForm() {
const perms = {};
PERM_RESOURCES.forEach((res) => {
const select = store.permSelects[res.key];
perms[res.key] = select?.value || "none";
});
return perms;
}
function openCreateDialog() {
store.editingId = null;
if (titleEl) titleEl.textContent = t("userGroups.createTitle");
if (fields.name) {
fields.name.value = "";
fields.name.disabled = false;
}
if (fields.allowPin) fields.allowPin.checked = false;
buildPermRows(defaultPermissionsForNewGroup());
deleteBtnEl?.toggleAttribute("hidden", true);
dialogEl?.showModal();
}
function defaultPermissionsForNewGroup() {
return {
dashboard: "write",
config: "none",
maps: "none",
missions: "read",
sounds: "read",
integrations: "read",
users: "none",
};
}
function openEditDialog(id) {
const group = groupById(id);
if (!group || !canManageGroup(group)) return;
store.editingId = id;
if (titleEl) titleEl.textContent = t("userGroups.editTitle");
if (fields.name) fields.name.value = group.name || "";
if (fields.allowPin) fields.allowPin.checked = !!group.allow_pin;
buildPermRows(group.permissions || {});
const canDelete = !group.builtin && (group.user_count || 0) === 0;
deleteBtnEl?.toggleAttribute("hidden", !canDelete);
dialogEl?.showModal();
}
async function saveGroup(evt) {
evt?.preventDefault();
const name = (fields.name?.value || "").trim();
if (!name) {
alert(t("userGroups.error.missing"));
return;
}
const payload = {
name,
allow_pin: !!fields.allowPin?.checked,
permissions: readPermissionsFromForm(),
};
try {
if (store.editingId) {
await apiJson(`/api/user_groups/${encodeURIComponent(store.editingId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
} else {
await apiJson("/api/user_groups", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
}
dialogEl?.close();
await refreshAll();
renderList();
} catch (e) {
alert(e.message);
}
}
function openDeleteConfirm(id) {
const group = groupById(id);
if (!group || group.builtin || (group.user_count || 0) > 0) return;
store.pendingDeleteId = id;
if (deleteConfirmTextEl) {
deleteConfirmTextEl.textContent = t("userGroups.deleteConfirmText", { name: group.name || "" });
}
deleteConfirmDialogEl?.showModal();
}
function openDeleteConfirmFromDialog() {
if (store.editingId) openDeleteConfirm(store.editingId);
}
async function confirmDelete() {
const id = store.pendingDeleteId;
if (!id) return;
try {
await apiJson(`/api/user_groups/${encodeURIComponent(id)}`, { method: "DELETE" });
store.pendingDeleteId = null;
deleteConfirmDialogEl?.close();
dialogEl?.close();
await refreshAll();
renderList();
} catch (e) {
alert(e.message);
}
}
function clearFilters() {
store.filter = "";
store.page = 1;
if (filterInputEl) filterInputEl.value = "";
renderList();
}
function bindEvents() {
el("userGroupCreateBtn")?.addEventListener("click", () => {
if (canWrite()) openCreateDialog();
});
formEl?.addEventListener("submit", saveGroup);
el("userGroupEditCancelBtn")?.addEventListener("click", () => dialogEl?.close());
deleteBtnEl?.addEventListener("click", openDeleteConfirmFromDialog);
listEl?.addEventListener("click", (evt) => {
const editBtn = evt.target.closest("[data-edit]");
const deleteBtn = evt.target.closest("[data-delete]");
if (editBtn?.dataset.edit) openEditDialog(editBtn.dataset.edit);
else if (deleteBtn?.dataset.delete) openDeleteConfirm(deleteBtn.dataset.delete);
});
deleteConfirmDialogEl?.addEventListener("cancel", (evt) => {
evt.preventDefault();
store.pendingDeleteId = null;
deleteConfirmDialogEl?.close();
});
el("userGroupDeleteCancelBtn")?.addEventListener("click", () => {
store.pendingDeleteId = null;
deleteConfirmDialogEl?.close();
});
el("userGroupDeleteYesBtn")?.addEventListener("click", confirmDelete);
filterInputEl?.addEventListener("input", () => {
store.filter = filterInputEl.value;
store.page = 1;
renderList();
});
el("userGroupsClearFiltersBtn")?.addEventListener("click", clearFilters);
el("userGroupsPageFirst")?.addEventListener("click", () => {
store.page = 1;
renderList();
});
el("userGroupsPagePrev")?.addEventListener("click", () => {
store.page = Math.max(1, store.page - 1);
renderList();
});
el("userGroupsPageNext")?.addEventListener("click", () => {
store.page += 1;
renderList();
});
el("userGroupsPageLast")?.addEventListener("click", () => {
store.page = pageCount(filteredGroups().length);
renderList();
});
el("userGroupsHelpBtn")?.addEventListener("click", () => alert(t("userGroups.helpBody")));
window.addEventListener("lm:locale-change", () => {
if (store.editingId || dialogEl?.open) {
const perms = readPermissionsFromForm();
buildPermRows(perms);
}
renderList();
});
}
async function onPageShow() {
if (!window.AuthApp?.canAccessPage?.("user-groups")) return;
document.body.classList.toggle("auth-readonly-user-groups", !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.UserGroupsApp = { onPageShow, onPageHide };
})();