408 lines
14 KiB
JavaScript
408 lines
14 KiB
JavaScript
(() => {
|
|
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 };
|
|
})();
|