diff --git a/Overlord-Server/public/assets/build.js b/Overlord-Server/public/assets/build.js index c617289..1f467b6 100644 --- a/Overlord-Server/public/assets/build.js +++ b/Overlord-Server/public/assets/build.js @@ -1702,6 +1702,8 @@ async function init() { } const data = await res.json(); + const permissions = Array.isArray(data.permissions) ? data.permissions : []; + const hasPermission = (permission) => permissions.includes(permission); currentUserRole = data.role; currentUsername = data.username || null; usernameDisplay.textContent = data.username; @@ -1740,21 +1742,25 @@ async function init() { "border-slate-600", ); } - if (data.role === "admin") { + if (hasPermission("users:manage")) { usersLink.classList.remove("hidden"); + } + if (hasPermission("plugins:manage")) { pluginsLink?.classList.remove("hidden"); + } + if (hasPermission("deploys:manage")) { document.getElementById("deploy-link")?.classList.remove("hidden"); } - if (data.role === "admin" || data.role === "operator" || data.canBuild) { + if (hasPermission("clients:build") || data.canBuild) { buildLink?.classList.remove("hidden"); } - if (data.role !== "viewer") { + if (hasPermission("scripts:manage")) { scriptsLink?.classList.remove("hidden"); } - if (data.role !== "admin" && data.role !== "operator" && !data.canBuild) { + if (!hasPermission("clients:build") && !data.canBuild) { buildBtn.disabled = true; buildBtn.innerHTML = ' Build requires permission'; diff --git a/Overlord-Server/public/assets/deploy.js b/Overlord-Server/public/assets/deploy.js index b11d3f9..66bcd62 100644 --- a/Overlord-Server/public/assets/deploy.js +++ b/Overlord-Server/public/assets/deploy.js @@ -63,6 +63,8 @@ async function checkAuth() { } const data = await res.json(); + const permissions = Array.isArray(data.permissions) ? data.permissions : []; + const hasPermission = (permission) => permissions.includes(permission); document.getElementById("username-display").textContent = data.username; const roleBadge = document.getElementById("role-badge"); @@ -84,12 +86,12 @@ async function checkAuth() { "border", "border-purple-800", ); - document.getElementById("metrics-link")?.classList.remove("hidden"); - document.getElementById("scripts-link")?.classList.remove("hidden"); - document.getElementById("build-link")?.classList.remove("hidden"); - document.getElementById("users-link")?.classList.remove("hidden"); - document.getElementById("plugins-link")?.classList.remove("hidden"); - document.getElementById("deploy-link")?.classList.remove("hidden"); + if (hasPermission("audit:view")) document.getElementById("metrics-link")?.classList.remove("hidden"); + if (hasPermission("scripts:manage")) document.getElementById("scripts-link")?.classList.remove("hidden"); + if (hasPermission("clients:build") || data.canBuild) document.getElementById("build-link")?.classList.remove("hidden"); + if (hasPermission("users:manage")) document.getElementById("users-link")?.classList.remove("hidden"); + if (hasPermission("plugins:manage")) document.getElementById("plugins-link")?.classList.remove("hidden"); + if (hasPermission("deploys:manage")) document.getElementById("deploy-link")?.classList.remove("hidden"); } else if (data.role === "operator") { roleBadge.classList.add( "bg-blue-900/50", @@ -106,8 +108,8 @@ async function checkAuth() { ); } - if (data.role !== "admin") { - alert("Access denied. Admin role required."); + if (!hasPermission("deploys:manage")) { + alert("Access denied. Missing deploys:manage permission."); window.location.href = "/"; } } catch (err) { diff --git a/Overlord-Server/public/assets/feature-gate.js b/Overlord-Server/public/assets/feature-gate.js index 3c8b09b..42ba52f 100644 --- a/Overlord-Server/public/assets/feature-gate.js +++ b/Overlord-Server/public/assets/feature-gate.js @@ -23,7 +23,9 @@ export async function checkFeatureAccess(feature, clientId) { const data = await res.json(); if (data.allowed) return true; - const reasons = Array.isArray(data.denied) ? data.denied : []; + const reasons = Array.isArray(data.denialDetails) && data.denialDetails.length + ? data.denialDetails + : (Array.isArray(data.denied) ? data.denied : []); showAccessDenied(reasons); return false; } catch { @@ -32,7 +34,13 @@ export async function checkFeatureAccess(feature, clientId) { } function showAccessDenied(reasons) { - const messages = reasons.map((r) => DENY_LABELS[r] || "Access denied.").join(" "); + const messages = reasons + .map((reason) => { + if (typeof reason === "string") return DENY_LABELS[reason] || "Access denied."; + if (reason && typeof reason.message === "string") return reason.message; + return "Access denied."; + }) + .join(" "); document.body.style.visibility = "hidden"; diff --git a/Overlord-Server/public/assets/main.css b/Overlord-Server/public/assets/main.css index 3ea6769..8cd658e 100644 --- a/Overlord-Server/public/assets/main.css +++ b/Overlord-Server/public/assets/main.css @@ -3183,8 +3183,8 @@ tbody tr:hover { background: #0d1018; border: 1px solid var(--cv-border); } -.cv-thumb-img { object-fit: cover; cursor: pointer; } -.cv-thumb { position: relative; } +.cv-thumb-img { object-fit: cover; } +.cv-thumb { position: relative; cursor: pointer; } .cv-thumb-host { position: relative; isolation: isolate; } .cv-thumb-overlay { position: absolute; diff --git a/Overlord-Server/public/assets/nav/role-ui.js b/Overlord-Server/public/assets/nav/role-ui.js index 16d648d..2b55b46 100644 --- a/Overlord-Server/public/assets/nav/role-ui.js +++ b/Overlord-Server/public/assets/nav/role-ui.js @@ -53,25 +53,28 @@ export function applyUserRoleUI(user, refs) { ); } + const permissions = new Set(Array.isArray(user.permissions) ? user.permissions : []); + const can = (permission) => permissions.has(permission); + const canAny = (...items) => items.some((permission) => can(permission)); + // Helper: unhide an element by ID in both topbar and sidebar const unhide = (id) => { const el = document.getElementById(id); if (el) el.classList.remove("hidden"); }; - if (user.role === "admin") { - unhide("users-link"); - unhide("plugins-link"); - unhide("logs-link"); - unhide("sol-publish-link"); - } - if (user.role === "admin" || user.role === "operator") { - unhide("build-link"); + if (can("users:manage")) unhide("users-link"); + if (can("plugins:manage")) unhide("plugins-link"); + if (can("audit:view")) unhide("logs-link"); + if (can("system:configure")) unhide("sol-publish-link"); + if (can("clients:build") || user.canBuild) unhide("build-link"); + if (can("clients:enroll")) unhide("enrollment-link"); + if (can("scripts:manage")) unhide("scripts-link"); + if (can("deploys:manage")) unhide("deploy-link"); + if (canAny("system:notifications", "clients:control", "clients:enroll")) { unhide("notifications-link"); - unhide("enrollment-link"); } - if (user.role !== "viewer") { - unhide("scripts-link"); + if (user.role !== "viewer" || user.canUploadFiles) { unhide("file-share-link"); } } diff --git a/Overlord-Server/public/assets/render.js b/Overlord-Server/public/assets/render.js index 93350d5..00d03f3 100644 --- a/Overlord-Server/public/assets/render.js +++ b/Overlord-Server/public/assets/render.js @@ -516,13 +516,17 @@ export function createRenderer({ return; } - const thumbImg = e.target.closest(".thumb-img"); - if (thumbImg) { + const thumbHost = e.target.closest("[data-thumb-host]"); + if (thumbHost) { + e.preventDefault(); + e.stopPropagation(); if (card.dataset.online === "true") { if (pingClient) pingClient(clientId); requestThumbnail(clientId); } - if (thumbImg.src) openModal(thumbImg.src); + const thumbImg = thumbHost.querySelector("img[data-thumb-img]"); + const src = thumbImg?.currentSrc || thumbImg?.src || ""; + if (src) openModal(src); return; } @@ -1190,6 +1194,8 @@ export function createRenderer({ `; return article; } + setupGridDelegation(); + return { renderMerge, setLayout }; } diff --git a/Overlord-Server/public/assets/scripts.js b/Overlord-Server/public/assets/scripts.js index 349b3a7..0507b77 100644 --- a/Overlord-Server/public/assets/scripts.js +++ b/Overlord-Server/public/assets/scripts.js @@ -68,6 +68,8 @@ async function checkAuth() { } const data = await res.json(); + const permissions = Array.isArray(data.permissions) ? data.permissions : []; + const hasPermission = (permission) => permissions.includes(permission); document.getElementById("username-display").textContent = data.username; const roleBadge = document.getElementById("role-badge"); @@ -105,25 +107,27 @@ async function checkAuth() { ); } - if (data.role === "admin") { + if (hasPermission("audit:view")) { document.getElementById("metrics-link")?.classList.remove("hidden"); + } + if (hasPermission("scripts:manage")) { document.getElementById("scripts-link")?.classList.remove("hidden"); + } + if (hasPermission("clients:build") || data.canBuild) { document.getElementById("build-link")?.classList.remove("hidden"); + } + if (hasPermission("deploys:manage")) { document.getElementById("deploy-link")?.classList.remove("hidden"); + } + if (hasPermission("users:manage")) { document.getElementById("users-link")?.classList.remove("hidden"); + } + if (hasPermission("plugins:manage")) { document.getElementById("plugins-link")?.classList.remove("hidden"); - } else if (data.role === "operator") { - document.getElementById("metrics-link")?.classList.remove("hidden"); - document.getElementById("scripts-link")?.classList.remove("hidden"); - document.getElementById("build-link")?.classList.remove("hidden"); } - if (data.canBuild) { - document.getElementById("build-link")?.classList.remove("hidden"); - } - - if (data.role === "viewer") { - alert("Access denied. Operator or Admin role required."); + if (!hasPermission("scripts:manage")) { + alert("Access denied. Missing scripts:manage permission."); window.location.href = "/"; } } catch (err) { diff --git a/Overlord-Server/public/assets/ui.js b/Overlord-Server/public/assets/ui.js index cba2a31..866a212 100644 --- a/Overlord-Server/public/assets/ui.js +++ b/Overlord-Server/public/assets/ui.js @@ -469,14 +469,14 @@ export function openModal(src) { setTimeout(() => { modalImg.src = src; - modal.classList.remove("Virtual"); + modal.classList.remove("hidden"); modal.classList.add("flex"); }, 10); } export function closeModal() { modal.classList.remove("flex"); - modal.classList.add("Virtual"); + modal.classList.add("hidden"); } export function wireModalClose() { diff --git a/Overlord-Server/public/assets/user-client-access.js b/Overlord-Server/public/assets/user-client-access.js index cb4f736..48f84bd 100644 --- a/Overlord-Server/public/assets/user-client-access.js +++ b/Overlord-Server/public/assets/user-client-access.js @@ -150,7 +150,8 @@ async function loadCurrentUser() { return; } state.me = await res.json(); - if (state.me.role !== "admin") { + const permissions = Array.isArray(state.me.permissions) ? state.me.permissions : []; + if (!permissions.includes("users:manage")) { window.location.href = "/"; } } diff --git a/Overlord-Server/public/assets/users.js b/Overlord-Server/public/assets/users.js index f559084..cf0ea16 100644 --- a/Overlord-Server/public/assets/users.js +++ b/Overlord-Server/public/assets/users.js @@ -14,10 +14,16 @@ const closeModal = document.getElementById("close-modal"); const cancelBtn = document.getElementById("cancel-btn"); const errorMessage = document.getElementById("error-message"); const errorText = document.getElementById("error-text"); +const generatePasswordBtn = document.getElementById("generate-password-btn"); +const generatedPasswordHint = document.getElementById("generated-password-hint"); const logoutBtn = document.getElementById("logout-btn"); const currentUserEl = document.getElementById("username-display"); const currentRoleEl = document.getElementById("role-badge"); +function hasPermission(permission) { + return Array.isArray(currentUser?.permissions) && currentUser.permissions.includes(permission); +} + async function getCurrentUser() { try { const res = await fetch("/api/auth/me"); @@ -59,25 +65,27 @@ async function getCurrentUser() { ); } - if (currentUser.role === "admin") { + if (hasPermission("audit:view")) { document.getElementById("metrics-link")?.classList.remove("hidden"); + } + if (hasPermission("scripts:manage")) { document.getElementById("scripts-link")?.classList.remove("hidden"); + } + if (hasPermission("clients:build") || currentUser.canBuild) { document.getElementById("build-link")?.classList.remove("hidden"); + } + if (hasPermission("users:manage")) { document.getElementById("users-link")?.classList.remove("hidden"); + } + if (hasPermission("plugins:manage")) { document.getElementById("plugins-link")?.classList.remove("hidden"); + } + if (hasPermission("deploys:manage")) { document.getElementById("deploy-link")?.classList.remove("hidden"); - } else if (currentUser.role === "operator") { - document.getElementById("metrics-link")?.classList.remove("hidden"); - document.getElementById("scripts-link")?.classList.remove("hidden"); - document.getElementById("build-link")?.classList.remove("hidden"); } - if (currentUser.canBuild) { - document.getElementById("build-link")?.classList.remove("hidden"); - } - - if (currentUser.role !== "admin") { - alert("Access denied. Admin role required."); + if (!hasPermission("users:manage")) { + alert("Access denied. Missing users:manage permission."); window.location.href = "/"; } @@ -220,17 +228,6 @@ function renderUsers() { > - ${user.role === "operator" ? ` - - ` : ''} ${user.role !== "admin" ? ` @@ -351,9 +349,6 @@ function attachActionListeners() { case "client-access": configureClientAccess(userId, username, role); break; - case "feature-permissions": - configureFeaturePermissions(userId, username); - break; case "plugin-access": configurePluginAccess(userId, username); break; @@ -380,6 +375,9 @@ function showModal(title) { function hideModal() { userModal.classList.add("hidden"); userForm.reset(); + generatedPasswordHint?.classList.add("hidden"); + const passwordInput = document.getElementById("password"); + if (passwordInput) passwordInput.type = "password"; } function showError(message) { @@ -387,10 +385,41 @@ function showError(message) { errorMessage.classList.remove("hidden"); } +function generatePassword(length = 20) { + const lower = "abcdefghjkmnpqrstuvwxyz"; + const upper = "ABCDEFGHJKLMNPQRSTUVWXYZ"; + const digits = "23456789"; + const symbols = "!@#$%^&*()-_=+[]{}"; + const required = [lower, upper, digits, symbols]; + const alphabet = required.join(""); + const bytes = new Uint8Array(length); + crypto.getRandomValues(bytes); + const chars = required.map((set, index) => set[bytes[index] % set.length]); + for (let i = required.length; i < length; i++) { + chars.push(alphabet[bytes[i] % alphabet.length]); + } + for (let i = chars.length - 1; i > 0; i--) { + const j = bytes[i] % (i + 1); + [chars[i], chars[j]] = [chars[j], chars[i]]; + } + return chars.join(""); +} + +generatePasswordBtn?.addEventListener("click", () => { + const passwordInput = document.getElementById("password"); + if (!passwordInput) return; + passwordInput.value = generatePassword(); + passwordInput.type = "text"; + generatedPasswordHint?.classList.remove("hidden"); + passwordInput.focus(); + passwordInput.select(); +}); + addUserBtn.addEventListener("click", () => { showModal("Add User"); document.getElementById("password-field").classList.remove("hidden"); document.getElementById("password").required = true; + generatedPasswordHint?.classList.add("hidden"); }); closeModal.addEventListener("click", hideModal); @@ -550,96 +579,6 @@ const FEATURE_LABELS = { client_metadata: { label: "Edit Client Metadata", icon: "fa-pen-to-square" }, }; -window.configureFeaturePermissions = async function (userId, username) { - try { - const res = await fetch(`/api/users/${userId}/feature-permissions`); - if (!res.ok) throw new Error("Failed to load feature permissions"); - const data = await res.json(); - const perms = data.permissions; - const features = data.features; - - let modal = document.getElementById("feature-perms-modal"); - if (modal) modal.remove(); - - modal = document.createElement("div"); - modal.id = "feature-perms-modal"; - modal.className = "fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"; - modal.innerHTML = ` -
- Manage feature access for ${escapeHtml(username)}. - Disabled features will return 403 when accessed. -
-Loading...
`; extrasBox.innerHTML = `Loading...
`; + featuresBox.innerHTML = `Loading...
`; + effectiveBox.innerHTML = `Loading...
`; modal.classList.remove("hidden"); if (allPermissions.length === 0 || allGroups.length === 0) { @@ -1118,12 +1113,18 @@ async function openUserPermissionsModal(userId, username) { } try { - const [groupsRes, extrasRes] = await Promise.all([ + const [groupsRes, extrasRes, effectiveRes, featureRes] = await Promise.all([ fetch(`/api/users/${userId}/permission-groups`), fetch(`/api/users/${userId}/extra-permissions`), + fetch(`/api/users/${userId}/effective-permissions`), + fetch(`/api/users/${userId}/feature-permissions`), ]); const groupsData = await groupsRes.json().catch(() => ({})); const extrasData = await extrasRes.json().catch(() => ({})); + const effectiveData = await effectiveRes.json().catch(() => ({})); + const featureData = await featureRes.json().catch(() => ({})); + const targetUser = users.find((candidate) => candidate.id === userId); + featuresBox.dataset.defaultAllowed = targetUser?.role === "viewer" ? "0" : "1"; const assignedGroups = new Set(groupsData.groupIds || []); const assignedExtras = new Set(extrasData.permissions || []); @@ -1154,6 +1155,32 @@ async function openUserPermissionsModal(userId, username) { `, ) .join(""); + + const featurePermissions = featureData.permissions || {}; + const features = Array.isArray(featureData.features) + ? featureData.features + : Object.keys(featurePermissions); + featuresBox.innerHTML = features.length === 0 + ? `No feature gates available.
` + : features + .map((feature) => { + const meta = FEATURE_LABELS[feature] || { label: feature, icon: "fa-puzzle-piece" }; + const checked = featurePermissions[feature] !== false; + return ` + `; + }) + .join(""); + + renderEffectivePermissions(effectiveBox, effectiveData); } catch (err) { console.error(err); errorBox.textContent = "Failed to load current assignments"; @@ -1167,6 +1194,13 @@ function closeUserPermsModal() { } document.getElementById("close-user-perms-modal")?.addEventListener("click", closeUserPermsModal); document.getElementById("user-perms-cancel-btn")?.addEventListener("click", closeUserPermsModal); +document.getElementById("reset-user-features-btn")?.addEventListener("click", () => { + const featuresBox = document.getElementById("user-features-list"); + const defaultAllowed = featuresBox?.dataset.defaultAllowed !== "0"; + document.querySelectorAll("#user-features-list input[data-feature]").forEach((el) => { + el.checked = defaultAllowed; + }); +}); document.getElementById("user-perms-save-btn")?.addEventListener("click", async () => { if (!editingUserId) return; @@ -1176,11 +1210,15 @@ document.getElementById("user-perms-save-btn")?.addEventListener("click", async const permissions = Array.from( document.querySelectorAll("#user-extras-list input[type=checkbox]:checked"), ).map((el) => el.dataset.perm); + const featurePermissions = {}; + document.querySelectorAll("#user-features-list input[data-feature]").forEach((el) => { + featurePermissions[el.dataset.feature] = el.checked; + }); const errorBox = document.getElementById("user-perms-error"); errorBox.classList.add("hidden"); try { - const [gRes, eRes] = await Promise.all([ + const [gRes, eRes, fRes] = await Promise.all([ fetch(`/api/users/${editingUserId}/permission-groups`, { method: "PUT", headers: { "Content-Type": "application/json" }, @@ -1191,9 +1229,15 @@ document.getElementById("user-perms-save-btn")?.addEventListener("click", async headers: { "Content-Type": "application/json" }, body: JSON.stringify({ permissions }), }), + fetch(`/api/users/${editingUserId}/feature-permissions`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ permissions: featurePermissions }), + }), ]); - if (!gRes.ok || !eRes.ok) { - const data = await (gRes.ok ? eRes : gRes).json().catch(() => ({})); + if (!gRes.ok || !eRes.ok || !fRes.ok) { + const failed = !gRes.ok ? gRes : (!eRes.ok ? eRes : fRes); + const data = await failed.json().catch(() => ({})); errorBox.textContent = data.error || "Failed to save"; errorBox.classList.remove("hidden"); return; diff --git a/Overlord-Server/public/assets/winre.js b/Overlord-Server/public/assets/winre.js index 96a2cdd..d9e6b9b 100644 --- a/Overlord-Server/public/assets/winre.js +++ b/Overlord-Server/public/assets/winre.js @@ -56,6 +56,7 @@ async function checkAuth() { const data = await res.json(); document.getElementById("username-display").textContent = data.username; + const permissions = Array.isArray(data.permissions) ? data.permissions : []; const roleBadge = document.getElementById("role-badge"); const roleBadges = { @@ -77,8 +78,8 @@ async function checkAuth() { roleBadge.classList.add("bg-slate-700", "text-slate-300", "border", "border-slate-600"); } - if (data.role !== "admin") { - alert("Access denied. Admin role required."); + if (!permissions.includes("clients:winre")) { + alert("Access denied. Missing clients:winre permission."); window.location.href = "/"; } } catch (err) { diff --git a/Overlord-Server/public/users.html b/Overlord-Server/public/users.html index 709bb5b..cfbbac1 100644 --- a/Overlord-Server/public/users.html +++ b/Overlord-Server/public/users.html @@ -166,16 +166,26 @@ class="block text-sm font-medium text-slate-300 mb-2" >Password - +Generated password filled. Copy it before saving.
Minimum 6 characters
@@ -256,8 +266,8 @@ -Loading...
Loading...
+Controls feature-level gates behind client context menu actions.
+Loading...
+Shows the final permission state and where each grant comes from.
+