This commit is contained in:
KingKDot
2026-05-19 00:46:30 -05:00
parent bbd61af672
commit 116beff515
31 changed files with 2170 additions and 521 deletions
+67 -36
View File
@@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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();
+290 -1
View File
@@ -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();