This commit is contained in:
407
www/user-groups.js
Normal file
407
www/user-groups.js
Normal 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, "&")
|
||||
.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 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 };
|
||||
})();
|
||||
Reference in New Issue
Block a user