better settings
This commit is contained in:
@@ -124,7 +124,7 @@ function formatDate(timestamp) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function canManageClientBans(role) {
|
function canManageClientBans(role) {
|
||||||
return role === "admin" || role === "operator";
|
return userHas("network:manage-bans");
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAdmin(role) {
|
function isAdmin(role) {
|
||||||
@@ -135,6 +135,12 @@ function userHas(perm) {
|
|||||||
return !!currentUser?.permissions?.includes(perm);
|
return !!currentUser?.permissions?.includes(perm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requireUiPermission(perm, message = "You do not have permission for this setting.") {
|
||||||
|
if (userHas(perm)) return true;
|
||||||
|
showMessage(message, "error");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function applyPermissionVisibility() {
|
function applyPermissionVisibility() {
|
||||||
document.querySelectorAll("[data-permission]").forEach((el) => {
|
document.querySelectorAll("[data-permission]").forEach((el) => {
|
||||||
const perm = el.dataset.permission;
|
const perm = el.dataset.permission;
|
||||||
@@ -164,6 +170,91 @@ function applyPermissionVisibility() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function renderPermissionsOverview() {
|
||||||
|
const container = document.getElementById("settings-permissions-summary");
|
||||||
|
if (!container || !currentUser) return;
|
||||||
|
|
||||||
|
const granted = new Set(currentUser.permissions || []);
|
||||||
|
let catalog = [];
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/permissions", { credentials: "include" });
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
catalog = Array.isArray(data.permissions) ? data.permissions : [];
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const byId = new Map(catalog.map((perm) => [perm.id, perm.description]));
|
||||||
|
const groups = [
|
||||||
|
{
|
||||||
|
title: "User Administration",
|
||||||
|
icon: "fa-users-gear",
|
||||||
|
perms: ["users:manage", "audit:view"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Client Operations",
|
||||||
|
icon: "fa-desktop",
|
||||||
|
perms: [
|
||||||
|
"clients:control",
|
||||||
|
"clients:build",
|
||||||
|
"clients:metadata",
|
||||||
|
"clients:disconnect",
|
||||||
|
"clients:reconnect",
|
||||||
|
"clients:uninstall",
|
||||||
|
"clients:elevate",
|
||||||
|
"clients:winre",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "System Settings",
|
||||||
|
icon: "fa-sliders",
|
||||||
|
perms: [
|
||||||
|
"system:security",
|
||||||
|
"system:tls",
|
||||||
|
"system:registration",
|
||||||
|
"system:notifications",
|
||||||
|
"system:chat",
|
||||||
|
"system:appearance",
|
||||||
|
"system:thumbnails",
|
||||||
|
"system:build-limits",
|
||||||
|
"system:export-import",
|
||||||
|
"system:health",
|
||||||
|
"system:health:manage",
|
||||||
|
"system:profiler",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
container.innerHTML = groups
|
||||||
|
.map((group) => {
|
||||||
|
const rows = group.perms
|
||||||
|
.map((perm) => {
|
||||||
|
const has = granted.has(perm);
|
||||||
|
return `
|
||||||
|
<div class="flex items-start justify-between gap-3 rounded bg-slate-950/70 border border-slate-800 px-3 py-2">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="font-mono text-xs ${has ? "text-slate-100" : "text-slate-500"}">${escapeHtml(perm)}</div>
|
||||||
|
<div class="text-[11px] text-slate-500 leading-snug">${escapeHtml(byId.get(perm) || "")}</div>
|
||||||
|
</div>
|
||||||
|
<span class="shrink-0 text-xs ${has ? "text-emerald-300" : "text-slate-600"}">
|
||||||
|
<i class="fa-solid ${has ? "fa-circle-check" : "fa-circle-minus"}"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
return `
|
||||||
|
<div class="rounded-lg border border-slate-800 bg-slate-900/70 p-3">
|
||||||
|
<h3 class="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||||
|
<i class="fa-solid ${group.icon} text-violet-400"></i>${escapeHtml(group.title)}
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-2">${rows}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
function getPasswordRequirementsText() {
|
function getPasswordRequirementsText() {
|
||||||
const minLength = Number(securityConfig?.passwordMinLength) || 6;
|
const minLength = Number(securityConfig?.passwordMinLength) || 6;
|
||||||
const requirements = [`min ${minLength} chars`];
|
const requirements = [`min ${minLength} chars`];
|
||||||
@@ -256,7 +347,7 @@ function applySecurityForm() {
|
|||||||
async function loadSecurityPolicy() {
|
async function loadSecurityPolicy() {
|
||||||
if (!currentUser) return;
|
if (!currentUser) return;
|
||||||
|
|
||||||
if (!isAdmin(currentUser.role)) {
|
if (!userHas("system:security")) {
|
||||||
securityPermissionNote.classList.remove("hidden");
|
securityPermissionNote.classList.remove("hidden");
|
||||||
setSecurityFormDisabled(true);
|
setSecurityFormDisabled(true);
|
||||||
securityConfig = {
|
securityConfig = {
|
||||||
@@ -301,7 +392,7 @@ function applyTlsForm() {
|
|||||||
async function loadTlsSettings() {
|
async function loadTlsSettings() {
|
||||||
if (!currentUser) return;
|
if (!currentUser) return;
|
||||||
|
|
||||||
if (!isAdmin(currentUser.role)) {
|
if (!userHas("system:tls")) {
|
||||||
tlsPermissionNote.classList.remove("hidden");
|
tlsPermissionNote.classList.remove("hidden");
|
||||||
setTlsFormDisabled(true);
|
setTlsFormDisabled(true);
|
||||||
tlsConfig = {
|
tlsConfig = {
|
||||||
@@ -559,8 +650,7 @@ function savePrefs(event) {
|
|||||||
|
|
||||||
async function saveSecurityPolicy(event) {
|
async function saveSecurityPolicy(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!isAdmin(currentUser?.role)) {
|
if (!requireUiPermission("system:security", "Security policy permission required.")) {
|
||||||
showMessage("Admin role required.", "error");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -598,8 +688,7 @@ async function saveSecurityPolicy(event) {
|
|||||||
|
|
||||||
async function saveTlsSettings(event) {
|
async function saveTlsSettings(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!isAdmin(currentUser?.role)) {
|
if (!requireUiPermission("system:tls", "TLS settings permission required.")) {
|
||||||
showMessage("Admin role required.", "error");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -633,8 +722,7 @@ async function saveTlsSettings(event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function runCertbotAutoSetup() {
|
async function runCertbotAutoSetup() {
|
||||||
if (!isAdmin(currentUser?.role)) {
|
if (!requireUiPermission("system:tls", "TLS settings permission required.")) {
|
||||||
showMessage("Admin role required.", "error");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1079,7 +1167,7 @@ function initSettingsSidebar() {
|
|||||||
async function loadAppearanceSettings() {
|
async function loadAppearanceSettings() {
|
||||||
if (!currentUser) return;
|
if (!currentUser) return;
|
||||||
|
|
||||||
if (!isAdmin(currentUser.role)) {
|
if (!userHas("system:appearance")) {
|
||||||
if (appearancePermissionNote) appearancePermissionNote.classList.remove("hidden");
|
if (appearancePermissionNote) appearancePermissionNote.classList.remove("hidden");
|
||||||
if (appearanceCustomCssInput) appearanceCustomCssInput.disabled = true;
|
if (appearanceCustomCssInput) appearanceCustomCssInput.disabled = true;
|
||||||
if (appearanceSaveBtn) appearanceSaveBtn.disabled = true;
|
if (appearanceSaveBtn) appearanceSaveBtn.disabled = true;
|
||||||
@@ -1104,8 +1192,7 @@ async function loadAppearanceSettings() {
|
|||||||
|
|
||||||
async function saveAppearanceSettings(event) {
|
async function saveAppearanceSettings(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!isAdmin(currentUser?.role)) {
|
if (!requireUiPermission("system:appearance", "Appearance settings permission required.")) {
|
||||||
showMessage("Admin role required.", "error");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1136,7 +1223,7 @@ const chatSettingsForm = document.getElementById("chat-settings-form");
|
|||||||
const chatRetentionDaysInput = document.getElementById("chat-retention-days");
|
const chatRetentionDaysInput = document.getElementById("chat-retention-days");
|
||||||
|
|
||||||
async function loadChatSettings() {
|
async function loadChatSettings() {
|
||||||
if (!isAdmin(currentUser?.role)) return;
|
if (!userHas("system:chat")) return;
|
||||||
if (chatSettingsSection) chatSettingsSection.classList.remove("hidden");
|
if (chatSettingsSection) chatSettingsSection.classList.remove("hidden");
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/settings/chat", { credentials: "include" });
|
const res = await fetch("/api/settings/chat", { credentials: "include" });
|
||||||
@@ -1151,8 +1238,7 @@ async function loadChatSettings() {
|
|||||||
|
|
||||||
async function saveChatSettings(event) {
|
async function saveChatSettings(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!isAdmin(currentUser?.role)) {
|
if (!requireUiPermission("system:chat", "Team chat settings permission required.")) {
|
||||||
showMessage("Admin role required.", "error");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1198,6 +1284,10 @@ function showExportImportMessage(text, type = "ok") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function exportSettings() {
|
async function exportSettings() {
|
||||||
|
if (!requireUiPermission("system:export-import", "Export/import settings permission required.")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/settings/export", { credentials: "include" });
|
const res = await fetch("/api/settings/export", { credentials: "include" });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -1227,6 +1317,11 @@ async function exportSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function importSettings(event) {
|
async function importSettings(event) {
|
||||||
|
if (!requireUiPermission("system:export-import", "Export/import settings permission required.")) {
|
||||||
|
event.target.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
@@ -1486,6 +1581,8 @@ async function handleRemoveInactiveSessions() {
|
|||||||
// ── Registration Settings ──────────────────────────────────────────────────
|
// ── Registration Settings ──────────────────────────────────────────────────
|
||||||
|
|
||||||
async function loadRegistrationSettings() {
|
async function loadRegistrationSettings() {
|
||||||
|
if (!userHas("system:registration")) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [regRes, groupsRes] = await Promise.all([
|
const [regRes, groupsRes] = await Promise.all([
|
||||||
fetch("/api/settings/registration", { credentials: "include" }),
|
fetch("/api/settings/registration", { credentials: "include" }),
|
||||||
@@ -1522,8 +1619,8 @@ async function loadRegistrationSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateRegSubsections(reg.mode || "off");
|
updateRegSubsections(reg.mode || "off");
|
||||||
if (reg.mode === "key") loadRegistrationKeys();
|
if (reg.mode === "key" && userHas("users:manage")) loadRegistrationKeys();
|
||||||
if (reg.mode === "approval") loadPendingRegistrations();
|
if (reg.mode === "approval" && userHas("users:manage")) loadPendingRegistrations();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to load registration settings", e);
|
console.error("Failed to load registration settings", e);
|
||||||
}
|
}
|
||||||
@@ -1532,8 +1629,8 @@ async function loadRegistrationSettings() {
|
|||||||
function updateRegSubsections(mode) {
|
function updateRegSubsections(mode) {
|
||||||
const keySection = document.getElementById("reg-key-section");
|
const keySection = document.getElementById("reg-key-section");
|
||||||
const pendingSection = document.getElementById("reg-pending-section");
|
const pendingSection = document.getElementById("reg-pending-section");
|
||||||
if (keySection) keySection.classList.toggle("hidden", mode !== "key");
|
if (keySection) keySection.classList.toggle("hidden", mode !== "key" || !userHas("users:manage"));
|
||||||
if (pendingSection) pendingSection.classList.toggle("hidden", mode !== "approval");
|
if (pendingSection) pendingSection.classList.toggle("hidden", mode !== "approval" || !userHas("users:manage"));
|
||||||
}
|
}
|
||||||
|
|
||||||
function showRegMsg(text, type) {
|
function showRegMsg(text, type) {
|
||||||
@@ -1547,6 +1644,10 @@ function showRegMsg(text, type) {
|
|||||||
|
|
||||||
async function saveRegistrationSettings(e) {
|
async function saveRegistrationSettings(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (!requireUiPermission("system:registration", "Registration settings permission required.")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const mode = document.getElementById("reg-mode")?.value;
|
const mode = document.getElementById("reg-mode")?.value;
|
||||||
const defaultRole = document.getElementById("reg-default-role")?.value;
|
const defaultRole = document.getElementById("reg-default-role")?.value;
|
||||||
const maxUsersTotal = Number(document.getElementById("reg-max-users")?.value) || 0;
|
const maxUsersTotal = Number(document.getElementById("reg-max-users")?.value) || 0;
|
||||||
@@ -1568,14 +1669,15 @@ async function saveRegistrationSettings(e) {
|
|||||||
}
|
}
|
||||||
showRegMsg("Registration settings saved.", "success");
|
showRegMsg("Registration settings saved.", "success");
|
||||||
updateRegSubsections(mode);
|
updateRegSubsections(mode);
|
||||||
if (mode === "key") loadRegistrationKeys();
|
if (mode === "key" && userHas("users:manage")) loadRegistrationKeys();
|
||||||
if (mode === "approval") loadPendingRegistrations();
|
if (mode === "approval" && userHas("users:manage")) loadPendingRegistrations();
|
||||||
} catch {
|
} catch {
|
||||||
showRegMsg("Network error.", "error");
|
showRegMsg("Network error.", "error");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadRegistrationKeys() {
|
async function loadRegistrationKeys() {
|
||||||
|
if (!userHas("users:manage")) return;
|
||||||
const tbody = document.getElementById("reg-keys-tbody");
|
const tbody = document.getElementById("reg-keys-tbody");
|
||||||
if (!tbody) return;
|
if (!tbody) return;
|
||||||
try {
|
try {
|
||||||
@@ -1608,6 +1710,10 @@ async function loadRegistrationKeys() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function generateRegistrationKeys() {
|
async function generateRegistrationKeys() {
|
||||||
|
if (!requireUiPermission("users:manage", "User management permission required to generate registration keys.")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const count = Number(document.getElementById("reg-key-count")?.value) || 1;
|
const count = Number(document.getElementById("reg-key-count")?.value) || 1;
|
||||||
const label = document.getElementById("reg-key-label")?.value || undefined;
|
const label = document.getElementById("reg-key-label")?.value || undefined;
|
||||||
const expiresInHours = Number(document.getElementById("reg-key-expires")?.value) || undefined;
|
const expiresInHours = Number(document.getElementById("reg-key-expires")?.value) || undefined;
|
||||||
@@ -1631,6 +1737,10 @@ async function generateRegistrationKeys() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteRegistrationKey(keyId) {
|
async function deleteRegistrationKey(keyId) {
|
||||||
|
if (!requireUiPermission("users:manage", "User management permission required to delete registration keys.")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/registration/keys/${keyId}`, {
|
const res = await fetch(`/api/registration/keys/${keyId}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
@@ -1641,6 +1751,7 @@ async function deleteRegistrationKey(keyId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadPendingRegistrations() {
|
async function loadPendingRegistrations() {
|
||||||
|
if (!userHas("users:manage")) return;
|
||||||
const tbody = document.getElementById("reg-pending-tbody");
|
const tbody = document.getElementById("reg-pending-tbody");
|
||||||
const emptyEl = document.getElementById("reg-pending-empty");
|
const emptyEl = document.getElementById("reg-pending-empty");
|
||||||
if (!tbody) return;
|
if (!tbody) return;
|
||||||
@@ -1673,6 +1784,10 @@ async function loadPendingRegistrations() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handlePendingAction(id, action) {
|
async function handlePendingAction(id, action) {
|
||||||
|
if (!requireUiPermission("users:manage", "User management permission required to approve registrations.")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/registration/pending/${id}/${action}`, {
|
const res = await fetch(`/api/registration/pending/${id}/${action}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -1697,8 +1812,8 @@ function initRegistrationHandlers() {
|
|||||||
const modeEl = document.getElementById("reg-mode");
|
const modeEl = document.getElementById("reg-mode");
|
||||||
if (modeEl) modeEl.addEventListener("change", () => {
|
if (modeEl) modeEl.addEventListener("change", () => {
|
||||||
updateRegSubsections(modeEl.value);
|
updateRegSubsections(modeEl.value);
|
||||||
if (modeEl.value === "key") loadRegistrationKeys();
|
if (modeEl.value === "key" && userHas("users:manage")) loadRegistrationKeys();
|
||||||
if (modeEl.value === "approval") loadPendingRegistrations();
|
if (modeEl.value === "approval" && userHas("users:manage")) loadPendingRegistrations();
|
||||||
});
|
});
|
||||||
|
|
||||||
const genBtn = document.getElementById("reg-key-generate-btn");
|
const genBtn = document.getElementById("reg-key-generate-btn");
|
||||||
@@ -1726,6 +1841,8 @@ function initRegistrationHandlers() {
|
|||||||
// ── Build Rate Limit Settings ──────────────────────────────────────────────
|
// ── Build Rate Limit Settings ──────────────────────────────────────────────
|
||||||
|
|
||||||
async function loadBuildRateLimitSettings() {
|
async function loadBuildRateLimitSettings() {
|
||||||
|
if (!userHas("system:build-limits")) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/settings/build-rate-limit", { credentials: "include" });
|
const res = await fetch("/api/settings/build-rate-limit", { credentials: "include" });
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
@@ -1753,6 +1870,10 @@ function showBrlMsg(text, type) {
|
|||||||
|
|
||||||
async function saveBuildRateLimitSettings(e) {
|
async function saveBuildRateLimitSettings(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (!requireUiPermission("system:build-limits", "Build rate limit settings permission required.")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const maxBuildsPerHour = Number(document.getElementById("brl-max-per-hour")?.value) || 5;
|
const maxBuildsPerHour = Number(document.getElementById("brl-max-per-hour")?.value) || 5;
|
||||||
const maxConcurrentPerUser = Number(document.getElementById("brl-max-concurrent-user")?.value) || 1;
|
const maxConcurrentPerUser = Number(document.getElementById("brl-max-concurrent-user")?.value) || 1;
|
||||||
const globalMaxConcurrent = Number(document.getElementById("brl-global-concurrent")?.value) || 3;
|
const globalMaxConcurrent = Number(document.getElementById("brl-global-concurrent")?.value) || 3;
|
||||||
@@ -1783,6 +1904,8 @@ function initBuildRateLimitHandlers() {
|
|||||||
// ── Thumbnail Settings ────────────────────────────────────────────────────
|
// ── Thumbnail Settings ────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function loadThumbnailSettings() {
|
async function loadThumbnailSettings() {
|
||||||
|
if (!userHas("system:thumbnails")) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/settings/thumbnails", { credentials: "include" });
|
const res = await fetch("/api/settings/thumbnails", { credentials: "include" });
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
@@ -1808,6 +1931,10 @@ function showThumbnailsMsg(text, type) {
|
|||||||
|
|
||||||
async function saveThumbnailSettings(e) {
|
async function saveThumbnailSettings(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (!requireUiPermission("system:thumbnails", "Thumbnail settings permission required.")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const dashboardEnabled = !!document.getElementById("thumb-dashboard-enabled")?.checked;
|
const dashboardEnabled = !!document.getElementById("thumb-dashboard-enabled")?.checked;
|
||||||
const wallEnabled = !!document.getElementById("thumb-wall-enabled")?.checked;
|
const wallEnabled = !!document.getElementById("thumb-wall-enabled")?.checked;
|
||||||
try {
|
try {
|
||||||
@@ -1858,6 +1985,8 @@ function formatUptime(totalSeconds) {
|
|||||||
let latestServerCpuProfile = null;
|
let latestServerCpuProfile = null;
|
||||||
|
|
||||||
async function loadHealthStats() {
|
async function loadHealthStats() {
|
||||||
|
if (!userHas("system:health")) return;
|
||||||
|
|
||||||
const loading = document.getElementById("health-loading");
|
const loading = document.getElementById("health-loading");
|
||||||
const content = document.getElementById("health-content");
|
const content = document.getElementById("health-content");
|
||||||
if (loading) { loading.innerHTML = '<i class="fa-solid fa-spinner fa-spin mr-2"></i>Loading...'; loading.classList.remove("hidden"); }
|
if (loading) { loading.innerHTML = '<i class="fa-solid fa-spinner fa-spin mr-2"></i>Loading...'; loading.classList.remove("hidden"); }
|
||||||
@@ -2060,6 +2189,10 @@ function renderServerProfileResults(data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function runServerProfile() {
|
async function runServerProfile() {
|
||||||
|
if (!requireUiPermission("system:profiler", "Server profiler permission required.")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const btn = document.getElementById("server-profile-btn");
|
const btn = document.getElementById("server-profile-btn");
|
||||||
const durationEl = document.getElementById("server-profile-duration");
|
const durationEl = document.getElementById("server-profile-duration");
|
||||||
const durationMs = Number(durationEl?.value || 5000);
|
const durationMs = Number(durationEl?.value || 5000);
|
||||||
@@ -2113,6 +2246,10 @@ function downloadServerCpuProfile() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function runGC() {
|
async function runGC() {
|
||||||
|
if (!requireUiPermission("system:health:manage", "Server health maintenance permission required.")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const gcBtn = document.getElementById("health-gc-btn");
|
const gcBtn = document.getElementById("health-gc-btn");
|
||||||
const msgEl = document.getElementById("health-gc-message");
|
const msgEl = document.getElementById("health-gc-message");
|
||||||
if (gcBtn) gcBtn.disabled = true;
|
if (gcBtn) gcBtn.disabled = true;
|
||||||
@@ -2139,6 +2276,14 @@ function initHealthHandlers() {
|
|||||||
const gcBtn = document.getElementById("health-gc-btn");
|
const gcBtn = document.getElementById("health-gc-btn");
|
||||||
const profileBtn = document.getElementById("server-profile-btn");
|
const profileBtn = document.getElementById("server-profile-btn");
|
||||||
const downloadProfileBtn = document.getElementById("server-profile-download-btn");
|
const downloadProfileBtn = document.getElementById("server-profile-download-btn");
|
||||||
|
if (gcBtn && !userHas("system:health:manage")) {
|
||||||
|
gcBtn.disabled = true;
|
||||||
|
gcBtn.title = "Requires system:health:manage";
|
||||||
|
}
|
||||||
|
if (profileBtn && !userHas("system:profiler")) {
|
||||||
|
profileBtn.disabled = true;
|
||||||
|
profileBtn.title = "Requires system:profiler";
|
||||||
|
}
|
||||||
if (refreshBtn) refreshBtn.addEventListener("click", loadHealthStats);
|
if (refreshBtn) refreshBtn.addEventListener("click", loadHealthStats);
|
||||||
if (gcBtn) gcBtn.addEventListener("click", runGC);
|
if (gcBtn) gcBtn.addEventListener("click", runGC);
|
||||||
if (profileBtn) profileBtn.addEventListener("click", runServerProfile);
|
if (profileBtn) profileBtn.addEventListener("click", runServerProfile);
|
||||||
@@ -2165,14 +2310,24 @@ async function init() {
|
|||||||
await loadChatSettings();
|
await loadChatSettings();
|
||||||
await loadBannedIps();
|
await loadBannedIps();
|
||||||
|
|
||||||
if (userHas("system:configure")) {
|
if (userHas("users:manage")) {
|
||||||
|
await renderPermissionsOverview();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userHas("system:registration")) {
|
||||||
await loadRegistrationSettings();
|
await loadRegistrationSettings();
|
||||||
await loadBuildRateLimitSettings();
|
|
||||||
await loadThumbnailSettings();
|
|
||||||
await loadHealthStats();
|
|
||||||
initRegistrationHandlers();
|
initRegistrationHandlers();
|
||||||
|
}
|
||||||
|
if (userHas("system:build-limits")) {
|
||||||
|
await loadBuildRateLimitSettings();
|
||||||
initBuildRateLimitHandlers();
|
initBuildRateLimitHandlers();
|
||||||
|
}
|
||||||
|
if (userHas("system:thumbnails")) {
|
||||||
|
await loadThumbnailSettings();
|
||||||
initThumbnailHandlers();
|
initThumbnailHandlers();
|
||||||
|
}
|
||||||
|
if (userHas("system:health")) {
|
||||||
|
await loadHealthStats();
|
||||||
initHealthHandlers();
|
initHealthHandlers();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -90,15 +90,16 @@
|
|||||||
<a data-target="wipe-offline-section" class="settings-nav-link" data-permission="clients:control" style="--accent: 248 113 113"><i class="fa-solid fa-circle-minus w-4 text-red-400"></i><span>Wipe Offline Clients</span></a>
|
<a data-target="wipe-offline-section" class="settings-nav-link" data-permission="clients:control" style="--accent: 248 113 113"><i class="fa-solid fa-circle-minus w-4 text-red-400"></i><span>Wipe Offline Clients</span></a>
|
||||||
|
|
||||||
<span class="settings-nav-group-label">Admin</span>
|
<span class="settings-nav-group-label">Admin</span>
|
||||||
<a data-target="section-security" class="settings-nav-link" data-permission="system:configure" style="--accent: 129 140 248"><i class="fa-solid fa-shield-halved w-4 text-indigo-400"></i><span>Security Policy</span></a>
|
<a data-target="section-permissions-overview" class="settings-nav-link" data-permission="users:manage" style="--accent: 167 139 250"><i class="fa-solid fa-user-shield w-4 text-violet-400"></i><span>Access Overview</span></a>
|
||||||
<a data-target="thumbnails-section" class="settings-nav-link" data-permission="system:configure" style="--accent: 56 189 248"><i class="fa-solid fa-images w-4 text-sky-400"></i><span>Screenshot Thumbnails</span></a>
|
<a data-target="section-security" class="settings-nav-link" data-permission="system:security" style="--accent: 129 140 248"><i class="fa-solid fa-shield-halved w-4 text-indigo-400"></i><span>Security Policy</span></a>
|
||||||
<a data-target="section-tls" class="settings-nav-link" data-permission="system:configure" style="--accent: 34 211 238"><i class="fa-solid fa-certificate w-4 text-cyan-400"></i><span>TLS Certificates</span></a>
|
<a data-target="section-tls" class="settings-nav-link" data-permission="system:tls" style="--accent: 34 211 238"><i class="fa-solid fa-certificate w-4 text-cyan-400"></i><span>TLS Certificates</span></a>
|
||||||
<a data-target="chat-settings-section" class="settings-nav-link" data-permission="system:configure" style="--accent: 56 189 248"><i class="fa-solid fa-comments w-4 text-sky-400"></i><span>Team Chat</span></a>
|
<a data-target="chat-settings-section" class="settings-nav-link" data-permission="system:chat" style="--accent: 56 189 248"><i class="fa-solid fa-comments w-4 text-sky-400"></i><span>Team Chat</span></a>
|
||||||
<a data-target="registration-section" class="settings-nav-link" data-permission="system:configure" style="--accent: 52 211 153"><i class="fa-solid fa-user-plus w-4 text-emerald-400"></i><span>Registration</span></a>
|
<a data-target="registration-section" class="settings-nav-link" data-permission="system:registration" style="--accent: 52 211 153"><i class="fa-solid fa-user-plus w-4 text-emerald-400"></i><span>Registration</span></a>
|
||||||
<a data-target="build-rate-limit-section" class="settings-nav-link" data-permission="system:configure" style="--accent: 251 146 60"><i class="fa-solid fa-gauge-high w-4 text-orange-400"></i><span>Build Rate Limit</span></a>
|
<a data-target="thumbnails-section" class="settings-nav-link" data-permission="system:thumbnails" style="--accent: 56 189 248"><i class="fa-solid fa-images w-4 text-sky-400"></i><span>Screenshot Thumbnails</span></a>
|
||||||
<a data-target="section-appearance" class="settings-nav-link" data-permission="system:configure" style="--accent: 232 121 249"><i class="fa-solid fa-paintbrush w-4 text-fuchsia-400"></i><span>Custom CSS</span></a>
|
<a data-target="build-rate-limit-section" class="settings-nav-link" data-permission="system:build-limits" style="--accent: 251 146 60"><i class="fa-solid fa-gauge-high w-4 text-orange-400"></i><span>Build Rate Limit</span></a>
|
||||||
<a data-target="export-import-section" class="settings-nav-link" data-permission="system:configure" style="--accent: 251 191 36"><i class="fa-solid fa-file-export w-4 text-amber-400"></i><span>Export / Import</span></a>
|
<a data-target="section-appearance" class="settings-nav-link" data-permission="system:appearance" style="--accent: 232 121 249"><i class="fa-solid fa-paintbrush w-4 text-fuchsia-400"></i><span>Custom CSS</span></a>
|
||||||
<a data-target="section-health" class="settings-nav-link" data-permission="system:configure" style="--accent: 34 211 238"><i class="fa-solid fa-heart-pulse w-4 text-cyan-400"></i><span>Server Health</span></a>
|
<a data-target="export-import-section" class="settings-nav-link" data-permission="system:export-import" style="--accent: 251 191 36"><i class="fa-solid fa-file-export w-4 text-amber-400"></i><span>Export / Import</span></a>
|
||||||
|
<a data-target="section-health" class="settings-nav-link" data-permission="system:health" style="--accent: 34 211 238"><i class="fa-solid fa-heart-pulse w-4 text-cyan-400"></i><span>Server Health</span></a>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -552,8 +553,30 @@
|
|||||||
|
|
||||||
<!-- ========== ADMIN ========== -->
|
<!-- ========== ADMIN ========== -->
|
||||||
|
|
||||||
<!-- Security Policy -->
|
<!-- Access Overview -->
|
||||||
<section id="section-security" data-permission="system:configure" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
<section id="section-permissions-overview" data-permission="users:manage" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-violet-500/10 border border-violet-500/30">
|
||||||
|
<i class="fa-solid fa-user-shield text-violet-400"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold">Access Overview</h2>
|
||||||
|
<p class="text-sm text-slate-400">Review your effective permissions and jump to user access management.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="settings-permissions-summary" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3"></div>
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<a href="/users" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-violet-700 hover:bg-violet-600 text-white text-sm font-medium">
|
||||||
|
<i class="fa-solid fa-users-gear"></i> Manage Users
|
||||||
|
</a>
|
||||||
|
<span class="inline-flex items-center gap-2 max-w-full px-3 py-2 rounded-lg bg-slate-900/70 border border-slate-800 text-slate-300 text-sm">
|
||||||
|
<i class="fa-solid fa-desktop text-sky-400"></i>
|
||||||
|
<span class="min-w-0 leading-snug">Client access rules are edited from a selected user in Manage Users.</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="section-security" data-permission="system:security" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-indigo-500/10 border border-indigo-500/30">
|
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-indigo-500/10 border border-indigo-500/30">
|
||||||
<i class="fa-solid fa-shield-halved text-indigo-400"></i>
|
<i class="fa-solid fa-shield-halved text-indigo-400"></i>
|
||||||
@@ -566,7 +589,7 @@
|
|||||||
|
|
||||||
<form id="security-form" class="space-y-4">
|
<form id="security-form" class="space-y-4">
|
||||||
<div id="security-permission-note" class="hidden rounded-lg border border-amber-800 bg-amber-900/20 text-amber-200 px-3 py-2 text-sm">
|
<div id="security-permission-note" class="hidden rounded-lg border border-amber-800 bg-amber-900/20 text-amber-200 px-3 py-2 text-sm">
|
||||||
Only admins can update security policy settings.
|
Your permissions do not allow security policy changes.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
@@ -675,7 +698,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- TLS Certificates -->
|
<!-- TLS Certificates -->
|
||||||
<section id="section-tls" data-permission="system:configure" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
<section id="section-tls" data-permission="system:tls" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-cyan-500/10 border border-cyan-500/30">
|
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-cyan-500/10 border border-cyan-500/30">
|
||||||
<i class="fa-solid fa-certificate text-cyan-400"></i>
|
<i class="fa-solid fa-certificate text-cyan-400"></i>
|
||||||
@@ -688,7 +711,7 @@
|
|||||||
|
|
||||||
<form id="tls-form" class="space-y-4">
|
<form id="tls-form" class="space-y-4">
|
||||||
<div id="tls-permission-note" class="hidden rounded-lg border border-amber-800 bg-amber-900/20 text-amber-200 px-3 py-2 text-sm">
|
<div id="tls-permission-note" class="hidden rounded-lg border border-amber-800 bg-amber-900/20 text-amber-200 px-3 py-2 text-sm">
|
||||||
Only admins can update TLS settings.
|
Your permissions do not allow TLS setting changes.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="flex items-center gap-2 text-sm">
|
<label class="flex items-center gap-2 text-sm">
|
||||||
@@ -777,7 +800,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Team Chat -->
|
<!-- Team Chat -->
|
||||||
<section id="chat-settings-section" data-permission="system:configure" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
<section id="chat-settings-section" data-permission="system:chat" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-sky-500/10 border border-sky-500/30">
|
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-sky-500/10 border border-sky-500/30">
|
||||||
<i class="fa-solid fa-comments text-sky-400"></i>
|
<i class="fa-solid fa-comments text-sky-400"></i>
|
||||||
@@ -813,7 +836,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Registration Settings (admin only) -->
|
<!-- Registration Settings (admin only) -->
|
||||||
<section id="registration-section" data-permission="system:configure" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
<section id="registration-section" data-permission="system:registration" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-emerald-500/10 border border-emerald-500/30">
|
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-emerald-500/10 border border-emerald-500/30">
|
||||||
<i class="fa-solid fa-user-plus text-emerald-400"></i>
|
<i class="fa-solid fa-user-plus text-emerald-400"></i>
|
||||||
@@ -920,8 +943,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Screenshot Thumbnails (admin only) -->
|
<!-- Screenshot Thumbnails -->
|
||||||
<section id="thumbnails-section" data-permission="system:configure" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
<section id="thumbnails-section" data-permission="system:thumbnails" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-sky-500/10 border border-sky-500/30">
|
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-sky-500/10 border border-sky-500/30">
|
||||||
<i class="fa-solid fa-images text-sky-400"></i>
|
<i class="fa-solid fa-images text-sky-400"></i>
|
||||||
@@ -961,7 +984,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Build Rate Limit (admin only) -->
|
<!-- Build Rate Limit (admin only) -->
|
||||||
<section id="build-rate-limit-section" data-permission="system:configure" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
<section id="build-rate-limit-section" data-permission="system:build-limits" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-orange-500/10 border border-orange-500/30">
|
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-orange-500/10 border border-orange-500/30">
|
||||||
<i class="fa-solid fa-gauge-high text-orange-400"></i>
|
<i class="fa-solid fa-gauge-high text-orange-400"></i>
|
||||||
@@ -995,7 +1018,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Custom CSS -->
|
<!-- Custom CSS -->
|
||||||
<section id="section-appearance" data-permission="system:configure" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
<section id="section-appearance" data-permission="system:appearance" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-fuchsia-500/10 border border-fuchsia-500/30">
|
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-fuchsia-500/10 border border-fuchsia-500/30">
|
||||||
<i class="fa-solid fa-paintbrush text-fuchsia-400"></i>
|
<i class="fa-solid fa-paintbrush text-fuchsia-400"></i>
|
||||||
@@ -1008,7 +1031,7 @@
|
|||||||
|
|
||||||
<form id="appearance-form" class="space-y-4">
|
<form id="appearance-form" class="space-y-4">
|
||||||
<div id="appearance-permission-note" class="hidden rounded-lg border border-amber-800 bg-amber-900/20 text-amber-200 px-3 py-2 text-sm">
|
<div id="appearance-permission-note" class="hidden rounded-lg border border-amber-800 bg-amber-900/20 text-amber-200 px-3 py-2 text-sm">
|
||||||
Only admins can update custom CSS.
|
Your permissions do not allow custom CSS changes.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="block">
|
<label class="block">
|
||||||
@@ -1033,7 +1056,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Export & Import -->
|
<!-- Export & Import -->
|
||||||
<section id="export-import-section" data-permission="system:configure" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
<section id="export-import-section" data-permission="system:export-import" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-amber-500/10 border border-amber-500/30">
|
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-amber-500/10 border border-amber-500/30">
|
||||||
<i class="fa-solid fa-file-export text-amber-400"></i>
|
<i class="fa-solid fa-file-export text-amber-400"></i>
|
||||||
@@ -1081,7 +1104,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Server Health -->
|
<!-- Server Health -->
|
||||||
<section id="section-health" class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section hidden" data-permission="system:configure">
|
<section id="section-health" class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section hidden" data-permission="system:health">
|
||||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-cyan-500/10 border border-cyan-500/30">
|
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-cyan-500/10 border border-cyan-500/30">
|
||||||
|
|||||||
@@ -84,7 +84,55 @@ const PERMISSIONS = {
|
|||||||
roles: ["admin"],
|
roles: ["admin"],
|
||||||
},
|
},
|
||||||
"system:configure": {
|
"system:configure": {
|
||||||
description: "Change server-level settings (TLS, security, registration, notifications, appearance, etc.)",
|
description: "Legacy full server settings access (grants all system:* settings permissions)",
|
||||||
|
roles: ["admin"],
|
||||||
|
},
|
||||||
|
"system:security": {
|
||||||
|
description: "Change security policy settings",
|
||||||
|
roles: ["admin"],
|
||||||
|
},
|
||||||
|
"system:tls": {
|
||||||
|
description: "Change TLS and certificate settings",
|
||||||
|
roles: ["admin"],
|
||||||
|
},
|
||||||
|
"system:registration": {
|
||||||
|
description: "Change user registration policy settings",
|
||||||
|
roles: ["admin"],
|
||||||
|
},
|
||||||
|
"system:notifications": {
|
||||||
|
description: "Change global notification delivery settings",
|
||||||
|
roles: ["admin"],
|
||||||
|
},
|
||||||
|
"system:chat": {
|
||||||
|
description: "Change team chat settings",
|
||||||
|
roles: ["admin"],
|
||||||
|
},
|
||||||
|
"system:appearance": {
|
||||||
|
description: "Change custom CSS and appearance settings",
|
||||||
|
roles: ["admin"],
|
||||||
|
},
|
||||||
|
"system:thumbnails": {
|
||||||
|
description: "Change screenshot thumbnail settings",
|
||||||
|
roles: ["admin"],
|
||||||
|
},
|
||||||
|
"system:build-limits": {
|
||||||
|
description: "Change build rate limit settings",
|
||||||
|
roles: ["admin"],
|
||||||
|
},
|
||||||
|
"system:export-import": {
|
||||||
|
description: "Export and import server settings",
|
||||||
|
roles: ["admin"],
|
||||||
|
},
|
||||||
|
"system:health": {
|
||||||
|
description: "View server health diagnostics",
|
||||||
|
roles: ["admin"],
|
||||||
|
},
|
||||||
|
"system:health:manage": {
|
||||||
|
description: "Run server health maintenance actions",
|
||||||
|
roles: ["admin"],
|
||||||
|
},
|
||||||
|
"system:profiler": {
|
||||||
|
description: "Run server CPU and memory profiler captures",
|
||||||
roles: ["admin"],
|
roles: ["admin"],
|
||||||
},
|
},
|
||||||
"clients:elevate": {
|
"clients:elevate": {
|
||||||
@@ -128,9 +176,18 @@ export function hasPermission(
|
|||||||
if (grantedByRole) return true;
|
if (grantedByRole) return true;
|
||||||
// Permission groups + per-user extras are *additive only*: they can grant a
|
// Permission groups + per-user extras are *additive only*: they can grant a
|
||||||
// permission the role doesn't have, but they cannot revoke one the role does.
|
// permission the role doesn't have, but they cannot revoke one the role does.
|
||||||
if (userId !== undefined && getUserGrantedPermissions(userId).has(permission)) {
|
if (userId !== undefined) {
|
||||||
|
const granted = getUserGrantedPermissions(userId);
|
||||||
|
if (granted.has(permission)) return true;
|
||||||
|
if (
|
||||||
|
typeof permission === "string" &&
|
||||||
|
permission.startsWith("system:") &&
|
||||||
|
permission !== "system:configure" &&
|
||||||
|
granted.has("system:configure")
|
||||||
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -103,6 +103,19 @@ describe("/api/auth/me permissions array", () => {
|
|||||||
expect(new Set(body.permissions).has("clients:metadata")).toBe(true);
|
expect(new Set(body.permissions).has("clients:metadata")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("legacy system:configure extra grants the split settings permissions", async () => {
|
||||||
|
const viewer = await tempUser("viewer");
|
||||||
|
setUserExtraPermissions(viewer.user.id, ["system:configure"]);
|
||||||
|
const body = await fetchMe(viewer.token);
|
||||||
|
const set = new Set(body.permissions);
|
||||||
|
expect(set.has("system:configure")).toBe(true);
|
||||||
|
expect(set.has("system:security")).toBe(true);
|
||||||
|
expect(set.has("system:tls")).toBe(true);
|
||||||
|
expect(set.has("system:registration")).toBe(true);
|
||||||
|
expect(set.has("system:health")).toBe(true);
|
||||||
|
expect(set.has("system:profiler")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
test("viewer in a group gains the group's permissions in /api/auth/me", async () => {
|
test("viewer in a group gains the group's permissions in /api/auth/me", async () => {
|
||||||
const viewer = await tempUser("viewer");
|
const viewer = await tempUser("viewer");
|
||||||
const group = tempGroup("me_group", ["clients:disconnect", "audit:view"]);
|
const group = tempGroup("me_group", ["clients:disconnect", "audit:view"]);
|
||||||
|
|||||||
@@ -432,7 +432,7 @@ export async function handleMiscRoutes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
requirePermission(user, "system:configure");
|
requirePermission(user, "system:security");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Response) return error;
|
if (error instanceof Response) return error;
|
||||||
return new Response("Forbidden", { status: 403 });
|
return new Response("Forbidden", { status: 403 });
|
||||||
@@ -494,7 +494,7 @@ export async function handleMiscRoutes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
requirePermission(user, "system:configure");
|
requirePermission(user, "system:tls");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Response) return error;
|
if (error instanceof Response) return error;
|
||||||
return new Response("Forbidden", { status: 403 });
|
return new Response("Forbidden", { status: 403 });
|
||||||
@@ -549,7 +549,7 @@ export async function handleMiscRoutes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
requirePermission(user, "system:configure");
|
requirePermission(user, "system:tls");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Response) return error;
|
if (error instanceof Response) return error;
|
||||||
return new Response("Forbidden", { status: 403 });
|
return new Response("Forbidden", { status: 403 });
|
||||||
@@ -790,7 +790,7 @@ export async function handleMiscRoutes(
|
|||||||
return new Response("Unauthorized", { status: 401 });
|
return new Response("Unauthorized", { status: 401 });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
requirePermission(user, "system:configure");
|
requirePermission(user, "system:export-import");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Response) return error;
|
if (error instanceof Response) return error;
|
||||||
return new Response("Forbidden", { status: 403 });
|
return new Response("Forbidden", { status: 403 });
|
||||||
@@ -823,7 +823,7 @@ export async function handleMiscRoutes(
|
|||||||
return new Response("Unauthorized", { status: 401 });
|
return new Response("Unauthorized", { status: 401 });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
requirePermission(user, "system:configure");
|
requirePermission(user, "system:export-import");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Response) return error;
|
if (error instanceof Response) return error;
|
||||||
return new Response("Forbidden", { status: 403 });
|
return new Response("Forbidden", { status: 403 });
|
||||||
@@ -875,7 +875,7 @@ export async function handleMiscRoutes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
requirePermission(user, "system:configure");
|
requirePermission(user, "system:chat");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Response) return error;
|
if (error instanceof Response) return error;
|
||||||
return new Response("Forbidden", { status: 403 });
|
return new Response("Forbidden", { status: 403 });
|
||||||
@@ -916,7 +916,7 @@ export async function handleMiscRoutes(
|
|||||||
return new Response("Unauthorized", { status: 401 });
|
return new Response("Unauthorized", { status: 401 });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
requirePermission(user, "system:configure");
|
requirePermission(user, "system:appearance");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Response) return error;
|
if (error instanceof Response) return error;
|
||||||
return new Response("Forbidden", { status: 403 });
|
return new Response("Forbidden", { status: 403 });
|
||||||
@@ -981,7 +981,7 @@ export async function handleMiscRoutes(
|
|||||||
if (url.pathname === "/api/settings/registration") {
|
if (url.pathname === "/api/settings/registration") {
|
||||||
const user = await authenticateRequest(req);
|
const user = await authenticateRequest(req);
|
||||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||||
try { requirePermission(user, "system:configure"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
|
try { requirePermission(user, "system:registration"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
|
||||||
|
|
||||||
if (req.method === "GET") {
|
if (req.method === "GET") {
|
||||||
return Response.json({ registration: getConfig().registration }, { headers: deps.CORS_HEADERS });
|
return Response.json({ registration: getConfig().registration }, { headers: deps.CORS_HEADERS });
|
||||||
@@ -1017,7 +1017,7 @@ export async function handleMiscRoutes(
|
|||||||
if (url.pathname === "/api/settings/build-rate-limit") {
|
if (url.pathname === "/api/settings/build-rate-limit") {
|
||||||
const user = await authenticateRequest(req);
|
const user = await authenticateRequest(req);
|
||||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||||
try { requirePermission(user, "system:configure"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
|
try { requirePermission(user, "system:build-limits"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
|
||||||
|
|
||||||
if (req.method === "GET") {
|
if (req.method === "GET") {
|
||||||
return Response.json({ buildRateLimit: getConfig().buildRateLimit }, { headers: deps.CORS_HEADERS });
|
return Response.json({ buildRateLimit: getConfig().buildRateLimit }, { headers: deps.CORS_HEADERS });
|
||||||
@@ -1058,7 +1058,7 @@ export async function handleMiscRoutes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === "PUT") {
|
if (req.method === "PUT") {
|
||||||
try { requirePermission(user, "system:configure"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
|
try { requirePermission(user, "system:thumbnails"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
|
||||||
|
|
||||||
let body: any = {};
|
let body: any = {};
|
||||||
try { body = await req.json(); } catch {
|
try { body = await req.json(); } catch {
|
||||||
@@ -1087,7 +1087,7 @@ export async function handleMiscRoutes(
|
|||||||
if (req.method === "GET" && url.pathname === "/api/settings/health") {
|
if (req.method === "GET" && url.pathname === "/api/settings/health") {
|
||||||
const user = await authenticateRequest(req);
|
const user = await authenticateRequest(req);
|
||||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||||
if (user.role !== "admin") return new Response("Forbidden", { status: 403 });
|
try { requirePermission(user, "system:health"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
|
||||||
|
|
||||||
const mem = process.memoryUsage();
|
const mem = process.memoryUsage();
|
||||||
return Response.json({
|
return Response.json({
|
||||||
@@ -1116,7 +1116,7 @@ export async function handleMiscRoutes(
|
|||||||
if (req.method === "POST" && url.pathname === "/api/settings/gc") {
|
if (req.method === "POST" && url.pathname === "/api/settings/gc") {
|
||||||
const user = await authenticateRequest(req);
|
const user = await authenticateRequest(req);
|
||||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||||
if (user.role !== "admin") return new Response("Forbidden", { status: 403 });
|
try { requirePermission(user, "system:health:manage"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
|
||||||
|
|
||||||
const before = process.memoryUsage().heapUsed;
|
const before = process.memoryUsage().heapUsed;
|
||||||
if (typeof Bun !== "undefined" && typeof (Bun as any).gc === "function") {
|
if (typeof Bun !== "undefined" && typeof (Bun as any).gc === "function") {
|
||||||
@@ -1130,7 +1130,7 @@ export async function handleMiscRoutes(
|
|||||||
if (req.method === "POST" && url.pathname === "/api/settings/profile") {
|
if (req.method === "POST" && url.pathname === "/api/settings/profile") {
|
||||||
const user = await authenticateRequest(req);
|
const user = await authenticateRequest(req);
|
||||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||||
if (user.role !== "admin") return new Response("Forbidden", { status: 403 });
|
try { requirePermission(user, "system:profiler"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
|
||||||
|
|
||||||
if (activeServerProfile) {
|
if (activeServerProfile) {
|
||||||
return Response.json({ error: "A server profile capture is already running." }, { status: 409 });
|
return Response.json({ error: "A server profile capture is already running." }, { status: 409 });
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export async function handleNotificationsConfigRoutes(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
requirePermission(user, "system:configure");
|
requirePermission(user, "system:notifications");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Response) return error;
|
if (error instanceof Response) return error;
|
||||||
return new Response("Forbidden", { status: 403 });
|
return new Response("Forbidden", { status: 403 });
|
||||||
@@ -95,7 +95,7 @@ export async function handleNotificationsConfigRoutes(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
requirePermission(user, "system:configure");
|
requirePermission(user, "system:notifications");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Response) return error;
|
if (error instanceof Response) return error;
|
||||||
return new Response("Forbidden", { status: 403 });
|
return new Response("Forbidden", { status: 403 });
|
||||||
|
|||||||
Reference in New Issue
Block a user