RBAC 3.0
This commit is contained in:
@@ -116,6 +116,39 @@ function isAdmin(role) {
|
||||
return role === "admin";
|
||||
}
|
||||
|
||||
function userHas(perm) {
|
||||
return !!currentUser?.permissions?.includes(perm);
|
||||
}
|
||||
|
||||
function applyPermissionVisibility() {
|
||||
document.querySelectorAll("[data-permission]").forEach((el) => {
|
||||
const perm = el.dataset.permission;
|
||||
if (perm && userHas(perm)) {
|
||||
el.classList.remove("hidden");
|
||||
} else {
|
||||
el.classList.add("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
const sidebar = document.getElementById("settings-sidebar");
|
||||
if (!sidebar) return;
|
||||
const children = Array.from(sidebar.children);
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const label = children[i];
|
||||
if (!label.classList.contains("settings-nav-group-label")) continue;
|
||||
let hasVisibleLink = false;
|
||||
for (let j = i + 1; j < children.length; j++) {
|
||||
const next = children[j];
|
||||
if (next.classList.contains("settings-nav-group-label")) break;
|
||||
if (next.classList.contains("settings-nav-link") && !next.classList.contains("hidden")) {
|
||||
hasVisibleLink = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
label.classList.toggle("hidden", !hasVisibleLink);
|
||||
}
|
||||
}
|
||||
|
||||
function getPasswordRequirementsText() {
|
||||
const minLength = Number(securityConfig?.passwordMinLength) || 6;
|
||||
const requirements = [`min ${minLength} chars`];
|
||||
@@ -820,19 +853,6 @@ function initSettingsSidebar() {
|
||||
if (!sidebar) return;
|
||||
const links = Array.from(sidebar.querySelectorAll(".settings-nav-link"));
|
||||
|
||||
// Hide nav links and group labels whose target sections are admin-only /
|
||||
// admin-or-operator when the current user isn't authorized. We hide rather
|
||||
// than disable so the sidebar stays uncluttered for non-admins.
|
||||
const gated = sidebar.querySelectorAll("[data-admin-only], [data-admin-or-operator]");
|
||||
for (const el of gated) {
|
||||
if (el.dataset.adminOnly !== undefined && !isAdmin(currentUser?.role)) {
|
||||
el.classList.add("hidden");
|
||||
}
|
||||
if (el.dataset.adminOrOperator !== undefined && !canManageBuilds(currentUser?.role)) {
|
||||
el.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
// Build the section→link map up front so click + observer share it.
|
||||
const sectionMap = new Map();
|
||||
// sectionsInOrder preserves document order for "last visible at page bottom"
|
||||
@@ -1340,9 +1360,12 @@ async function handleRemoveInactiveSessions() {
|
||||
|
||||
async function loadRegistrationSettings() {
|
||||
try {
|
||||
const res = await fetch("/api/settings/registration", { credentials: "include" });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const [regRes, groupsRes] = await Promise.all([
|
||||
fetch("/api/settings/registration", { credentials: "include" }),
|
||||
fetch("/api/permission-groups", { credentials: "include" }),
|
||||
]);
|
||||
if (!regRes.ok) return;
|
||||
const data = await regRes.json();
|
||||
const reg = data.registration || {};
|
||||
const modeEl = document.getElementById("reg-mode");
|
||||
const roleEl = document.getElementById("reg-default-role");
|
||||
@@ -1350,6 +1373,27 @@ async function loadRegistrationSettings() {
|
||||
if (modeEl) modeEl.value = reg.mode || "off";
|
||||
if (roleEl) roleEl.value = reg.defaultRole || "operator";
|
||||
if (maxEl) maxEl.value = reg.maxUsersTotal ?? 0;
|
||||
|
||||
const listEl = document.getElementById("reg-default-groups-list");
|
||||
if (listEl) {
|
||||
const groups = groupsRes.ok ? (await groupsRes.json()).groups || [] : [];
|
||||
const selected = new Set(reg.defaultGroupIds || []);
|
||||
listEl.innerHTML = groups.length === 0
|
||||
? `<p class="text-slate-500 text-sm">No groups defined yet. Create one on the <a href="/users" class="text-sky-400 hover:underline">Users page</a>.</p>`
|
||||
: groups
|
||||
.map(
|
||||
(g) => `
|
||||
<label class="flex items-start gap-2 py-1 cursor-pointer hover:bg-slate-800/40 rounded px-2">
|
||||
<input type="checkbox" class="mt-1 h-4 w-4 accent-emerald-500" data-default-group="${g.id}" ${selected.has(g.id) ? "checked" : ""} />
|
||||
<div>
|
||||
<div class="text-sm font-medium text-slate-200">${g.name.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]))}</div>
|
||||
<div class="text-xs text-slate-500">${g.permissions.length} permission${g.permissions.length === 1 ? "" : "s"}</div>
|
||||
</div>
|
||||
</label>`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
updateRegSubsections(reg.mode || "off");
|
||||
if (reg.mode === "key") loadRegistrationKeys();
|
||||
if (reg.mode === "approval") loadPendingRegistrations();
|
||||
@@ -1379,13 +1423,16 @@ async function saveRegistrationSettings(e) {
|
||||
const mode = document.getElementById("reg-mode")?.value;
|
||||
const defaultRole = document.getElementById("reg-default-role")?.value;
|
||||
const maxUsersTotal = Number(document.getElementById("reg-max-users")?.value) || 0;
|
||||
const defaultGroupIds = Array.from(
|
||||
document.querySelectorAll("#reg-default-groups-list input[type=checkbox]:checked"),
|
||||
).map((el) => Number(el.dataset.defaultGroup)).filter((n) => Number.isFinite(n));
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/settings/registration", {
|
||||
method: "PUT",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mode, defaultRole, maxUsersTotal }),
|
||||
body: JSON.stringify({ mode, defaultRole, maxUsersTotal, defaultGroupIds }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
@@ -1662,19 +1709,10 @@ function initThumbnailHandlers() {
|
||||
async function init() {
|
||||
try {
|
||||
await loadCurrentUser();
|
||||
applyPermissionVisibility();
|
||||
loadPrefs();
|
||||
|
||||
if (isAdmin(currentUser?.role) && exportImportSection) {
|
||||
exportImportSection.classList.remove("hidden");
|
||||
}
|
||||
|
||||
if (canManageClientBans(currentUser?.role) && wipeOfflineSection) {
|
||||
wipeOfflineSection.classList.remove("hidden");
|
||||
}
|
||||
|
||||
if (canManageBuilds(currentUser?.role)) {
|
||||
if (buildsSection) buildsSection.classList.remove("hidden");
|
||||
// Hide the "show all" toggle for non-admins
|
||||
if (userHas("clients:build")) {
|
||||
if (buildsShowAllWrap && !isAdmin(currentUser?.role)) {
|
||||
buildsShowAllWrap.classList.add("hidden");
|
||||
}
|
||||
@@ -1687,14 +1725,7 @@ async function init() {
|
||||
await loadChatSettings();
|
||||
await loadBannedIps();
|
||||
|
||||
// Registration + Build Rate Limit + Thumbnails (admin only)
|
||||
if (isAdmin(currentUser?.role)) {
|
||||
const regSection = document.getElementById("registration-section");
|
||||
const brlSection = document.getElementById("build-rate-limit-section");
|
||||
const thumbSection = document.getElementById("thumbnails-section");
|
||||
if (regSection) regSection.classList.remove("hidden");
|
||||
if (brlSection) brlSection.classList.remove("hidden");
|
||||
if (thumbSection) thumbSection.classList.remove("hidden");
|
||||
if (userHas("system:configure")) {
|
||||
await loadRegistrationSettings();
|
||||
await loadBuildRateLimitSettings();
|
||||
await loadThumbnailSettings();
|
||||
|
||||
@@ -231,7 +231,18 @@ function renderUsers() {
|
||||
<i class="fa-solid fa-puzzle-piece"></i>
|
||||
</button>
|
||||
` : ''}
|
||||
<button
|
||||
${user.role !== "admin" ? `
|
||||
<button
|
||||
class="user-action-btn px-3 py-1.5 text-sm bg-violet-900/30 hover:bg-violet-900/50 text-violet-300 rounded border border-violet-800 transition-colors"
|
||||
data-action="permissions"
|
||||
data-user-id="${user.id}"
|
||||
data-username="${escapeHtml(user.username)}"
|
||||
title="Permission Groups & Extras"
|
||||
>
|
||||
<i class="fa-solid fa-layer-group"></i>
|
||||
</button>
|
||||
` : ''}
|
||||
<button
|
||||
class="user-action-btn px-3 py-1.5 text-sm bg-cyan-900/30 hover:bg-cyan-900/50 text-cyan-300 rounded border border-cyan-800 transition-colors"
|
||||
data-action="view-sessions"
|
||||
data-user-id="${user.id}"
|
||||
@@ -329,6 +340,9 @@ function attachActionListeners() {
|
||||
case "view-sessions":
|
||||
viewUserSessions(userId, username);
|
||||
break;
|
||||
case "permissions":
|
||||
openUserPermissionsModal(userId, username);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -512,6 +526,10 @@ const FEATURE_LABELS = {
|
||||
processes: { label: "Processes", icon: "fa-microchip" },
|
||||
keylogger: { label: "Keylogger", icon: "fa-keyboard" },
|
||||
voice: { label: "Voice", icon: "fa-microphone" },
|
||||
disconnect: { label: "Disconnect Client", icon: "fa-plug-circle-xmark" },
|
||||
reconnect: { label: "Reconnect Client", icon: "fa-arrows-rotate" },
|
||||
uninstall: { label: "Uninstall Agent", icon: "fa-trash-can" },
|
||||
client_metadata: { label: "Edit Client Metadata", icon: "fa-pen-to-square" },
|
||||
};
|
||||
|
||||
window.configureFeaturePermissions = async function (userId, username) {
|
||||
@@ -899,5 +917,276 @@ function formatRelTime(epochSeconds) {
|
||||
return `${Math.floor(diff / 86400)}d ago`;
|
||||
}
|
||||
|
||||
let allPermissions = [];
|
||||
let allGroups = [];
|
||||
let editingGroupId = null;
|
||||
let editingUserId = null;
|
||||
|
||||
async function loadPermissionsAndGroups() {
|
||||
try {
|
||||
const [permsRes, groupsRes] = await Promise.all([
|
||||
fetch("/api/permissions"),
|
||||
fetch("/api/permission-groups"),
|
||||
]);
|
||||
if (permsRes.ok) {
|
||||
const data = await permsRes.json();
|
||||
allPermissions = Array.isArray(data.permissions) ? data.permissions : [];
|
||||
}
|
||||
if (groupsRes.ok) {
|
||||
const data = await groupsRes.json();
|
||||
allGroups = Array.isArray(data.groups) ? data.groups : [];
|
||||
}
|
||||
renderGroupsList();
|
||||
} catch (err) {
|
||||
console.error("Failed to load permissions/groups:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function renderGroupsList() {
|
||||
const container = document.getElementById("groups-list");
|
||||
if (!container) return;
|
||||
if (allGroups.length === 0) {
|
||||
container.innerHTML = `<p class="text-slate-500 text-sm text-center py-4">No groups yet. Click "New Group" to create one.</p>`;
|
||||
return;
|
||||
}
|
||||
container.innerHTML = allGroups
|
||||
.map(
|
||||
(g) => `
|
||||
<div class="flex items-start justify-between gap-3 p-3 bg-slate-800/40 border border-slate-700 rounded-lg">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium text-slate-100">${escapeHtml(g.name)}</div>
|
||||
${g.description ? `<div class="text-xs text-slate-400 mt-0.5">${escapeHtml(g.description)}</div>` : ""}
|
||||
<div class="text-xs text-slate-500 mt-1">
|
||||
${g.permissions.length} permission${g.permissions.length === 1 ? "" : "s"}:
|
||||
<span class="text-slate-400">${g.permissions.map((p) => escapeHtml(p)).join(", ") || "<em>none</em>"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-1 flex-shrink-0">
|
||||
<button class="group-action px-2 py-1 text-xs bg-slate-700 hover:bg-slate-600 rounded" data-action="edit" data-group-id="${g.id}"><i class="fa-solid fa-pen"></i></button>
|
||||
<button class="group-action px-2 py-1 text-xs bg-red-900/40 hover:bg-red-900/60 text-red-300 rounded" data-action="delete" data-group-id="${g.id}" data-group-name="${escapeHtml(g.name)}"><i class="fa-solid fa-trash"></i></button>
|
||||
</div>
|
||||
</div>`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
document.getElementById("groups-list")?.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest(".group-action");
|
||||
if (!btn) return;
|
||||
const groupId = Number(btn.dataset.groupId);
|
||||
if (btn.dataset.action === "edit") openGroupModal(groupId);
|
||||
else if (btn.dataset.action === "delete") deleteGroup(groupId, btn.dataset.groupName);
|
||||
});
|
||||
|
||||
document.getElementById("new-group-btn")?.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
openGroupModal(null);
|
||||
});
|
||||
|
||||
function openGroupModal(groupId) {
|
||||
editingGroupId = groupId;
|
||||
const modal = document.getElementById("group-modal");
|
||||
const title = document.getElementById("group-modal-title");
|
||||
const nameInput = document.getElementById("group-name");
|
||||
const descInput = document.getElementById("group-description");
|
||||
const permsList = document.getElementById("group-permissions-list");
|
||||
const errorBox = document.getElementById("group-error");
|
||||
errorBox.classList.add("hidden");
|
||||
|
||||
let selected = new Set();
|
||||
if (groupId) {
|
||||
const g = allGroups.find((x) => x.id === groupId);
|
||||
if (g) {
|
||||
title.textContent = `Edit Group: ${g.name}`;
|
||||
nameInput.value = g.name;
|
||||
descInput.value = g.description || "";
|
||||
selected = new Set(g.permissions);
|
||||
}
|
||||
} else {
|
||||
title.textContent = "New Group";
|
||||
nameInput.value = "";
|
||||
descInput.value = "";
|
||||
}
|
||||
|
||||
permsList.innerHTML = allPermissions
|
||||
.map(
|
||||
(p) => `
|
||||
<label class="flex items-start gap-2 py-1 cursor-pointer hover:bg-slate-800/40 rounded px-2">
|
||||
<input type="checkbox" class="mt-1 h-4 w-4 accent-violet-500" data-perm="${escapeHtml(p.id)}" ${selected.has(p.id) ? "checked" : ""} />
|
||||
<div>
|
||||
<div class="text-sm font-mono text-slate-200">${escapeHtml(p.id)}</div>
|
||||
<div class="text-xs text-slate-500">${escapeHtml(p.description)}</div>
|
||||
</div>
|
||||
</label>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
modal.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function closeGroupModal() {
|
||||
document.getElementById("group-modal")?.classList.add("hidden");
|
||||
editingGroupId = null;
|
||||
}
|
||||
document.getElementById("close-group-modal")?.addEventListener("click", closeGroupModal);
|
||||
document.getElementById("group-cancel-btn")?.addEventListener("click", closeGroupModal);
|
||||
|
||||
document.getElementById("group-form")?.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const name = document.getElementById("group-name").value.trim();
|
||||
const description = document.getElementById("group-description").value.trim();
|
||||
const permissions = Array.from(
|
||||
document.querySelectorAll("#group-permissions-list input[type=checkbox]:checked"),
|
||||
).map((el) => el.dataset.perm);
|
||||
const errorBox = document.getElementById("group-error");
|
||||
errorBox.classList.add("hidden");
|
||||
|
||||
try {
|
||||
const url = editingGroupId
|
||||
? `/api/permission-groups/${editingGroupId}`
|
||||
: "/api/permission-groups";
|
||||
const method = editingGroupId ? "PATCH" : "POST";
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, description, permissions }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
errorBox.textContent = data.error || "Failed to save group";
|
||||
errorBox.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
closeGroupModal();
|
||||
await loadPermissionsAndGroups();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
errorBox.textContent = "Network error";
|
||||
errorBox.classList.remove("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
async function deleteGroup(groupId, groupName) {
|
||||
if (!confirm(`Delete group "${groupName}"? Users assigned to this group will lose its permissions.`)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/permission-groups/${groupId}`, { method: "DELETE" });
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
alert(data.error || "Failed to delete group");
|
||||
return;
|
||||
}
|
||||
await loadPermissionsAndGroups();
|
||||
} catch {
|
||||
alert("Network error");
|
||||
}
|
||||
}
|
||||
|
||||
async function openUserPermissionsModal(userId, username) {
|
||||
editingUserId = userId;
|
||||
const modal = document.getElementById("user-perms-modal");
|
||||
const title = document.getElementById("user-perms-title");
|
||||
const groupsBox = document.getElementById("user-groups-list");
|
||||
const extrasBox = document.getElementById("user-extras-list");
|
||||
const errorBox = document.getElementById("user-perms-error");
|
||||
|
||||
title.textContent = `Permissions: ${username}`;
|
||||
errorBox.classList.add("hidden");
|
||||
groupsBox.innerHTML = `<p class="text-slate-500 text-sm">Loading...</p>`;
|
||||
extrasBox.innerHTML = `<p class="text-slate-500 text-sm">Loading...</p>`;
|
||||
modal.classList.remove("hidden");
|
||||
|
||||
if (allPermissions.length === 0 || allGroups.length === 0) {
|
||||
await loadPermissionsAndGroups();
|
||||
}
|
||||
|
||||
try {
|
||||
const [groupsRes, extrasRes] = await Promise.all([
|
||||
fetch(`/api/users/${userId}/permission-groups`),
|
||||
fetch(`/api/users/${userId}/extra-permissions`),
|
||||
]);
|
||||
const groupsData = await groupsRes.json().catch(() => ({}));
|
||||
const extrasData = await extrasRes.json().catch(() => ({}));
|
||||
const assignedGroups = new Set(groupsData.groupIds || []);
|
||||
const assignedExtras = new Set(extrasData.permissions || []);
|
||||
|
||||
groupsBox.innerHTML = allGroups.length === 0
|
||||
? `<p class="text-slate-500 text-sm">No groups defined yet.</p>`
|
||||
: allGroups
|
||||
.map(
|
||||
(g) => `
|
||||
<label class="flex items-start gap-2 py-1 cursor-pointer hover:bg-slate-800/40 rounded px-2">
|
||||
<input type="checkbox" class="mt-1 h-4 w-4 accent-violet-500" data-group="${g.id}" ${assignedGroups.has(g.id) ? "checked" : ""} />
|
||||
<div>
|
||||
<div class="text-sm font-medium text-slate-200">${escapeHtml(g.name)}</div>
|
||||
<div class="text-xs text-slate-500">${g.permissions.length} permission${g.permissions.length === 1 ? "" : "s"}</div>
|
||||
</div>
|
||||
</label>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
extrasBox.innerHTML = allPermissions
|
||||
.map(
|
||||
(p) => `
|
||||
<label class="flex items-start gap-2 py-1 cursor-pointer hover:bg-slate-800/40 rounded px-2">
|
||||
<input type="checkbox" class="mt-1 h-4 w-4 accent-violet-500" data-perm="${escapeHtml(p.id)}" ${assignedExtras.has(p.id) ? "checked" : ""} />
|
||||
<div>
|
||||
<div class="text-sm font-mono text-slate-200">${escapeHtml(p.id)}</div>
|
||||
<div class="text-xs text-slate-500">${escapeHtml(p.description)}</div>
|
||||
</div>
|
||||
</label>`,
|
||||
)
|
||||
.join("");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
errorBox.textContent = "Failed to load current assignments";
|
||||
errorBox.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
function closeUserPermsModal() {
|
||||
document.getElementById("user-perms-modal")?.classList.add("hidden");
|
||||
editingUserId = null;
|
||||
}
|
||||
document.getElementById("close-user-perms-modal")?.addEventListener("click", closeUserPermsModal);
|
||||
document.getElementById("user-perms-cancel-btn")?.addEventListener("click", closeUserPermsModal);
|
||||
|
||||
document.getElementById("user-perms-save-btn")?.addEventListener("click", async () => {
|
||||
if (!editingUserId) return;
|
||||
const groupIds = Array.from(
|
||||
document.querySelectorAll("#user-groups-list input[type=checkbox]:checked"),
|
||||
).map((el) => Number(el.dataset.group));
|
||||
const permissions = Array.from(
|
||||
document.querySelectorAll("#user-extras-list input[type=checkbox]:checked"),
|
||||
).map((el) => el.dataset.perm);
|
||||
const errorBox = document.getElementById("user-perms-error");
|
||||
errorBox.classList.add("hidden");
|
||||
|
||||
try {
|
||||
const [gRes, eRes] = await Promise.all([
|
||||
fetch(`/api/users/${editingUserId}/permission-groups`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ groupIds }),
|
||||
}),
|
||||
fetch(`/api/users/${editingUserId}/extra-permissions`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ permissions }),
|
||||
}),
|
||||
]);
|
||||
if (!gRes.ok || !eRes.ok) {
|
||||
const data = await (gRes.ok ? eRes : gRes).json().catch(() => ({}));
|
||||
errorBox.textContent = data.error || "Failed to save";
|
||||
errorBox.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
closeUserPermsModal();
|
||||
} catch {
|
||||
errorBox.textContent = "Network error";
|
||||
errorBox.classList.remove("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
getCurrentUser();
|
||||
loadUsers();
|
||||
loadPermissionsAndGroups();
|
||||
|
||||
@@ -84,20 +84,20 @@
|
||||
<a data-target="telegram-settings" class="settings-nav-link" style="--accent: 52 211 153"><i class="fa-solid fa-bell w-4 text-emerald-400"></i><span>Notification Delivery</span></a>
|
||||
<a data-target="section-news" class="settings-nav-link" style="--accent: 56 189 248"><i class="fa-brands fa-telegram w-4 text-sky-400"></i><span>News & Updates</span></a>
|
||||
|
||||
<span class="settings-nav-group-label" data-admin-or-operator>Operations</span>
|
||||
<a data-target="section-bans" class="settings-nav-link" style="--accent: 248 113 113"><i class="fa-solid fa-ban w-4 text-red-400"></i><span>Banned IPs</span></a>
|
||||
<a data-target="section-builds" class="settings-nav-link" data-admin-or-operator style="--accent: 251 146 60"><i class="fa-solid fa-hammer w-4 text-orange-400"></i><span>Build IDs</span></a>
|
||||
<a data-target="wipe-offline-section" class="settings-nav-link" data-admin-or-operator style="--accent: 248 113 113"><i class="fa-solid fa-circle-minus w-4 text-red-400"></i><span>Wipe Offline Clients</span></a>
|
||||
<span class="settings-nav-group-label">Operations</span>
|
||||
<a data-target="section-bans" class="settings-nav-link" data-permission="network:manage-bans" style="--accent: 248 113 113"><i class="fa-solid fa-ban w-4 text-red-400"></i><span>Banned IPs</span></a>
|
||||
<a data-target="section-builds" class="settings-nav-link" data-permission="clients:build" style="--accent: 251 146 60"><i class="fa-solid fa-hammer w-4 text-orange-400"></i><span>Build IDs</span></a>
|
||||
<a data-target="wipe-offline-section" class="settings-nav-link" data-permission="clients:control" style="--accent: 248 113 113"><i class="fa-solid fa-circle-minus w-4 text-red-400"></i><span>Wipe Offline Clients</span></a>
|
||||
|
||||
<span class="settings-nav-group-label" data-admin-only>Admin</span>
|
||||
<a data-target="section-security" class="settings-nav-link" data-admin-only style="--accent: 129 140 248"><i class="fa-solid fa-shield-halved w-4 text-indigo-400"></i><span>Security Policy</span></a>
|
||||
<a data-target="thumbnails-section" class="settings-nav-link" data-admin-only style="--accent: 56 189 248"><i class="fa-solid fa-images w-4 text-sky-400"></i><span>Screenshot Thumbnails</span></a>
|
||||
<a data-target="section-tls" class="settings-nav-link" data-admin-only style="--accent: 34 211 238"><i class="fa-solid fa-certificate w-4 text-cyan-400"></i><span>TLS Certificates</span></a>
|
||||
<a data-target="chat-settings-section" class="settings-nav-link" data-admin-only style="--accent: 56 189 248"><i class="fa-solid fa-comments w-4 text-sky-400"></i><span>Team Chat</span></a>
|
||||
<a data-target="registration-section" class="settings-nav-link" data-admin-only style="--accent: 52 211 153"><i class="fa-solid fa-user-plus w-4 text-emerald-400"></i><span>Registration</span></a>
|
||||
<a data-target="build-rate-limit-section" class="settings-nav-link" data-admin-only style="--accent: 251 146 60"><i class="fa-solid fa-gauge-high w-4 text-orange-400"></i><span>Build Rate Limit</span></a>
|
||||
<a data-target="section-appearance" class="settings-nav-link" data-admin-only style="--accent: 232 121 249"><i class="fa-solid fa-paintbrush w-4 text-fuchsia-400"></i><span>Custom CSS</span></a>
|
||||
<a data-target="export-import-section" class="settings-nav-link" data-admin-only style="--accent: 251 191 36"><i class="fa-solid fa-file-export w-4 text-amber-400"></i><span>Export / Import</span></a>
|
||||
<span class="settings-nav-group-label">Admin</span>
|
||||
<a data-target="section-security" class="settings-nav-link" data-permission="system:configure" style="--accent: 129 140 248"><i class="fa-solid fa-shield-halved w-4 text-indigo-400"></i><span>Security Policy</span></a>
|
||||
<a data-target="thumbnails-section" class="settings-nav-link" data-permission="system:configure" style="--accent: 56 189 248"><i class="fa-solid fa-images w-4 text-sky-400"></i><span>Screenshot Thumbnails</span></a>
|
||||
<a data-target="section-tls" class="settings-nav-link" data-permission="system:configure" style="--accent: 34 211 238"><i class="fa-solid fa-certificate w-4 text-cyan-400"></i><span>TLS Certificates</span></a>
|
||||
<a data-target="chat-settings-section" class="settings-nav-link" data-permission="system:configure" style="--accent: 56 189 248"><i class="fa-solid fa-comments w-4 text-sky-400"></i><span>Team Chat</span></a>
|
||||
<a data-target="registration-section" class="settings-nav-link" data-permission="system:configure" style="--accent: 52 211 153"><i class="fa-solid fa-user-plus w-4 text-emerald-400"></i><span>Registration</span></a>
|
||||
<a data-target="build-rate-limit-section" class="settings-nav-link" data-permission="system:configure" style="--accent: 251 146 60"><i class="fa-solid fa-gauge-high w-4 text-orange-400"></i><span>Build Rate Limit</span></a>
|
||||
<a data-target="section-appearance" class="settings-nav-link" data-permission="system:configure" style="--accent: 232 121 249"><i class="fa-solid fa-paintbrush w-4 text-fuchsia-400"></i><span>Custom CSS</span></a>
|
||||
<a data-target="export-import-section" class="settings-nav-link" data-permission="system:configure" style="--accent: 251 191 36"><i class="fa-solid fa-file-export w-4 text-amber-400"></i><span>Export / Import</span></a>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
@@ -353,7 +353,7 @@
|
||||
<!-- ========== OPERATIONS ========== -->
|
||||
|
||||
<!-- Banned IPs -->
|
||||
<section id="section-bans" class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||
<section id="section-bans" data-permission="network:manage-bans" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-red-500/10 border border-red-500/30">
|
||||
@@ -397,7 +397,7 @@
|
||||
</section>
|
||||
|
||||
<!-- Build IDs (operator/admin) -->
|
||||
<section id="section-builds" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||
<section id="section-builds" data-permission="clients:build" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-orange-500/10 border border-orange-500/30">
|
||||
@@ -451,7 +451,7 @@
|
||||
</section>
|
||||
|
||||
<!-- Wipe Offline Clients -->
|
||||
<section id="wipe-offline-section" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||
<section id="wipe-offline-section" data-permission="clients:control" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-red-500/10 border border-red-500/30">
|
||||
<i class="fa-solid fa-circle-minus text-red-400"></i>
|
||||
@@ -482,7 +482,7 @@
|
||||
<!-- ========== ADMIN ========== -->
|
||||
|
||||
<!-- Security Policy -->
|
||||
<section id="section-security" class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||
<section id="section-security" data-permission="system:configure" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-indigo-500/10 border border-indigo-500/30">
|
||||
<i class="fa-solid fa-shield-halved text-indigo-400"></i>
|
||||
@@ -584,7 +584,7 @@
|
||||
</section>
|
||||
|
||||
<!-- TLS Certificates -->
|
||||
<section id="section-tls" class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||
<section id="section-tls" data-permission="system:configure" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-cyan-500/10 border border-cyan-500/30">
|
||||
<i class="fa-solid fa-certificate text-cyan-400"></i>
|
||||
@@ -686,7 +686,7 @@
|
||||
</section>
|
||||
|
||||
<!-- Team Chat -->
|
||||
<section id="chat-settings-section" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||
<section id="chat-settings-section" data-permission="system:configure" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-sky-500/10 border border-sky-500/30">
|
||||
<i class="fa-solid fa-comments text-sky-400"></i>
|
||||
@@ -722,7 +722,7 @@
|
||||
</section>
|
||||
|
||||
<!-- Registration Settings (admin only) -->
|
||||
<section id="registration-section" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||
<section id="registration-section" data-permission="system:configure" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-emerald-500/10 border border-emerald-500/30">
|
||||
<i class="fa-solid fa-user-plus text-emerald-400"></i>
|
||||
@@ -756,6 +756,13 @@
|
||||
<input id="reg-max-users" type="number" min="0" max="10000" class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500" />
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm text-slate-300">Default permission groups for new users</span>
|
||||
<p class="text-xs text-slate-500 mb-2">Newly registered users automatically join these groups. Manage groups on the <a href="/users" class="text-sky-400 hover:underline">Users page</a>.</p>
|
||||
<div id="reg-default-groups-list" class="space-y-1 max-h-40 overflow-y-auto border border-slate-700 rounded-lg p-2 bg-slate-900/50">
|
||||
<p class="text-slate-500 text-sm">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-emerald-700 hover:bg-emerald-600 transition-colors text-white text-sm font-medium">
|
||||
<i class="fa-solid fa-floppy-disk"></i>Save Registration Settings
|
||||
</button>
|
||||
@@ -823,7 +830,7 @@
|
||||
</section>
|
||||
|
||||
<!-- Screenshot Thumbnails (admin only) -->
|
||||
<section id="thumbnails-section" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||
<section id="thumbnails-section" data-permission="system:configure" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-sky-500/10 border border-sky-500/30">
|
||||
<i class="fa-solid fa-images text-sky-400"></i>
|
||||
@@ -863,7 +870,7 @@
|
||||
</section>
|
||||
|
||||
<!-- Build Rate Limit (admin only) -->
|
||||
<section id="build-rate-limit-section" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||
<section id="build-rate-limit-section" data-permission="system:configure" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-orange-500/10 border border-orange-500/30">
|
||||
<i class="fa-solid fa-gauge-high text-orange-400"></i>
|
||||
@@ -897,7 +904,7 @@
|
||||
</section>
|
||||
|
||||
<!-- Custom CSS -->
|
||||
<section id="section-appearance" class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||
<section id="section-appearance" data-permission="system:configure" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-fuchsia-500/10 border border-fuchsia-500/30">
|
||||
<i class="fa-solid fa-paintbrush text-fuchsia-400"></i>
|
||||
@@ -935,7 +942,7 @@
|
||||
</section>
|
||||
|
||||
<!-- Export & Import -->
|
||||
<section id="export-import-section" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||
<section id="export-import-section" data-permission="system:configure" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-amber-500/10 border border-amber-500/30">
|
||||
<i class="fa-solid fa-file-export text-amber-400"></i>
|
||||
|
||||
@@ -23,6 +23,24 @@
|
||||
<div class="min-h-full flex flex-col">
|
||||
<!-- Main Content -->
|
||||
<main class="flex-1 max-w-7xl w-full mx-auto px-4 py-8">
|
||||
<!-- Permission Groups Manager -->
|
||||
<details class="mb-6 bg-slate-900/50 border border-slate-800 rounded-xl overflow-hidden">
|
||||
<summary class="cursor-pointer px-6 py-4 flex items-center justify-between hover:bg-slate-800/30 transition-colors">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-slate-100"><i class="fa-solid fa-layer-group mr-2 text-violet-400"></i>Permission Groups</h2>
|
||||
<p class="text-slate-400 text-sm">Bundles of permissions that can be assigned to users. Groups can only grant — they never revoke.</p>
|
||||
</div>
|
||||
<button id="new-group-btn" type="button" class="px-3 py-1.5 bg-violet-700 hover:bg-violet-600 text-white rounded-lg text-sm font-medium" onclick="event.preventDefault(); event.stopPropagation();">
|
||||
<i class="fa-solid fa-plus mr-1"></i>New Group
|
||||
</button>
|
||||
</summary>
|
||||
<div class="border-t border-slate-800 p-4">
|
||||
<div id="groups-list" class="space-y-2">
|
||||
<p class="text-slate-500 text-sm text-center py-4">Loading groups...</p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Actions Bar -->
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
@@ -195,6 +213,67 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group Editor Modal -->
|
||||
<div id="group-modal" class="hidden fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl max-w-2xl w-full p-6 shadow-2xl max-h-[90vh] overflow-y-auto">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 id="group-modal-title" class="text-xl font-bold">Edit Group</h3>
|
||||
<button id="close-group-modal" class="text-slate-400 hover:text-slate-200"><i class="fa-solid fa-times text-xl"></i></button>
|
||||
</div>
|
||||
<form id="group-form" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2">Name</label>
|
||||
<input id="group-name" type="text" required maxlength="64" class="w-full px-4 py-2 bg-slate-800 border border-slate-700 rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2">Description (optional)</label>
|
||||
<input id="group-description" type="text" maxlength="256" class="w-full px-4 py-2 bg-slate-800 border border-slate-700 rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2">Permissions</label>
|
||||
<div id="group-permissions-list" class="space-y-1 max-h-72 overflow-y-auto border border-slate-700 rounded-lg p-3 bg-slate-950/50">
|
||||
<p class="text-slate-500 text-sm">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="group-error" class="hidden p-3 bg-red-900/20 border border-red-800 rounded-lg text-red-400 text-sm"></div>
|
||||
<div class="flex gap-3 pt-2">
|
||||
<button type="button" id="group-cancel-btn" class="flex-1 px-4 py-2 bg-slate-800 hover:bg-slate-700 rounded-lg border border-slate-700">Cancel</button>
|
||||
<button type="submit" class="flex-1 px-4 py-2 bg-violet-600 hover:bg-violet-700 text-white rounded-lg font-medium">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Permissions Modal -->
|
||||
<div id="user-perms-modal" class="hidden fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl max-w-2xl w-full p-6 shadow-2xl max-h-[90vh] overflow-y-auto">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 id="user-perms-title" class="text-xl font-bold">Permissions</h3>
|
||||
<button id="close-user-perms-modal" class="text-slate-400 hover:text-slate-200"><i class="fa-solid fa-times text-xl"></i></button>
|
||||
</div>
|
||||
<p class="text-sm text-slate-400 mb-4">Groups bundle permissions; extras grant specific permissions on top. Both are additive — they only add to what the user's role already grants.</p>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-slate-300 mb-2">Groups</h4>
|
||||
<div id="user-groups-list" class="space-y-1 max-h-48 overflow-y-auto border border-slate-700 rounded-lg p-3 bg-slate-950/50">
|
||||
<p class="text-slate-500 text-sm">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-slate-300 mb-2">Extra Permissions</h4>
|
||||
<div id="user-extras-list" class="space-y-1 max-h-72 overflow-y-auto border border-slate-700 rounded-lg p-3 bg-slate-950/50">
|
||||
<p class="text-slate-500 text-sm">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="user-perms-error" class="hidden mt-3 p-3 bg-red-900/20 border border-red-800 rounded-lg text-red-400 text-sm"></div>
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button type="button" id="user-perms-cancel-btn" class="flex-1 px-4 py-2 bg-slate-800 hover:bg-slate-700 rounded-lg border border-slate-700">Cancel</button>
|
||||
<button type="button" id="user-perms-save-btn" class="flex-1 px-4 py-2 bg-violet-600 hover:bg-violet-700 text-white rounded-lg font-medium">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/assets/nav.js"></script>
|
||||
<script type="module" src="/assets/users.js"></script>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user