major RBAC imrpovements + fix thumbnails

This commit is contained in:
KingKDot
2026-07-21 13:18:30 -05:00
parent 9b885ad2ef
commit d602de396e
23 changed files with 599 additions and 213 deletions
+10 -4
View File
@@ -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 =
'<i class="fa-solid fa-lock"></i> <span>Build requires permission</span>';
+10 -8
View File
@@ -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) {
+10 -2
View File
@@ -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";
+2 -2
View File
@@ -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;
+14 -11
View File
@@ -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");
}
}
+9 -3
View File
@@ -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 };
}
+15 -11
View File
@@ -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) {
+2 -2
View File
@@ -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() {
@@ -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 = "/";
}
}
+164 -120
View File
@@ -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() {
>
<i class="fa-solid fa-user-shield"></i>
</button>
${user.role === "operator" ? `
<button
class="user-action-btn px-3 py-1.5 text-sm bg-amber-900/30 hover:bg-amber-900/50 text-amber-300 rounded border border-amber-800 transition-colors"
data-action="feature-permissions"
data-user-id="${user.id}"
data-username="${escapeHtml(user.username)}"
title="Feature Permissions"
>
<i class="fa-solid fa-sliders"></i>
</button>
` : ''}
${user.role !== "admin" ? `
<button
class="user-action-btn px-3 py-1.5 text-sm bg-emerald-900/30 hover:bg-emerald-900/50 text-emerald-300 rounded border border-emerald-800 transition-colors"
@@ -248,7 +245,8 @@ function renderUsers() {
data-action="permissions"
data-user-id="${user.id}"
data-username="${escapeHtml(user.username)}"
title="Permission Groups & Extras"
data-role="${escapeHtml(user.role)}"
title="Permissions & Feature Access"
>
<i class="fa-solid fa-layer-group"></i>
</button>
@@ -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 = `
<div class="bg-slate-900 border border-slate-700 rounded-xl max-w-md w-full p-6 shadow-2xl">
<div class="flex items-center justify-between mb-6">
<h3 class="text-xl font-bold text-slate-100">Feature Permissions</h3>
<button id="close-feature-modal" class="text-slate-400 hover:text-slate-200 transition-colors">
<i class="fa-solid fa-times text-xl"></i>
</button>
</div>
<p class="text-sm text-slate-400 mb-4">
Manage feature access for <span class="text-slate-200 font-medium">${escapeHtml(username)}</span>.
Disabled features will return 403 when accessed.
</p>
<div class="space-y-2 mb-6" id="feature-toggles">
${features.map(f => {
const meta = FEATURE_LABELS[f] || { label: f, icon: "fa-puzzle-piece" };
const checked = perms[f] !== false;
return `
<label class="flex items-center justify-between p-3 bg-slate-800/50 rounded-lg border border-slate-700 hover:border-slate-600 cursor-pointer transition-colors">
<div class="flex items-center gap-3">
<i class="fa-solid ${meta.icon} text-slate-400 w-5 text-center"></i>
<span class="text-slate-200 font-medium">${meta.label}</span>
</div>
<input type="checkbox" data-feature="${f}" ${checked ? "checked" : ""}
class="w-5 h-5 rounded bg-slate-700 border-slate-600 text-blue-500 focus:ring-blue-500 focus:ring-offset-0 cursor-pointer" />
</label>`;
}).join("")}
</div>
<div class="flex gap-3">
<button id="reset-feature-perms" class="flex-1 px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-lg border border-slate-700 transition-colors font-medium">
Reset to Defaults
</button>
<button id="save-feature-perms" class="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors font-medium">
<i class="fa-solid fa-check mr-2"></i>Save
</button>
</div>
</div>`;
document.body.appendChild(modal);
document.getElementById("close-feature-modal").addEventListener("click", () => modal.remove());
modal.addEventListener("click", (e) => { if (e.target === modal) modal.remove(); });
document.getElementById("save-feature-perms").addEventListener("click", async () => {
const toggles = modal.querySelectorAll("[data-feature]");
const permissions = {};
toggles.forEach(t => { permissions[t.dataset.feature] = t.checked; });
try {
const saveRes = await fetch(`/api/users/${userId}/feature-permissions`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ permissions }),
});
if (!saveRes.ok) throw new Error("Failed to save");
modal.remove();
if (window.showToast) window.showToast("Feature permissions updated", "success");
} catch (err) {
alert("Failed to save feature permissions");
}
});
document.getElementById("reset-feature-perms").addEventListener("click", async () => {
if (!confirm("Reset all feature permissions to defaults (all enabled)?")) return;
try {
const delRes = await fetch(`/api/users/${userId}/feature-permissions`, { method: "DELETE" });
if (!delRes.ok) throw new Error("Failed to reset");
modal.remove();
if (window.showToast) window.showToast("Feature permissions reset", "success");
} catch (err) {
alert("Failed to reset feature permissions");
}
});
} catch (err) {
console.error("Feature permissions error:", err);
alert("Failed to load feature permissions");
}
};
window.configurePluginAccess = async function (userId, username) {
try {
@@ -1099,18 +1038,74 @@ async function deleteGroup(groupId, groupName) {
}
}
function renderEffectivePermissions(container, effective) {
const permissions = Array.isArray(effective?.permissions) ? effective.permissions : [];
const granted = permissions.filter((p) => p.allowed);
const denied = permissions.filter((p) => !p.allowed);
const ordered = [...granted, ...denied];
const clientScope = effective?.clientAccess?.scope || "unknown";
const pluginScope = effective?.pluginAccess?.scope || "unknown";
const features = effective?.featurePermissions || {};
const disabledFeatures = Object.entries(features)
.filter(([, allowed]) => allowed === false)
.map(([feature]) => feature);
container.innerHTML = `
<div class="mb-2 grid grid-cols-1 sm:grid-cols-3 gap-2 text-xs">
<div class="rounded bg-slate-900 border border-slate-800 px-2 py-1.5">
<span class="text-slate-500">Client scope</span>
<span class="block text-slate-200 font-mono">${escapeHtml(clientScope)}</span>
</div>
<div class="rounded bg-slate-900 border border-slate-800 px-2 py-1.5">
<span class="text-slate-500">Plugin scope</span>
<span class="block text-slate-200 font-mono">${escapeHtml(pluginScope)}</span>
</div>
<div class="rounded bg-slate-900 border border-slate-800 px-2 py-1.5">
<span class="text-slate-500">Disabled features</span>
<span class="block text-slate-200 font-mono">${disabledFeatures.length ? disabledFeatures.map(escapeHtml).join(", ") : "none"}</span>
</div>
</div>
${ordered
.map((permission) => {
const sources = Array.isArray(permission.sources) ? permission.sources : [];
const sourceHtml = sources.length
? sources
.map((source) => `<span class="inline-flex items-center px-1.5 py-0.5 rounded bg-slate-800 border border-slate-700 text-[11px] text-slate-300">${escapeHtml(source.label || source.type || "source")}</span>`)
.join(" ")
: '<span class="text-xs text-slate-600">No grant source</span>';
return `
<div class="rounded border ${permission.allowed ? "border-emerald-900/60 bg-emerald-950/20" : "border-slate-800 bg-slate-900/50"} px-2 py-1.5">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<div class="font-mono text-xs ${permission.allowed ? "text-emerald-200" : "text-slate-400"}">${escapeHtml(permission.id)}</div>
<div class="text-[11px] text-slate-500">${escapeHtml(permission.description || "")}</div>
</div>
<span class="text-[11px] ${permission.allowed ? "text-emerald-300" : "text-slate-600"}">${permission.allowed ? "granted" : "denied"}</span>
</div>
<div class="mt-1 flex flex-wrap gap-1">${sourceHtml}</div>
</div>`;
})
.join("")}
`;
}
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 effectiveBox = document.getElementById("user-effective-perms-list");
const extrasBox = document.getElementById("user-extras-list");
const featuresBox = document.getElementById("user-features-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>`;
featuresBox.innerHTML = `<p class="text-slate-500 text-sm">Loading...</p>`;
effectiveBox.innerHTML = `<p class="text-slate-500 text-sm">Loading...</p>`;
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) {
</label>`,
)
.join("");
const featurePermissions = featureData.permissions || {};
const features = Array.isArray(featureData.features)
? featureData.features
: Object.keys(featurePermissions);
featuresBox.innerHTML = features.length === 0
? `<p class="text-slate-500 text-sm">No feature gates available.</p>`
: features
.map((feature) => {
const meta = FEATURE_LABELS[feature] || { label: feature, icon: "fa-puzzle-piece" };
const checked = featurePermissions[feature] !== false;
return `
<label class="flex items-center justify-between gap-3 py-2 cursor-pointer hover:bg-slate-800/40 rounded px-2">
<div class="flex items-center gap-3 min-w-0">
<i class="fa-solid ${meta.icon} text-slate-400 w-5 text-center"></i>
<div>
<div class="text-sm font-medium text-slate-200">${escapeHtml(meta.label)}</div>
<div class="text-xs font-mono text-slate-500">${escapeHtml(feature)}</div>
</div>
</div>
<input type="checkbox" class="h-4 w-4 accent-blue-500" data-feature="${escapeHtml(feature)}" ${checked ? "checked" : ""} />
</label>`;
})
.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;
+3 -2
View File
@@ -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) {
+39 -12
View File
@@ -166,16 +166,26 @@
class="block text-sm font-medium text-slate-300 mb-2"
>Password</label
>
<input
type="password"
id="password"
name="password"
autocomplete="new-password"
required
minlength="6"
class="w-full px-4 py-2 bg-slate-800 border border-slate-700 rounded-lg text-slate-200 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter password"
/>
<div class="flex gap-2">
<input
type="password"
id="password"
name="password"
autocomplete="new-password"
required
minlength="6"
class="flex-1 px-4 py-2 bg-slate-800 border border-slate-700 rounded-lg text-slate-200 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter password"
/>
<button
type="button"
id="generate-password-btn"
class="px-3 py-2 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-lg border border-slate-700 transition-colors text-sm font-medium whitespace-nowrap"
>
<i class="fa-solid fa-wand-magic-sparkles mr-1"></i>Generate
</button>
</div>
<p id="generated-password-hint" class="hidden text-xs text-emerald-400 mt-1">Generated password filled. Copy it before saving.</p>
<p class="text-xs text-slate-500 mt-1">Minimum 6 characters</p>
</div>
@@ -256,8 +266,8 @@
</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 id="user-perms-modal" class="hidden fixed inset-0 bg-black/50 backdrop-blur-sm z-50 overflow-y-auto p-3 sm:p-6">
<div class="bg-slate-900 border border-slate-700 rounded-xl max-w-5xl w-full p-4 sm:p-6 shadow-2xl mx-auto my-0 sm:my-4">
<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>
@@ -276,6 +286,23 @@
<p class="text-slate-500 text-sm">Loading...</p>
</div>
</div>
<div>
<div class="flex items-center justify-between gap-2 mb-2">
<h4 class="text-sm font-semibold text-slate-300">Feature Access / Context Menu</h4>
<button type="button" id="reset-user-features-btn" class="text-xs px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 border border-slate-700 text-slate-300">Reset to defaults</button>
</div>
<div id="user-features-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>
<p class="text-xs text-slate-500 mt-1">Controls feature-level gates behind client context menu actions.</p>
</div>
<div>
<h4 class="text-sm font-semibold text-slate-300 mb-2">Effective Permissions</h4>
<div id="user-effective-perms-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>
<p class="text-xs text-slate-500 mt-1">Shows the final permission state and where each grant comes from.</p>
</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">
+50 -1
View File
@@ -9,11 +9,12 @@ import {
getUserGrantedPermissions,
getUserGroupIds,
listPermissionGroups,
setUserCanUploadFiles,
setUserExtraPermissions,
setUserGroups,
updatePermissionGroup,
} from "./users";
import { hasPermission, getUserPermissions } from "./rbac";
import { hasPermission, getUserEffectivePermissionDetails, getUserPermissions } from "./rbac";
const createdUserIds: number[] = [];
const createdGroupIds: number[] = [];
@@ -140,6 +141,54 @@ describe("user permission groups + extras", () => {
expect(perms.has("users:manage")).toBe(false);
});
test("effective permission details include role, group, and extra grant sources", async () => {
const viewer = await tempUser("viewer");
const g = tempGroup("eff_a", ["clients:metadata"]);
setUserGroups(viewer.id, [g.id]);
setUserExtraPermissions(viewer.id, ["clients:uninstall"]);
const details = getUserEffectivePermissionDetails(viewer.id, viewer.role);
const metadata = details.find((p) => p.id === "clients:metadata");
const uninstall = details.find((p) => p.id === "clients:uninstall");
const control = details.find((p) => p.id === "clients:control");
expect(metadata?.allowed).toBe(true);
expect(metadata?.sources).toContainEqual({
type: "group",
label: g.name,
groupId: g.id,
});
expect(uninstall?.allowed).toBe(true);
expect(uninstall?.sources).toContainEqual({
type: "extra",
label: "Direct extra permission",
});
expect(control?.allowed).toBe(false);
expect(control?.sources).toEqual([]);
});
test("effective permission details reflect build and upload toggles", async () => {
const op = await tempUser("operator");
setUserCanUploadFiles(op.id, true);
const details = getUserEffectivePermissionDetails(op.id, op.role);
const build = details.find((p) => p.id === "clients:build");
const upload = details.find((p) => p.id === "files:upload");
expect(build?.allowed).toBe(true);
expect(build?.sources).toContainEqual({
type: "role",
label: "Build permission toggle",
role: "operator",
});
expect(upload?.allowed).toBe(true);
expect(upload?.sources).toContainEqual({
type: "role",
label: "File upload permission toggle",
role: "operator",
});
});
test("getUserGroupIds returns assigned ids", async () => {
const viewer = await tempUser("viewer");
const g = tempGroup("gid_a", []);
+4
View File
@@ -237,6 +237,7 @@ describe("getPermissionDescription", () => {
["clients:silent-exec", "Silently execute arbitrary commands on clients"],
["audit:view", "View audit logs"],
["chat:write", "Send messages in team chat"],
["files:upload", "Upload shared files and build artifacts"],
["scripts:manage", "Manage auto-run scripts"],
["deploys:manage", "Manage deploys and auto-deploys"],
["plugins:manage", "Upload, enable, and delete plugins"],
@@ -274,6 +275,7 @@ describe("getRolePermissions", () => {
expect(perms).toContain("network:manage-bans");
expect(perms).toContain("system:configure");
expect(perms).toContain("clients:winre");
expect(perms).toContain("files:upload");
});
test("operator gets the role-eligible subset", () => {
@@ -292,6 +294,7 @@ describe("getRolePermissions", () => {
expect(perms).not.toContain("clients:silent-exec");
expect(perms).not.toContain("system:configure");
expect(perms).not.toContain("clients:winre");
expect(perms).not.toContain("files:upload");
});
test("viewer gets nothing", () => {
@@ -312,6 +315,7 @@ describe("listAllPermissions", () => {
expect(perms).toContain("clients:uninstall" as Permission);
expect(perms).toContain("system:configure" as Permission);
expect(perms).toContain("network:manage-bans" as Permission);
expect(perms).toContain("files:upload" as Permission);
});
test("returns the same length as the description coverage (every entry has a description)", () => {
+97 -1
View File
@@ -2,10 +2,14 @@ import { type AuthenticatedUser } from "./auth";
import {
canBuildClients,
canChatWrite,
canUploadFiles,
canUserAccessClient,
canUserAccessFeature,
canUserAccessPlugin,
getUserExtraPermissions,
getUserGrantedPermissions,
getUserGroupIds,
listPermissionGroups,
type FeatureName,
type UserRole,
} from "./users";
@@ -14,6 +18,7 @@ type PermissionDef = {
description: string;
roles: readonly UserRole[];
userOverride?: (userId: number, role: UserRole) => boolean;
overrideSourceLabel?: string;
};
const PERMISSIONS = {
@@ -29,6 +34,7 @@ const PERMISSIONS = {
description: "Build client binaries",
roles: ["admin", "operator"],
userOverride: canBuildClients,
overrideSourceLabel: "Build permission toggle",
},
"clients:enroll": {
description: "Manage client enrollment approvals",
@@ -62,6 +68,13 @@ const PERMISSIONS = {
description: "Send messages in team chat",
roles: ["admin", "operator"],
userOverride: canChatWrite,
overrideSourceLabel: "Chat write permission toggle",
},
"files:upload": {
description: "Upload shared files and build artifacts",
roles: ["admin"],
userOverride: canUploadFiles,
overrideSourceLabel: "File upload permission toggle",
},
"scripts:manage": {
description: "Manage auto-run scripts",
@@ -237,7 +250,7 @@ export function requirePermission(
const authedUser = requireAuth(user);
if (!checkPermission(authedUser, permission)) {
throw new Response("Forbidden: Insufficient permissions", { status: 403 });
throw new Response(`Forbidden: missing permission ${permission}`, { status: 403 });
}
return authedUser;
@@ -322,3 +335,86 @@ export function getUserPermissions(userId: number, role: UserRole): Permission[]
}
return result;
}
export type EffectivePermissionSource =
| { type: "role"; label: string; role: UserRole }
| { type: "group"; label: string; groupId: number }
| { type: "extra"; label: string }
| { type: "system-configure"; label: string };
export type EffectivePermissionDetail = {
id: Permission;
description: string;
allowed: boolean;
sources: EffectivePermissionSource[];
};
export function getUserEffectivePermissionDetails(
userId: number,
role: UserRole,
): EffectivePermissionDetail[] {
const assignedGroupIds = new Set(getUserGroupIds(userId));
const groups = listPermissionGroups().filter((group) => assignedGroupIds.has(group.id));
const extras = getUserExtraPermissions(userId);
return listAllPermissions().map((permission) => {
const def = lookupPermission(permission);
const sources: EffectivePermissionSource[] = [];
if (def) {
const roleGrants = def.userOverride
? def.userOverride(userId, role)
: def.roles.includes(role);
if (roleGrants) {
sources.push({
type: "role",
label: def.overrideSourceLabel || `${role} role`,
role,
});
}
}
for (const group of groups) {
if (group.permissions.includes(permission)) {
sources.push({
type: "group",
label: group.name,
groupId: group.id,
});
}
}
if (extras.has(permission)) {
sources.push({
type: "extra",
label: "Direct extra permission",
});
}
if (
sources.length === 0 &&
permission.startsWith("system:") &&
permission !== "system:configure"
) {
const configureGroup = groups.find((group) => group.permissions.includes("system:configure"));
if (configureGroup) {
sources.push({
type: "system-configure",
label: `Via ${configureGroup.name} → system:configure`,
});
} else if (extras.has("system:configure")) {
sources.push({
type: "system-configure",
label: "Via direct system:configure",
});
}
}
return {
id: permission,
description: getPermissionDescription(permission),
allowed: hasPermission(role, permission, userId),
sources,
};
});
}
@@ -8,6 +8,7 @@ import {
getUserById,
setUserExtraPermissions,
setUserGroups,
setUserCanUploadFiles,
} from "../../users";
import { handleAuthRoutes } from "./auth-routes";
@@ -76,6 +77,7 @@ describe("/api/auth/me permissions array", () => {
expect(set.has("clients:control")).toBe(true);
expect(set.has("system:configure")).toBe(true);
expect(set.has("clients:disconnect")).toBe(true);
expect(set.has("files:upload")).toBe(true);
});
test("operator gets the role-eligible subset and lacks admin-only perms", async () => {
@@ -83,11 +85,13 @@ describe("/api/auth/me permissions array", () => {
const body = await fetchMe(op.token);
const set = new Set(body.permissions);
expect(set.has("clients:control")).toBe(true);
expect(body.canBuild).toBe(true);
expect(set.has("clients:disconnect")).toBe(true);
expect(set.has("clients:metadata")).toBe(true);
expect(set.has("users:manage")).toBe(false);
expect(set.has("system:configure")).toBe(false);
expect(set.has("network:manage-bans")).toBe(false);
expect(set.has("files:upload")).toBe(false);
});
test("viewer has no permissions by default", async () => {
@@ -132,5 +136,23 @@ describe("/api/auth/me permissions array", () => {
setUserCanBuild(op.user.id, false);
const body = await fetchMe(op.token);
expect(new Set(body.permissions).has("clients:build")).toBe(false);
expect(body.canBuild).toBe(false);
});
test("operator with upload enabled gets files:upload and canUploadFiles via /api/auth/me", async () => {
const op = await tempUser("operator");
setUserCanUploadFiles(op.user.id, true);
const body = await fetchMe(op.token);
expect(new Set(body.permissions).has("files:upload")).toBe(true);
expect(body.canUploadFiles).toBe(true);
});
test("viewer in a build group gets canBuild from effective permissions", async () => {
const viewer = await tempUser("viewer");
const group = tempGroup("me_build", ["clients:build"]);
setUserGroups(viewer.user.id, [group.id]);
const body = await fetchMe(viewer.token);
expect(new Set(body.permissions).has("clients:build")).toBe(true);
expect(body.canBuild).toBe(true);
});
});
@@ -471,17 +471,33 @@ export async function handleAuthRoutes(
}
const reasons: string[] = [];
const denialDetails: Array<{
type: "feature" | "client";
message: string;
feature?: FeatureName;
clientId?: string;
}> = [];
if (!canUserAccessFeature(user.userId, user.role, feature)) {
reasons.push("feature");
denialDetails.push({
type: "feature",
feature,
message: `Feature access denied for ${feature}`,
});
}
if (clientId && !canUserAccessClient(user.userId, user.role, clientId)) {
reasons.push("client");
denialDetails.push({
type: "client",
clientId,
message: `Client access denied for ${clientId}`,
});
}
if (reasons.length > 0) {
return Response.json({ allowed: false, denied: reasons });
return Response.json({ allowed: false, denied: reasons, denialDetails });
}
return Response.json({ allowed: true });
@@ -507,7 +523,8 @@ export async function handleAuthRoutes(
userId: user.userId,
mustChangePassword: dbUser ? Boolean(dbUser.must_change_password) : false,
needsOnboarding: dbUser ? dbUser.onboarding_completed_at === null : false,
canBuild: dbUser ? Boolean(dbUser.can_build) : false,
canBuild: permissions.includes("clients:build"),
canUploadFiles: permissions.includes("files:upload"),
telegramChatId: dbUser?.telegram_chat_id || "",
inputArchiveEnabled: dbUser ? Boolean((dbUser as any).keylog_archive_enabled) : false,
featurePermissions,
@@ -16,12 +16,12 @@ import {
} from "../../db";
import { encodeMessage } from "../../protocol";
import { metrics } from "../../metrics";
import { requirePermission } from "../../rbac";
import { checkPermission, requirePermission } from "../../rbac";
import { logger } from "../../logger";
import { normalizeClientOs } from "../deploy-utils";
import { canUserBuild, recordBuildStart, recordBuildEnd } from "../../build-rate-limit";
import { getConfig } from "../../config";
import { canUploadFiles, canUserAccessPlugin } from "../../users";
import { canUserAccessPlugin } from "../../users";
import { addBuildToBanlist, removeBuildFromBanlist } from "../build-signing";
import path from "path";
import fs from "fs";
@@ -501,7 +501,7 @@ export async function handleBuildRoutes(
}
const safeUploadToFileShare = !!uploadToFileShare;
if (safeUploadToFileShare && !canUploadFiles(user.userId, user.role)) {
if (safeUploadToFileShare && !checkPermission(user, "files:upload")) {
return Response.json(
{ error: "You do not have permission to upload files" },
{ status: 403 },
@@ -12,7 +12,8 @@ import {
updateSharedFile,
incrementSharedFileDownloadCount,
} from "../../db";
import { getUserById, canUploadFiles } from "../../users";
import { getUserById } from "../../users";
import { checkPermission, requirePermission } from "../../rbac";
import { resolveContainedPath, sanitizeUploadFilename } from "../upload-security";
type FileShareRouteDeps = {
@@ -155,7 +156,7 @@ export async function handleFileShareRoutes(
if (req.method === "GET" && url.pathname === "/api/file-share") {
const files = listSharedFiles();
const userCanUpload = canUploadFiles(user.userId, user.role);
const userCanUpload = checkPermission(user, "files:upload");
return Response.json({
files: files.map((f) => ({
id: f.id,
@@ -176,8 +177,11 @@ export async function handleFileShareRoutes(
}
if (req.method === "POST" && url.pathname === "/api/file-share/upload") {
if (!canUploadFiles(user.userId, user.role)) {
return Response.json({ error: "You do not have upload permission" }, { status: 403 });
try {
requirePermission(user, "files:upload");
} catch (error) {
if (error instanceof Response) return Response.json({ error: "You do not have upload permission" }, { status: 403 });
return Response.json({ error: "Forbidden" }, { status: 403 });
}
let form: FormData;
@@ -2,7 +2,7 @@ import { authenticateRequest } from "../../auth";
import { getConfig } from "../../config";
import { consumeLoginPageRateLimit } from "../../rateLimit";
import { requirePermission, type Permission } from "../../rbac";
import { canUserAccessClient, getUserById, type UserRole } from "../../users";
import { canUserAccessClient, canUserAccessFeature, getUserById, type FeatureName, type UserRole } from "../../users";
type PageRouteDeps = {
PUBLIC_ROOT: string;
@@ -72,11 +72,15 @@ type ClientPageDef = {
pattern: RegExp;
file: string;
clientIdGroup: number;
feature?: FeatureName;
permission?: Permission;
};
type QueryClientPageDef = {
path: string;
file: string;
feature?: FeatureName;
permission?: Permission;
};
/** Static pages: each entry maps a URL path to its HTML file + access rules. */
@@ -87,33 +91,33 @@ const STATIC_PAGES: StaticPageDef[] = [
{ path: "/settings", file: "settings.html", access: "any", checkPasswordChange: true },
{ path: "/logs", file: "logs.html", access: "any", checkPasswordChange: true, permission: "audit:view" },
{ path: "/notifications", file: "notifications.html", access: "admin-or-operator", checkPasswordChange: true },
{ path: "/users", file: "users.html", access: "admin", checkPasswordChange: true },
{ path: "/user-client-access", file: "user-client-access.html", access: "admin", checkPasswordChange: true },
{ path: "/build", file: "build.html", access: "admin-or-operator" },
{ path: "/sol-publish", file: "sol-publish.html", access: "admin" },
{ path: "/plugins", file: "plugins.html", access: "admin-or-operator" },
{ path: "/scripts", file: "scripts.html", access: "no-viewer" },
{ path: "/deploy", file: "deploy.html", access: "admin" },
{ path: "/users", file: "users.html", access: "any", checkPasswordChange: true, permission: "users:manage" },
{ path: "/user-client-access", file: "user-client-access.html", access: "any", checkPasswordChange: true, permission: "users:manage" },
{ path: "/build", file: "build.html", access: "any", permission: "clients:build" },
{ path: "/sol-publish", file: "sol-publish.html", access: "any", permission: "system:configure" },
{ path: "/plugins", file: "plugins.html", access: "any", permission: "plugins:manage" },
{ path: "/scripts", file: "scripts.html", access: "any", permission: "scripts:manage" },
{ path: "/deploy", file: "deploy.html", access: "any", permission: "deploys:manage" },
{ path: "/socks5-manager", file: "socks5-manager.html", access: "no-viewer", checkPasswordChange: true },
{ path: "/file-share", file: "file-share.html", access: "no-viewer", checkPasswordChange: true },
{ path: "/purgatory", file: "purgatory.html", access: "admin-or-operator", checkPasswordChange: true },
{ path: "/purgatory", file: "purgatory.html", access: "any", checkPasswordChange: true, permission: "clients:enroll" },
];
/** Client-scoped pages accessed via query param ?clientId=... */
const QUERY_CLIENT_PAGES: QueryClientPageDef[] = [
{ path: "/remotedesktop", file: "remotedesktop.html" },
{ path: "/webcam", file: "webcam.html" },
{ path: "/backstage", file: "backstage.html" },
{ path: "/voice", file: "voice.html" },
{ path: "/winre", file: "winre.html" },
{ path: "/remotedesktop", file: "remotedesktop.html", feature: "remote_desktop" },
{ path: "/webcam", file: "webcam.html", feature: "webcam" },
{ path: "/backstage", file: "backstage.html", feature: "backstage" },
{ path: "/voice", file: "voice.html", feature: "voice" },
{ path: "/winre", file: "winre.html", permission: "clients:winre" },
];
/** Client-scoped pages accessed via path /:clientId/feature */
const PATH_CLIENT_PAGES: ClientPageDef[] = [
{ pattern: /^\/(.+)\/console$/, file: "console.html", clientIdGroup: 1 },
{ pattern: /^\/(.+)\/files$/, file: "filebrowser.html", clientIdGroup: 1 },
{ pattern: /^\/(.+)\/processes$/, file: "processes.html", clientIdGroup: 1 },
{ pattern: /^\/(.+)\/keylogger$/, file: "keylogger.html", clientIdGroup: 1 },
{ pattern: /^\/(.+)\/console$/, file: "console.html", clientIdGroup: 1, feature: "console" },
{ pattern: /^\/(.+)\/files$/, file: "filebrowser.html", clientIdGroup: 1, feature: "file_browser" },
{ pattern: /^\/(.+)\/processes$/, file: "processes.html", clientIdGroup: 1, feature: "processes" },
{ pattern: /^\/(.+)\/keylogger$/, file: "keylogger.html", clientIdGroup: 1, feature: "keylogger" },
];
function checkAccess(role: UserRole, access: AccessLevel): Response | null {
@@ -224,8 +228,17 @@ export async function handlePageRoutes(
const maybeChange = await serveChangePasswordIfRequired(deps, user.userId);
if (maybeChange) return maybeChange;
if (user.role === "viewer") {
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
if (page.permission) {
try {
requirePermission(user, page.permission);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
}
if (page.feature && !canUserAccessFeature(user.userId, user.role, page.feature)) {
return new Response(`Forbidden: feature access denied for ${page.feature}`, { status: 403 });
}
const clientId = (url.searchParams.get("clientId") || "").trim();
@@ -244,8 +257,17 @@ export async function handlePageRoutes(
const user = await authenticateRequest(req);
if (!user) return serveLoginOrUnauthorized(req, deps);
if (user.role === "viewer") {
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
if (page.permission) {
try {
requirePermission(user, page.permission);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
}
if (page.feature && !canUserAccessFeature(user.userId, user.role, page.feature)) {
return new Response(`Forbidden: feature access denied for ${page.feature}`, { status: 403 });
}
const clientId = match[page.clientIdGroup];
@@ -1,7 +1,7 @@
import { generateToken, authenticateRequest, getSessionTtlSeconds } from "../../auth";
import { AuditAction, logAudit } from "../../auditLog";
import { logger } from "../../logger";
import { requirePermission } from "../../rbac";
import { getUserEffectivePermissionDetails, requirePermission } from "../../rbac";
import {
listUserSessions,
persistRevokedTokenHash,
@@ -58,6 +58,29 @@ export async function handleUsersRoutes(
return Response.json({ users });
}
if (req.method === "GET" && url.pathname.match(/^\/api\/users\/\d+\/effective-permissions$/)) {
requirePermission(user, "users:manage");
const userId = parseInt(url.pathname.split("/")[3]);
const targetUser = getUserById(userId);
if (!targetUser) {
return Response.json({ error: "User not found" }, { status: 404 });
}
return Response.json({
success: true,
permissions: getUserEffectivePermissionDetails(targetUser.id, targetUser.role),
clientAccess: {
scope: getUserClientAccessScope(targetUser.id),
rules: listUserClientAccessRules(targetUser.id),
},
pluginAccess: {
scope: getUserPluginAccessScope(targetUser.id),
rules: listUserPluginAccessRules(targetUser.id),
},
featurePermissions: getUserFeaturePermissions(targetUser.id),
});
}
if (req.method === "POST" && url.pathname === "/api/users") {
const authedUser = requirePermission(user, "users:manage");
const body = await req.json();
+24 -1
View File
@@ -56,7 +56,7 @@ test.describe("authentication and authorization", () => {
const denied = await page.goto("/users");
expect(denied?.status()).toBe(403);
await expect(page.locator("body")).toContainText("Admin access required");
await expect(page.locator("body")).toContainText("missing permission users:manage");
const allowed = await page.goto("/metrics");
expect(allowed?.status()).toBe(200);
@@ -78,6 +78,29 @@ test.describe("authentication and authorization", () => {
});
});
test("admin can generate passwords and inspect combined permission sources", async ({ page }) => {
await login(page);
await page.goto("/users");
await page.locator("#add-user-btn").click();
await page.locator("#generate-password-btn").click();
const generatedPassword = await page.locator("#password").inputValue();
expect(generatedPassword.length).toBeGreaterThanOrEqual(20);
await expect(page.locator("#generated-password-hint")).toBeVisible();
await page.locator("#cancel-btn").click();
await expect(page.locator(`[data-action="feature-permissions"]`)).toHaveCount(0);
await page.locator(`[data-action="permissions"][data-username="${OPERATOR.username}"]`).click();
await expect(page.locator("#user-perms-modal")).toBeVisible();
await expect(page.locator("#user-features-list")).toContainText("Console");
await expect(page.locator("#user-features-list")).toContainText("console");
await expect(page.locator("#user-effective-perms-list")).toContainText("clients:control");
await expect(page.locator("#user-effective-perms-list")).toContainText("operator role");
await expect(page.locator("#user-effective-perms-list")).toContainText("clients:build");
await expect(page.locator("#user-effective-perms-list")).toContainText("Build permission toggle");
await expect(page.locator("#user-effective-perms-list")).toContainText("Client scope");
});
test("admin page sweep loads without browser errors", async ({ page }) => {
test.setTimeout(60_000);
await login(page);
+24
View File
@@ -64,6 +64,30 @@ test.describe("authenticated browser smoke tests", () => {
}
});
test("dashboard thumbnail preview modal opens when requested", async ({ page }) => {
await login(page);
await page.waitForFunction(() => document.getElementById("role-badge")?.textContent?.includes("Admin"));
await page.waitForFunction(() => getComputedStyle(document.getElementById("pagination")!).visibility !== "hidden");
await page.evaluate(() => {
const grid = document.getElementById("grid");
if (!grid) throw new Error("Dashboard grid not found");
grid.innerHTML = `
<article class="cv-row" data-client-row="rows" data-id="thumb-test" data-online="false">
<div class="cv-thumb cv-thumb-host" data-thumb-host data-thumb-client="thumb-test" style="width: 80px; height: 50px;">
<img class="thumb-img cv-thumb-img cv-thumb-overlay" data-thumb-img src="data:image/gif;base64,R0lGODlhAQABAAAAACw=" alt="" />
</div>
</article>
`;
const host = grid.querySelector("[data-thumb-host]");
if (!host) throw new Error("Thumbnail host not found");
host.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
});
const modal = page.locator(".modal").last();
await expect(modal).toHaveClass(/flex/);
await expect(modal).not.toHaveClass(/hidden/);
await expect(page.locator("#modal-img")).toHaveAttribute("src", /data:image\/gif/);
});
test("password inputs expose browser-safe form semantics", async ({ page }) => {
const issues = collectBrowserIssues(page);
await login(page);