import { getNotificationsEnabled, setNotificationsEnabled, getDesktopNotificationsEnabled, setDesktopNotificationsEnabled, requestDesktopNotificationPermission, } from "./notify-client.js"; import { escapeHtml, formatBytes as formatSharedBytes, formatDate as formatSharedDate } from "./format.js"; import { getCertificateTrustGuide } from "./cert-banner.js"; import { isEasterEggEnabled, setEasterEggEnabled, showEnabledEasterEgg, } from "./easter-egg.js"; import { animateElement, animateTextChange, concealElement, emphasizeElement, revealElement, } from "./motion.js"; const PREF_REFRESH_KEY = "overlord_refresh_interval_seconds"; const NAV_MODE_KEY = "sb_mode"; const NAV_HIDDEN_KEY = "nav_hidden"; const usernameEl = document.getElementById("settings-username"); const roleEl = document.getElementById("settings-role"); const messageEl = document.getElementById("settings-message"); const passwordForm = document.getElementById("password-form"); const currentPasswordInput = document.getElementById("current-password"); const newPasswordInput = document.getElementById("new-password"); const confirmPasswordInput = document.getElementById("confirm-password"); const passwordPolicyHint = document.getElementById("password-policy-hint"); const mfaStatusText = document.getElementById("mfa-status-text"); const mfaStartBtn = document.getElementById("mfa-start-btn"); const mfaSetupPanel = document.getElementById("mfa-setup-panel"); const mfaQrCode = document.getElementById("mfa-qr-code"); const mfaSecretText = document.getElementById("mfa-secret-text"); const mfaEnableCodeInput = document.getElementById("mfa-enable-code"); const mfaEnableBtn = document.getElementById("mfa-enable-btn"); const mfaOtpauthLink = document.getElementById("mfa-otpauth-link"); const mfaDisablePanel = document.getElementById("mfa-disable-panel"); const mfaDisablePasswordInput = document.getElementById("mfa-disable-password"); const mfaDisableCodeInput = document.getElementById("mfa-disable-code"); const mfaDisableBtn = document.getElementById("mfa-disable-btn"); const mfaMessage = document.getElementById("mfa-message"); const prefsForm = document.getElementById("prefs-form"); const prefNotificationsInput = document.getElementById("pref-notifications"); const prefDesktopNotificationsInput = document.getElementById("pref-desktop-notifications"); const prefDesktopNotificationsHint = document.getElementById("pref-desktop-notifications-hint"); const prefRefreshSecondsInput = document.getElementById("pref-refresh-seconds"); const easterEggEnabledInput = document.getElementById("easter-egg-enabled"); const easterEggStatus = document.getElementById("easter-egg-status"); const inputArchiveUserForm = document.getElementById("input-archive-user-form"); const inputArchiveMyEnabledInput = document.getElementById("input-archive-my-enabled"); const inputArchiveAdminForm = document.getElementById("input-archive-admin-form"); const inputArchiveGlobalEnabledInput = document.getElementById("input-archive-global-enabled"); const inputArchiveRetentionDaysInput = document.getElementById("input-archive-retention-days"); const inputArchiveMaxFileMbInput = document.getElementById("input-archive-max-file-mb"); const inputArchivePollSecondsInput = document.getElementById("input-archive-poll-seconds"); const myTelegramChatIdInput = document.getElementById("my-telegram-chat-id"); const saveMyTelegramBtn = document.getElementById("save-my-telegram"); const bansTableBody = document.getElementById("bans-table-body"); const bansPermissionNote = document.getElementById("bans-permission-note"); const refreshBansBtn = document.getElementById("refresh-bans-btn"); const securityForm = document.getElementById("security-form"); const securityPermissionNote = document.getElementById("security-permission-note"); const securitySaveBtn = document.getElementById("security-save-btn"); const securitySessionTtlInput = document.getElementById("security-session-ttl"); const securityLoginMaxAttemptsInput = document.getElementById("security-login-max-attempts"); const securityLoginWindowInput = document.getElementById("security-login-window"); const securityLockoutInput = document.getElementById("security-lockout"); const securityPasswordMinInput = document.getElementById("security-password-min"); const securityRequireUppercaseInput = document.getElementById("security-require-uppercase"); const securityRequireLowercaseInput = document.getElementById("security-require-lowercase"); const securityRequireNumberInput = document.getElementById("security-require-number"); const securityRequireSymbolInput = document.getElementById("security-require-symbol"); const securityMfaAdminsInput = document.getElementById("security-mfa-admins"); const securityMfaNonAdminsInput = document.getElementById("security-mfa-non-admins"); const tlsForm = document.getElementById("tls-form"); const tlsPermissionNote = document.getElementById("tls-permission-note"); const tlsSaveBtn = document.getElementById("tls-save-btn"); const tlsCertbotAutoBtn = document.getElementById("tls-certbot-auto-btn"); const tlsCertbotEmailInput = document.getElementById("tls-certbot-email"); const tlsCertbotEnabledInput = document.getElementById("tls-certbot-enabled"); const tlsCertbotLivePathInput = document.getElementById("tls-certbot-live-path"); const tlsCertbotDomainInput = document.getElementById("tls-certbot-domain"); const tlsCertbotCertFileInput = document.getElementById("tls-certbot-cert-file"); const tlsCertbotKeyFileInput = document.getElementById("tls-certbot-key-file"); const tlsCertbotCaFileInput = document.getElementById("tls-certbot-ca-file"); const tlsActiveCertSource = document.getElementById("tls-active-cert-source"); const tlsDownloadCertBtn = document.getElementById("tls-download-cert-btn"); const tlsTrustHelpBtn = document.getElementById("tls-trust-help-btn"); const tlsTrustInstructions = document.getElementById("tls-trust-instructions"); const oidcForm = document.getElementById("oidc-form"); const oidcPermissionNote = document.getElementById("oidc-permission-note"); const oidcSaveBtn = document.getElementById("oidc-save-btn"); const oidcEnabledInput = document.getElementById("oidc-enabled"); const oidcLabelInput = document.getElementById("oidc-label"); const oidcIssuerInput = document.getElementById("oidc-issuer"); const oidcClientIdInput = document.getElementById("oidc-client-id"); const oidcClientSecretInput = document.getElementById("oidc-client-secret"); const oidcSecretHint = document.getElementById("oidc-secret-hint"); const oidcRedirectUriInput = document.getElementById("oidc-redirect-uri"); const oidcScopesInput = document.getElementById("oidc-scopes"); const oidcClientAuthMethodInput = document.getElementById("oidc-client-auth-method"); const oidcAutoProvisionInput = document.getElementById("oidc-auto-provision"); const oidcAllowEmailLinkInput = document.getElementById("oidc-allow-email-link"); const oidcDefaultRoleInput = document.getElementById("oidc-default-role"); const oidcAllowedDomainsInput = document.getElementById("oidc-allowed-domains"); const oidcAllowedEmailsInput = document.getElementById("oidc-allowed-emails"); const oidcGroupClaimInput = document.getElementById("oidc-group-claim"); const oidcAdminGroupsInput = document.getElementById("oidc-admin-groups"); const oidcOperatorGroupsInput = document.getElementById("oidc-operator-groups"); const oidcViewerGroupsInput = document.getElementById("oidc-viewer-groups"); const appearanceForm = document.getElementById("appearance-form"); const appearancePermissionNote = document.getElementById("appearance-permission-note"); const appearanceSaveBtn = document.getElementById("appearance-save-btn"); const appearanceCustomCssInput = document.getElementById("appearance-custom-css"); const simpleThemeEnabledInput = document.getElementById("simple-theme-enabled"); const simpleThemeControls = document.getElementById("simple-theme-controls"); const simpleThemeAccentInput = document.getElementById("simple-theme-accent"); const simpleThemeBackgroundInput = document.getElementById("simple-theme-background"); const simpleThemeSurfaceInput = document.getElementById("simple-theme-surface"); const simpleThemeTextInput = document.getElementById("simple-theme-text"); const simpleThemeCornersInput = document.getElementById("simple-theme-corners"); const simpleThemeResetBtn = document.getElementById("simple-theme-reset"); const simpleThemePreview = document.getElementById("simple-theme-preview"); const simpleThemePreviewBar = document.getElementById("simple-theme-preview-bar"); const simpleThemePreviewBody = document.getElementById("simple-theme-preview-body"); const simpleThemePreviewDot = document.getElementById("simple-theme-preview-dot"); const simpleThemePreviewMuted = document.getElementById("simple-theme-preview-muted"); const simpleThemePreviewButton = document.getElementById("simple-theme-preview-button"); const brandProductNameInput = document.getElementById("brand-product-name"); const brandTabNameInput = document.getElementById("brand-tab-name"); const brandFaviconUrlInput = document.getElementById("brand-favicon-url"); const brandFaviconFileInput = document.getElementById("brand-favicon-file"); const brandDashboardBackgroundUrlInput = document.getElementById("brand-dashboard-background-url"); const brandDashboardBackgroundFileInput = document.getElementById("brand-dashboard-background-file"); const brandNavNameInput = document.getElementById("brand-nav-name"); const brandAccentColorInput = document.getElementById("brand-accent-color"); const brandIconClassInput = document.getElementById("brand-icon-class"); const brandNavLogoUrlInput = document.getElementById("brand-nav-logo-url"); const brandNavLogoFileInput = document.getElementById("brand-nav-logo-file"); const brandNavLogoAltInput = document.getElementById("brand-nav-logo-alt"); const brandLoginLogoUrlInput = document.getElementById("brand-login-logo-url"); const brandLoginLogoFileInput = document.getElementById("brand-login-logo-file"); const brandLoginLogoAltInput = document.getElementById("brand-login-logo-alt"); const brandLoginTitleInput = document.getElementById("brand-login-title"); const brandLoginSubtitleInput = document.getElementById("brand-login-subtitle"); const brandHeroImageUrlInput = document.getElementById("brand-hero-image-url"); const brandHeroImageFileInput = document.getElementById("brand-hero-image-file"); const brandHeroImageAltInput = document.getElementById("brand-hero-image-alt"); const brandFooterTextInput = document.getElementById("brand-footer-text"); const brandSupportTextInput = document.getElementById("brand-support-text"); const brandSupportUrlInput = document.getElementById("brand-support-url"); const exportImportSection = document.getElementById("export-import-section"); const exportSettingsBtn = document.getElementById("export-settings-btn"); const importSettingsFile = document.getElementById("import-settings-file"); const exportImportMessage = document.getElementById("export-import-message"); const wipeOfflineSection = document.getElementById("wipe-offline-section"); const wipeOfflineBtn = document.getElementById("wipe-offline-btn"); const wipeOfflineMessage = document.getElementById("wipe-offline-message"); let currentUser = null; let securityConfig = null; let tlsConfig = null; let oidcConfig = null; function showMessage(text, type = "ok") { if (!messageEl) return; messageEl.textContent = text; messageEl.classList.remove( "hidden", "text-emerald-200", "border-emerald-700", "bg-emerald-900/30", "text-rose-200", "border-rose-700", "bg-rose-900/30", ); if (type === "error") { messageEl.classList.add("text-rose-200", "border-rose-700", "bg-rose-900/30"); } else { messageEl.classList.add("text-emerald-200", "border-emerald-700", "bg-emerald-900/30"); } animateTextChange(messageEl); } function formatDate(timestamp) { return formatSharedDate(timestamp, "-"); } function showSettingsSuccess(text, showInline = showMessage) { showInline(text); window.showToast?.(text, "success"); } function canManageClientBans(role) { return userHas("network:manage-bans"); } function isAdmin(role) { return role === "admin"; } function userHas(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() { document.querySelectorAll("[data-permission]").forEach((el) => { const perm = el.dataset.permission; if (perm && userHas(perm)) { el.classList.remove("hidden"); } else { el.classList.add("hidden"); } }); const sidebar = document.getElementById("settings-sidebar"); if (!sidebar) return; const children = Array.from(sidebar.children); for (let i = 0; i < children.length; i++) { const label = children[i]; if (!label.classList.contains("settings-nav-group-label")) continue; let hasVisibleLink = false; for (let j = i + 1; j < children.length; j++) { const next = children[j]; if (next.classList.contains("settings-nav-group-label")) break; if (next.classList.contains("settings-nav-link") && !next.classList.contains("hidden")) { hasVisibleLink = true; break; } } label.classList.toggle("hidden", !hasVisibleLink); } } 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:oidc", "system:registration", "system:notifications", "system:chat", "system:appearance", "system:thumbnails", "system:backstage-dll", "system:input-archive", "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 `
${escapeHtml(perm)}
${escapeHtml(byId.get(perm) || "")}
`; }) .join(""); return `

${escapeHtml(group.title)}

${rows}
`; }) .join(""); } function getPasswordRequirementsText() { const minLength = Number(securityConfig?.passwordMinLength) || 6; const requirements = [`min ${minLength} chars`]; if (securityConfig?.passwordRequireUppercase) requirements.push("uppercase"); if (securityConfig?.passwordRequireLowercase) requirements.push("lowercase"); if (securityConfig?.passwordRequireNumber) requirements.push("number"); if (securityConfig?.passwordRequireSymbol) requirements.push("symbol"); return requirements.join(", "); } function updatePasswordPolicyUi() { const minLength = Number(securityConfig?.passwordMinLength) || 6; if (newPasswordInput) { newPasswordInput.minLength = minLength; newPasswordInput.placeholder = `New password (${getPasswordRequirementsText()})`; } if (confirmPasswordInput) { confirmPasswordInput.minLength = minLength; } if (passwordPolicyHint) { passwordPolicyHint.textContent = `Policy: ${getPasswordRequirementsText()}`; } } function setSecurityFormDisabled(disabled) { const controls = [ securitySessionTtlInput, securityLoginMaxAttemptsInput, securityLoginWindowInput, securityLockoutInput, securityPasswordMinInput, securityRequireUppercaseInput, securityRequireLowercaseInput, securityRequireNumberInput, securityRequireSymbolInput, securityMfaAdminsInput, securityMfaNonAdminsInput, securitySaveBtn, ]; for (const control of controls) { if (!control) continue; control.disabled = disabled; } } function setTlsFormDisabled(disabled) { const controls = [ tlsCertbotEmailInput, tlsCertbotEnabledInput, tlsCertbotLivePathInput, tlsCertbotDomainInput, tlsCertbotCertFileInput, tlsCertbotKeyFileInput, tlsCertbotCaFileInput, tlsSaveBtn, tlsCertbotAutoBtn, ]; for (const control of controls) { if (!control) continue; control.disabled = disabled; } } function setTlsAutoSetupRunning(running) { if (!tlsCertbotAutoBtn) return; tlsCertbotAutoBtn.disabled = running; tlsCertbotAutoBtn.innerHTML = running ? 'Running Certbot Setup...' : 'Auto Setup Free SSL (Let\'s Encrypt)'; } function applySecurityForm() { if (!securityConfig) return; securitySessionTtlInput.value = String(securityConfig.sessionTtlHours || 168); securityLoginMaxAttemptsInput.value = String(securityConfig.loginMaxAttempts || 5); securityLoginWindowInput.value = String(securityConfig.loginWindowMinutes || 15); securityLockoutInput.value = String(securityConfig.loginLockoutMinutes || 30); securityPasswordMinInput.value = String(securityConfig.passwordMinLength || 6); securityRequireUppercaseInput.checked = Boolean(securityConfig.passwordRequireUppercase); securityRequireLowercaseInput.checked = Boolean(securityConfig.passwordRequireLowercase); securityRequireNumberInput.checked = Boolean(securityConfig.passwordRequireNumber); securityRequireSymbolInput.checked = Boolean(securityConfig.passwordRequireSymbol); if (securityMfaAdminsInput) securityMfaAdminsInput.checked = Boolean(securityConfig.mfaRequiredForAdmins); if (securityMfaNonAdminsInput) securityMfaNonAdminsInput.checked = Boolean(securityConfig.mfaRequiredForNonAdmins); updatePasswordPolicyUi(); } async function loadSecurityPolicy() { if (!currentUser) return; if (!userHas("system:security")) { securityPermissionNote.classList.remove("hidden"); setSecurityFormDisabled(true); securityConfig = { passwordMinLength: 6, passwordRequireUppercase: false, passwordRequireLowercase: false, passwordRequireNumber: false, passwordRequireSymbol: false, mfaRequiredForAdmins: false, mfaRequiredForNonAdmins: false, }; updatePasswordPolicyUi(); return; } securityPermissionNote.classList.add("hidden"); const res = await fetch("/api/settings/security", { credentials: "include" }); if (!res.ok) { showMessage("Failed to load security settings.", "error"); setSecurityFormDisabled(true); return; } const data = await res.json().catch(() => ({})); securityConfig = data.security || null; applySecurityForm(); setSecurityFormDisabled(false); } function applyTlsForm() { const certbot = tlsConfig?.certbot || {}; tlsCertbotEnabledInput.checked = Boolean(certbot.enabled); tlsCertbotEmailInput.value = ""; tlsCertbotLivePathInput.value = certbot.livePath || "/etc/letsencrypt/live"; tlsCertbotDomainInput.value = certbot.domain || ""; tlsCertbotCertFileInput.value = certbot.certFileName || "fullchain.pem"; tlsCertbotKeyFileInput.value = certbot.keyFileName || "privkey.pem"; tlsCertbotCaFileInput.value = certbot.caFileName || "chain.pem"; } tlsTrustHelpBtn?.addEventListener("click", () => { if (tlsTrustInstructions?.classList.contains("hidden")) { revealElement(tlsTrustInstructions, { duration: 200, offset: 5 }); } else { concealElement(tlsTrustInstructions, { duration: 120, offset: -3 }); } }); async function loadActiveTlsCertificate() { const guide = getCertificateTrustGuide(); if (tlsTrustHelpBtn) { tlsTrustHelpBtn.innerHTML = `Trust on ${guide.label}`; } if (tlsTrustInstructions) { tlsTrustInstructions.innerHTML = guide.instructions; } try { const res = await fetch("/api/cert/info", { credentials: "include" }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); const source = String(data.source || "unknown"); if (tlsActiveCertSource) { tlsActiveCertSource.textContent = source === "self-signed" ? "Self-signed" : source === "certbot" ? "Certbot / Let's Encrypt" : source === "configured" ? "Configured certificate" : "Unknown source"; tlsActiveCertSource.classList.toggle("border-amber-600", source === "self-signed"); tlsActiveCertSource.classList.toggle("bg-amber-950", source === "self-signed"); tlsActiveCertSource.classList.toggle("text-amber-200", source === "self-signed"); } } catch { if (tlsActiveCertSource) tlsActiveCertSource.textContent = "Unavailable"; if (tlsDownloadCertBtn) { tlsDownloadCertBtn.setAttribute("aria-disabled", "true"); tlsDownloadCertBtn.classList.add("pointer-events-none", "opacity-50"); } } } async function loadTlsSettings() { if (!currentUser) return; if (!userHas("system:tls")) { tlsPermissionNote.classList.remove("hidden"); setTlsFormDisabled(true); tlsConfig = { certbot: { enabled: false, livePath: "/etc/letsencrypt/live", domain: "", certFileName: "fullchain.pem", keyFileName: "privkey.pem", caFileName: "chain.pem", }, }; applyTlsForm(); return; } tlsPermissionNote.classList.add("hidden"); const res = await fetch("/api/settings/tls", { credentials: "include" }); if (!res.ok) { showMessage("Failed to load TLS settings.", "error"); setTlsFormDisabled(true); return; } const data = await res.json().catch(() => ({})); tlsConfig = data.tls || null; applyTlsForm(); setTlsFormDisabled(false); await loadActiveTlsCertificate(); } function listToCsv(value) { return Array.isArray(value) ? value.join(", ") : ""; } function csvToList(value) { return String(value || "") .split(",") .map((item) => item.trim()) .filter(Boolean); } function setOidcFormDisabled(disabled) { const controls = [ oidcEnabledInput, oidcLabelInput, oidcIssuerInput, oidcClientIdInput, oidcClientSecretInput, oidcRedirectUriInput, oidcScopesInput, oidcClientAuthMethodInput, oidcAutoProvisionInput, oidcAllowEmailLinkInput, oidcDefaultRoleInput, oidcAllowedDomainsInput, oidcAllowedEmailsInput, oidcGroupClaimInput, oidcAdminGroupsInput, oidcOperatorGroupsInput, oidcViewerGroupsInput, oidcSaveBtn, ]; for (const control of controls) { if (!control) continue; control.disabled = disabled; } } function applyOidcForm() { const cfg = oidcConfig || {}; if (oidcEnabledInput) oidcEnabledInput.checked = Boolean(cfg.enabled); if (oidcLabelInput) oidcLabelInput.value = cfg.label || "Single sign-on"; if (oidcIssuerInput) oidcIssuerInput.value = cfg.issuer || ""; if (oidcClientIdInput) oidcClientIdInput.value = cfg.clientId || ""; if (oidcClientSecretInput) oidcClientSecretInput.value = ""; if (oidcSecretHint) { oidcSecretHint.textContent = cfg.clientSecretSet ? "A client secret is saved. Leave this blank to keep it." : "No client secret is currently saved."; } if (oidcRedirectUriInput) oidcRedirectUriInput.value = cfg.redirectUri || ""; if (oidcScopesInput) oidcScopesInput.value = listToCsv(cfg.scopes || ["openid", "profile", "email"]); if (oidcClientAuthMethodInput) oidcClientAuthMethodInput.value = cfg.clientAuthMethod || "client_secret_post"; if (oidcAutoProvisionInput) oidcAutoProvisionInput.checked = cfg.autoProvision !== false; if (oidcAllowEmailLinkInput) oidcAllowEmailLinkInput.checked = Boolean(cfg.allowEmailLink); if (oidcDefaultRoleInput) oidcDefaultRoleInput.value = cfg.defaultRole || "viewer"; if (oidcAllowedDomainsInput) oidcAllowedDomainsInput.value = listToCsv(cfg.allowedDomains); if (oidcAllowedEmailsInput) oidcAllowedEmailsInput.value = listToCsv(cfg.allowedEmails); if (oidcGroupClaimInput) oidcGroupClaimInput.value = cfg.groupClaim || "groups"; if (oidcAdminGroupsInput) oidcAdminGroupsInput.value = listToCsv(cfg.adminGroups); if (oidcOperatorGroupsInput) oidcOperatorGroupsInput.value = listToCsv(cfg.operatorGroups); if (oidcViewerGroupsInput) oidcViewerGroupsInput.value = listToCsv(cfg.viewerGroups); } async function loadOidcSettings() { if (!currentUser) return; if (!userHas("system:oidc")) { if (oidcPermissionNote) oidcPermissionNote.classList.remove("hidden"); setOidcFormDisabled(true); return; } if (oidcPermissionNote) oidcPermissionNote.classList.add("hidden"); const res = await fetch("/api/settings/oidc", { credentials: "include" }); if (!res.ok) { showMessage("Failed to load OIDC settings.", "error"); setOidcFormDisabled(true); return; } const data = await res.json().catch(() => ({})); oidcConfig = data.oidc || null; applyOidcForm(); setOidcFormDisabled(false); } function updateNavLayoutButtons(mode, sidebarBtn, topbarBtn) { const active = ["bg-indigo-600/80", "border-indigo-500", "text-white"]; const inactive = ["bg-slate-800", "border-slate-700", "text-slate-400", "hover:bg-slate-700", "hover:text-slate-200"]; const base = ["nav-layout-btn", "flex-1", "flex", "items-center", "justify-center", "gap-2", "px-3", "py-2", "rounded-lg", "border", "text-sm", "font-medium", "transition-colors"]; sidebarBtn.className = [...base, ...(mode === "sidebar" ? active : inactive)].join(" "); topbarBtn.className = [...base, ...(mode === "topbar" ? active : inactive)].join(" "); sidebarBtn.dataset.selected = mode === "sidebar" ? "true" : "false"; topbarBtn.dataset.selected = mode === "topbar" ? "true" : "false"; } function loadPrefs() { prefNotificationsInput.checked = getNotificationsEnabled(); if (prefDesktopNotificationsInput) { prefDesktopNotificationsInput.checked = getDesktopNotificationsEnabled(); } const refreshSeconds = Number(localStorage.getItem(PREF_REFRESH_KEY) || 8); prefRefreshSecondsInput.value = String(Math.min(120, Math.max(3, refreshSeconds))); const navMode = localStorage.getItem(NAV_MODE_KEY) || "topbar"; const sidebarBtn = document.getElementById("pref-nav-sidebar"); const topbarBtn = document.getElementById("pref-nav-topbar"); if (sidebarBtn && topbarBtn) { updateNavLayoutButtons(navMode === "topbar" ? "topbar" : "sidebar", sidebarBtn, topbarBtn); } const navHiddenInput = document.getElementById("pref-nav-hidden"); if (navHiddenInput) { navHiddenInput.checked = localStorage.getItem(NAV_HIDDEN_KEY) === "true"; } } function initEasterEggPreference() { if (!easterEggEnabledInput) return; easterEggEnabledInput.checked = isEasterEggEnabled(); easterEggEnabledInput.addEventListener("change", async () => { const enabled = easterEggEnabledInput.checked; setEasterEggEnabled(enabled); if (!easterEggStatus) return; if (!enabled) { easterEggStatus.textContent = "Disabled. The image will not be requested."; easterEggStatus.className = "text-xs text-slate-500"; return; } easterEggEnabledInput.disabled = true; easterEggStatus.textContent = "Loading the easter egg…"; easterEggStatus.className = "text-xs text-violet-300"; try { await showEnabledEasterEgg({ force: true }); easterEggStatus.textContent = "Enabled and shown in the browser console."; easterEggStatus.className = "text-xs text-emerald-400"; } catch (error) { setEasterEggEnabled(false); easterEggEnabledInput.checked = false; easterEggStatus.textContent = `Could not load the easter egg: ${error.message}`; easterEggStatus.className = "text-xs text-rose-400"; } finally { easterEggEnabledInput.disabled = false; } }); } async function loadCurrentUser() { const res = await fetch("/api/auth/me", { credentials: "include" }); if (!res.ok) { window.location.href = "/"; return; } currentUser = await res.json(); usernameEl.textContent = currentUser.username || "unknown"; roleEl.textContent = currentUser.role || "unknown"; } async function updatePassword(event) { event.preventDefault(); if (!currentUser?.userId) return; const currentPassword = currentPasswordInput.value; const newPassword = newPasswordInput.value; const confirmPassword = confirmPasswordInput.value; const minLength = Number(securityConfig?.passwordMinLength) || 6; if (newPassword.length < minLength) { showMessage(`New password must be at least ${minLength} characters.`, "error"); return; } if (newPassword !== confirmPassword) { showMessage("Password confirmation does not match.", "error"); return; } const res = await fetch(`/api/users/${currentUser.userId}/password`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ currentPassword, newPassword, }), }); const data = await res.json().catch(() => ({})); if (!res.ok) { showMessage(data.error || "Failed to update password.", "error"); return; } passwordForm.reset(); showSettingsSuccess("Password updated successfully."); } function showMfaMessage(text, type = "ok") { if (!mfaMessage) return; mfaMessage.textContent = text; mfaMessage.className = `text-sm rounded-lg px-3 py-2 border ${ type === "error" ? "border-rose-800 bg-rose-900/20 text-rose-200" : "border-emerald-800 bg-emerald-900/20 text-emerald-200" }`; mfaMessage.classList.remove("hidden"); setTimeout(() => mfaMessage.classList.add("hidden"), 5000); } async function loadMfaStatus() { if (!mfaStatusText) return; try { const res = await fetch("/api/mfa/status", { credentials: "include" }); if (!res.ok) { mfaStatusText.textContent = "Failed to load MFA status."; return; } const data = await res.json(); const requiredText = data.required ? " Required by policy." : ""; mfaStatusText.textContent = data.enabled ? `MFA is enabled.${requiredText}` : `MFA is not enabled.${requiredText}`; if (mfaStartBtn) mfaStartBtn.classList.toggle("hidden", !!data.enabled); if (mfaDisablePanel) mfaDisablePanel.classList.toggle("hidden", !data.enabled); if (mfaDisableBtn) mfaDisableBtn.disabled = !!data.required; if (mfaDisablePanel) { mfaDisablePanel.classList.toggle("opacity-60", !!data.required); } } catch { mfaStatusText.textContent = "Failed to load MFA status."; } } function renderMfaQrCode(svgMarkup) { if (!mfaQrCode) return; mfaQrCode.replaceChildren(); const svg = typeof svgMarkup === "string" ? svgMarkup.trim() : ""; if (!svg || svg.length > 512 * 1024 || !/^]/i.test(svg) || !/<\/svg>$/i.test(svg)) { return; } // Keep API-provided SVG out of the document DOM. SVG loaded through an // image element is an inert image context, so scripts and event handlers in // a compromised response cannot run in the settings page. const image = document.createElement("img"); image.alt = "MFA setup QR code"; image.className = "block h-full w-full"; image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; mfaQrCode.appendChild(image); } function safeMfaOtpauthUrl(value) { const candidate = typeof value === "string" ? value.trim() : ""; if (!candidate || candidate.length > 4096) return "#"; try { const parsed = new URL(candidate); return parsed.protocol === "otpauth:" && parsed.hostname === "totp" ? parsed.href : "#"; } catch { return "#"; } } async function startMfaSetup() { try { const res = await fetch("/api/mfa/setup", { method: "POST", credentials: "include" }); const data = await res.json().catch(() => ({})); if (!res.ok) { showMfaMessage(data.error || "Failed to start MFA setup.", "error"); return; } renderMfaQrCode(data.qrSvg); if (mfaSecretText) mfaSecretText.textContent = data.secret || ""; if (mfaOtpauthLink) mfaOtpauthLink.setAttribute("href", safeMfaOtpauthUrl(data.otpauthUrl)); revealElement(mfaSetupPanel, { duration: 240, offset: 8 }); if (mfaEnableCodeInput) mfaEnableCodeInput.focus(); } catch { showMfaMessage("Network error while starting MFA setup.", "error"); } } async function enableMfa() { const code = String(mfaEnableCodeInput?.value || "").trim(); try { const res = await fetch("/api/mfa/enable", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ code }), }); const data = await res.json().catch(() => ({})); if (!res.ok) { showMfaMessage(data.error || "Failed to enable MFA.", "error"); return; } concealElement(mfaSetupPanel, { duration: 130, offset: -4 }); if (mfaEnableCodeInput) mfaEnableCodeInput.value = ""; showMfaMessage("MFA enabled."); await loadMfaStatus(); } catch { showMfaMessage("Network error while enabling MFA.", "error"); } } async function disableMfa() { const currentPassword = String(mfaDisablePasswordInput?.value || ""); const code = String(mfaDisableCodeInput?.value || "").trim(); try { const res = await fetch("/api/mfa/disable", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ currentPassword, code }), }); const data = await res.json().catch(() => ({})); if (!res.ok) { showMfaMessage(data.error || "Failed to disable MFA.", "error"); return; } if (mfaDisablePasswordInput) mfaDisablePasswordInput.value = ""; if (mfaDisableCodeInput) mfaDisableCodeInput.value = ""; showMfaMessage("MFA disabled."); await loadMfaStatus(); } catch { showMfaMessage("Network error while disabling MFA.", "error"); } } function savePrefs(event) { event.preventDefault(); const refreshSeconds = Math.min( 120, Math.max(3, Number(prefRefreshSecondsInput.value || 8)), ); setNotificationsEnabled(prefNotificationsInput.checked); localStorage.setItem(PREF_REFRESH_KEY, String(refreshSeconds)); prefRefreshSecondsInput.value = String(refreshSeconds); const wantsDesktop = prefDesktopNotificationsInput ? prefDesktopNotificationsInput.checked : false; if (wantsDesktop) { requestDesktopNotificationPermission().then((perm) => { if (perm === "granted") { setDesktopNotificationsEnabled(true); if (prefDesktopNotificationsHint) prefDesktopNotificationsHint.classList.add("hidden"); showSettingsSuccess("Preferences saved. Desktop notifications enabled."); } else { setDesktopNotificationsEnabled(false); if (prefDesktopNotificationsInput) prefDesktopNotificationsInput.checked = false; if (prefDesktopNotificationsHint) prefDesktopNotificationsHint.classList.remove("hidden"); showMessage("Desktop notifications require browser permission — not granted.", "error"); } }); } else { setDesktopNotificationsEnabled(false); if (prefDesktopNotificationsHint) prefDesktopNotificationsHint.classList.add("hidden"); showSettingsSuccess("Preferences saved."); } const navHiddenInput = document.getElementById("pref-nav-hidden"); if (navHiddenInput) { localStorage.setItem(NAV_HIDDEN_KEY, String(navHiddenInput.checked)); document.body.classList.toggle("nav-hidden", navHiddenInput.checked); } } async function saveSecurityPolicy(event) { event.preventDefault(); if (!requireUiPermission("system:security", "Security policy permission required.")) { return; } const payload = { sessionTtlHours: Number(securitySessionTtlInput.value || 168), loginMaxAttempts: Number(securityLoginMaxAttemptsInput.value || 5), loginWindowMinutes: Number(securityLoginWindowInput.value || 15), loginLockoutMinutes: Number(securityLockoutInput.value || 30), passwordMinLength: Number(securityPasswordMinInput.value || 6), passwordRequireUppercase: securityRequireUppercaseInput.checked, passwordRequireLowercase: securityRequireLowercaseInput.checked, passwordRequireNumber: securityRequireNumberInput.checked, passwordRequireSymbol: securityRequireSymbolInput.checked, mfaRequiredForAdmins: !!securityMfaAdminsInput?.checked, mfaRequiredForNonAdmins: !!securityMfaNonAdminsInput?.checked, }; const res = await fetch("/api/settings/security", { method: "PUT", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify(payload), }); const data = await res.json().catch(() => ({})); if (!res.ok) { showMessage(data.error || "Failed to save security policy.", "error"); return; } securityConfig = data.security || payload; applySecurityForm(); showSettingsSuccess("Security policy updated."); } async function saveTlsSettings(event) { event.preventDefault(); if (!requireUiPermission("system:tls", "TLS settings permission required.")) { return; } const payload = { certbot: { enabled: tlsCertbotEnabledInput.checked, livePath: String(tlsCertbotLivePathInput.value || "").trim(), domain: String(tlsCertbotDomainInput.value || "").trim(), certFileName: String(tlsCertbotCertFileInput.value || "").trim(), keyFileName: String(tlsCertbotKeyFileInput.value || "").trim(), caFileName: String(tlsCertbotCaFileInput.value || "").trim(), }, }; const res = await fetch("/api/settings/tls", { method: "PUT", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify(payload), }); const data = await res.json().catch(() => ({})); if (!res.ok) { showMessage(data.error || "Failed to save TLS settings.", "error"); return; } tlsConfig = data.tls || payload; applyTlsForm(); showSettingsSuccess("TLS settings updated. Restart server to apply."); } async function saveOidcSettings(event) { event.preventDefault(); if (!requireUiPermission("system:oidc", "OIDC settings permission required.")) { return; } const payload = { enabled: !!oidcEnabledInput?.checked, label: String(oidcLabelInput?.value || "").trim(), issuer: String(oidcIssuerInput?.value || "").trim(), clientId: String(oidcClientIdInput?.value || "").trim(), redirectUri: String(oidcRedirectUriInput?.value || "").trim(), scopes: csvToList(oidcScopesInput?.value), clientAuthMethod: oidcClientAuthMethodInput?.value || "client_secret_post", autoProvision: !!oidcAutoProvisionInput?.checked, allowEmailLink: !!oidcAllowEmailLinkInput?.checked, defaultRole: oidcDefaultRoleInput?.value || "viewer", allowedDomains: csvToList(oidcAllowedDomainsInput?.value), allowedEmails: csvToList(oidcAllowedEmailsInput?.value), groupClaim: String(oidcGroupClaimInput?.value || "").trim(), adminGroups: csvToList(oidcAdminGroupsInput?.value), operatorGroups: csvToList(oidcOperatorGroupsInput?.value), viewerGroups: csvToList(oidcViewerGroupsInput?.value), }; const clientSecret = String(oidcClientSecretInput?.value || ""); if (clientSecret) { payload.clientSecret = clientSecret; } if (payload.enabled) { if (!payload.issuer) { showMessage("OIDC issuer URL is required when OIDC is enabled.", "error"); oidcIssuerInput?.focus(); return; } if (!payload.redirectUri) { showMessage("OIDC redirect URI is required when OIDC is enabled.", "error"); oidcRedirectUriInput?.focus(); return; } if (!payload.clientId) { showMessage("OIDC client ID is required when OIDC is enabled.", "error"); oidcClientIdInput?.focus(); return; } if (!payload.scopes.length || !payload.scopes.includes("openid")) { showMessage("OIDC scopes must include openid.", "error"); oidcScopesInput?.focus(); return; } if (payload.clientAuthMethod !== "none" && !clientSecret && !oidcConfig?.clientSecretSet) { showMessage("OIDC client secret is required for the selected client auth method.", "error"); oidcClientSecretInput?.focus(); return; } } const res = await fetch("/api/settings/oidc", { method: "PUT", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify(payload), }); const data = await res.json().catch(() => ({})); if (!res.ok) { showMessage(data.error || "Failed to save OIDC settings.", "error"); return; } oidcConfig = data.oidc || payload; applyOidcForm(); showSettingsSuccess("OIDC settings saved. Environment variables still take priority after restart."); } async function runCertbotAutoSetup() { if (!requireUiPermission("system:tls", "TLS settings permission required.")) { return; } const domain = String(tlsCertbotDomainInput.value || "").trim(); const email = String(tlsCertbotEmailInput.value || "").trim(); const livePath = String(tlsCertbotLivePathInput.value || "").trim() || "/etc/letsencrypt/live"; if (!domain) { showMessage("Please enter a domain first.", "error"); return; } if (!email) { showMessage("Please enter an email first.", "error"); return; } setTlsAutoSetupRunning(true); try { const res = await fetch("/api/settings/tls/certbot/setup", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ domain, email, livePath }), }); const data = await res.json().catch(() => ({})); if (!res.ok) { showMessage(data.error || "Certbot setup failed.", "error"); return; } tlsConfig = data.tls || tlsConfig; applyTlsForm(); const details = data?.certbot?.certPath ? ` Cert: ${data.certbot.certPath}` : ""; showMessage(`${data.message || "Certbot setup complete."}${details}`); } catch (error) { showMessage(`Certbot setup failed: ${String(error?.message || error)}`, "error"); } finally { setTlsAutoSetupRunning(false); } } async function loadMyTelegram() { if (!myTelegramChatIdInput) return; if (currentUser?.telegramChatId) { myTelegramChatIdInput.value = currentUser.telegramChatId; } else { try { const res = await fetch("/api/settings/telegram", { credentials: "include" }); if (res.ok) { const data = await res.json(); myTelegramChatIdInput.value = data.telegramChatId || ""; } } catch {} } } async function saveMyTelegram() { if (!myTelegramChatIdInput) return; const chatId = myTelegramChatIdInput.value.trim(); try { const res = await fetch("/api/settings/telegram", { method: "PUT", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ telegramChatId: chatId }), }); const data = await res.json().catch(() => ({})); if (!res.ok) { showMessage(data.error || "Failed to save Telegram settings.", "error"); return; } myTelegramChatIdInput.value = data.telegramChatId || ""; showSettingsSuccess(chatId ? "Telegram chat ID saved." : "Telegram notifications disabled."); } catch { showMessage("Failed to save Telegram settings.", "error"); } } async function loadBannedIps() { if (!currentUser) return; if (!canManageClientBans(currentUser.role)) { bansPermissionNote.classList.remove("hidden"); refreshBansBtn.disabled = true; bansTableBody.innerHTML = ` No access `; return; } bansPermissionNote.classList.add("hidden"); const res = await fetch("/api/clients/banned-ips", { credentials: "include" }); if (!res.ok) { bansTableBody.innerHTML = ` Failed to load banned IPs `; return; } const data = await res.json().catch(() => ({ items: [] })); const items = Array.isArray(data.items) ? data.items : []; if (items.length === 0) { bansTableBody.innerHTML = ` No banned IPs `; return; } bansTableBody.innerHTML = items .map( (item) => ` ${escapeHtml(item.ip)} ${escapeHtml(item.reason || "Manual ban")} ${formatDate(item.createdAt)} `, ) .join(""); } async function handleUnbanClick(event) { const button = event.target.closest(".unban-btn"); if (!button) return; const ip = button.dataset.ip; if (!ip) return; if (!confirm(`Unban ${ip}?`)) return; button.disabled = true; const res = await fetch(`/api/clients/banned-ips?ip=${encodeURIComponent(ip)}`, { method: "DELETE", credentials: "include", }); const data = await res.json().catch(() => ({})); if (!res.ok) { showMessage(data.error || `Failed to unban ${ip}.`, "error"); button.disabled = false; return; } showMessage(`Unbanned ${ip}.`); await loadBannedIps(); } // ---- Build IDs section ---------------------------------------------------- const buildsSection = document.getElementById("section-builds"); const buildsTableBody = document.getElementById("builds-table-body"); const refreshBuildsBtn = document.getElementById("refresh-builds-btn"); const buildsShowAllInput = document.getElementById("builds-show-all"); const buildsShowAllWrap = document.getElementById("builds-show-all-wrap"); const buildsMessageEl = document.getElementById("builds-message"); function showBuildsMessage(text, type = "ok") { if (!buildsMessageEl) return; buildsMessageEl.textContent = text; buildsMessageEl.classList.remove( "hidden", "text-emerald-200", "border-emerald-700", "bg-emerald-900/30", "text-rose-200", "border-rose-700", "bg-rose-900/30", ); if (type === "error") { buildsMessageEl.classList.add("text-rose-200", "border-rose-700", "bg-rose-900/30"); } else { buildsMessageEl.classList.add("text-emerald-200", "border-emerald-700", "bg-emerald-900/30"); } setTimeout(() => buildsMessageEl.classList.add("hidden"), 4000); } function canManageBuilds(role) { return role === "admin" || role === "operator"; } async function loadBuilds() { if (!buildsTableBody) return; if (!canManageBuilds(currentUser?.role)) return; const showAll = !!buildsShowAllInput?.checked && isAdmin(currentUser?.role); const url = showAll ? "/api/build/list?all=true" : "/api/build/list"; buildsTableBody.innerHTML = ` Loading... `; let res; try { res = await fetch(url, { credentials: "include" }); } catch { buildsTableBody.innerHTML = ` Network error `; return; } if (!res.ok) { buildsTableBody.innerHTML = ` Failed to load builds (HTTP ${res.status}) `; return; } const data = await res.json().catch(() => ({ builds: [] })); const builds = Array.isArray(data.builds) ? data.builds : []; if (builds.length === 0) { buildsTableBody.innerHTML = ` No builds `; return; } buildsTableBody.innerHTML = builds.map((b) => { const tag = b.buildTag ? String(b.buildTag) : ""; const tagShort = tag ? `${tag.substring(0, 8)}...` : "—"; const idShort = `${String(b.id).substring(0, 8)}...`; const statusBadge = b.blocked ? `Blocked` : `Active`; const actionBtn = b.blocked ? `` : ``; const tagCell = tag ? `${escapeHtml(tagShort)}` : `—`; return ` ${escapeHtml(idShort)} ${tagCell} ${statusBadge} ${formatDate(b.startTime)} ${Number(b.claimCount) || 0} ${actionBtn} `; }).join(""); } async function handleBuildBlockClick(event) { const btn = event.target.closest(".builds-action-btn"); if (!btn) return; const buildId = btn.dataset.buildId; const block = btn.dataset.block === "true"; if (!buildId) return; const verb = block ? "Block" : "Unblock"; if (block) { if (!confirm(`Block build ${buildId.substring(0, 8)}...?\n\nAny agent currently connected from this build will be disconnected, and future connection attempts with this build's tag will be rejected.`)) return; } btn.disabled = true; let res; try { res = await fetch(`/api/build/${encodeURIComponent(buildId)}/block`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ blocked: block }), }); } catch { showBuildsMessage(`${verb} failed: network error`, "error"); btn.disabled = false; return; } const data = await res.json().catch(() => ({})); if (!res.ok || data.success === false) { showBuildsMessage(data.error || `${verb} failed`, "error"); btn.disabled = false; return; } if (block && typeof data.disconnected === "number" && data.disconnected > 0) { showBuildsMessage(`Build blocked. Disconnected ${data.disconnected} live agent${data.disconnected === 1 ? "" : "s"}.`); } else { showBuildsMessage(`Build ${block ? "blocked" : "unblocked"}.`); } await loadBuilds(); } // ---- Sidebar nav --------------------------------------------------------- /** * Update the --settings-scroll-mt CSS variable to match whatever the top nav * is currently consuming at the top of the viewport. In topbar mode this is * the topbar's rendered height + an 8px buffer so the section title isn't * flush against the nav. In sidebar mode the nav doesn't obstruct the top, so * we use a small visual margin. Called on init and on viewport changes. */ function updateSettingsScrollOffset() { const root = document.documentElement; const nav = document.getElementById("top-nav"); // Sidebar mode (left column): no top obstruction. Body gets `sb-ready` from // the adaptive nav layout. const isSidebarMode = document.body.classList.contains("sb-ready"); // Nav hidden via Ctrl+\: also no obstruction. const isHidden = document.body.classList.contains("nav-hidden"); let offsetPx = 16; // matches the old scroll-mt-4 default if (!isSidebarMode && !isHidden && nav) { const rect = nav.getBoundingClientRect(); // Only treat as obstructing if the nav is actually pinned at the top and // spans most of the width. (Defensive — covers mobile/dropdown variations.) if (rect.top <= 0 && rect.height > 0 && rect.width >= window.innerWidth * 0.5) { offsetPx = Math.round(rect.height) + 8; } } root.style.setProperty("--settings-scroll-mt", `${offsetPx}px`); } function initSettingsSidebar() { const sidebar = document.getElementById("settings-sidebar"); if (!sidebar) return; const links = Array.from(sidebar.querySelectorAll(".settings-nav-link")); // Build the section→link map up front so click + observer share it. const sectionMap = new Map(); // sectionsInOrder preserves document order for "last visible at page bottom" // tie-breaking (see updateActive below). const sectionsInOrder = []; for (const link of links) { const id = link.dataset.target; const section = id ? document.getElementById(id) : null; if (section) { sectionMap.set(section, link); sectionsInOrder.push(section); } } if (sectionMap.size === 0) return; let activeLink = null; function setActive(link) { if (link === activeLink) return; activeLink = link; links.forEach((l) => l.classList.remove("active")); if (link) { link.classList.add("active"); animateElement(link, [ { transform: "translateX(-2px)", opacity: 0.8 }, { transform: "translateX(0)", opacity: 1 }, ], { duration: 180 }); } } function isAtPageBottom() { return window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 4; } // Click → smooth scroll to section. Mark the link active immediately so the // bottom links (where scroll can't reach the section's top) still highlight. sidebar.addEventListener("click", (event) => { const link = event.target.closest(".settings-nav-link"); if (!link) return; event.preventDefault(); const id = link.dataset.target; if (!id) return; const target = document.getElementById(id); if (!target) return; // Re-measure the topbar offset right before scrolling, so a layout shift // (nav-hidden toggle, viewport resize, dropdown opened) doesn't leave the // section partly hidden under the nav. updateSettingsScrollOffset(); _clickLockUntil = Date.now() + 1200; setActive(link); emphasizeElement(target, "rgba(56, 189, 248, 0.18)"); target.scrollIntoView({ behavior: "smooth", block: "start" }); history.replaceState(null, "", `#${id}`); }); // Active-section highlight via IntersectionObserver + page-bottom override. const visible = new Set(); let _clickLockUntil = 0; function updateActive() { if (Date.now() < _clickLockUntil) return; // Special case: if the user has scrolled as far as the page allows, the // last visible section becomes active (otherwise the topmost-visible // heuristic would leave shorter trailing sections — like Custom CSS or // Export / Import — unable to ever become active because the page can't // scroll their headings to the top of the viewport). if (isAtPageBottom() && visible.size > 0) { let lastEl = null; for (const section of sectionsInOrder) { if (visible.has(section)) lastEl = section; } if (lastEl) { setActive(sectionMap.get(lastEl)); return; } } // Default: topmost visible section. let topEl = null; let topY = Infinity; for (const el of visible) { const y = el.getBoundingClientRect().top; if (y < topY) { topY = y; topEl = el; } } if (topEl) setActive(sectionMap.get(topEl)); } const observer = new IntersectionObserver( (entries) => { for (const e of entries) { if (e.isIntersecting) visible.add(e.target); else visible.delete(e.target); } updateActive(); }, { rootMargin: "-10% 0px -70% 0px", threshold: 0 }, ); for (const section of sectionMap.keys()) observer.observe(section); // Re-run the picker on raw scroll too, so the page-bottom override fires // even when no IntersectionObserver entry has changed (last sections may // already be inside `visible` and the rootMargin won't re-trigger). let scrollRaf = 0; window.addEventListener("scroll", () => { if (scrollRaf) return; scrollRaf = requestAnimationFrame(() => { scrollRaf = 0; updateActive(); }); }, { passive: true }); } const brandInputs = [ brandProductNameInput, brandTabNameInput, brandFaviconUrlInput, brandFaviconFileInput, brandDashboardBackgroundUrlInput, brandDashboardBackgroundFileInput, brandNavNameInput, brandAccentColorInput, brandIconClassInput, brandNavLogoUrlInput, brandNavLogoFileInput, brandNavLogoAltInput, brandLoginLogoUrlInput, brandLoginLogoFileInput, brandLoginLogoAltInput, brandLoginTitleInput, brandLoginSubtitleInput, brandHeroImageUrlInput, brandHeroImageFileInput, brandHeroImageAltInput, brandFooterTextInput, brandSupportTextInput, brandSupportUrlInput, ]; function setAppearanceFormDisabled(disabled) { const simpleThemeInputs = [ simpleThemeEnabledInput, simpleThemeAccentInput, simpleThemeBackgroundInput, simpleThemeSurfaceInput, simpleThemeTextInput, simpleThemeCornersInput, simpleThemeResetBtn, ]; for (const input of [...brandInputs, ...simpleThemeInputs, appearanceCustomCssInput, appearanceSaveBtn]) { if (input) input.disabled = disabled; } } const SIMPLE_THEME_START = "/* === OVERLORD SIMPLE THEME START === */"; const SIMPLE_THEME_END = "/* === OVERLORD SIMPLE THEME END === */"; const SIMPLE_THEME_BLOCK_RE = /\/\* === OVERLORD SIMPLE THEME START === \*\/[\s\S]*?\/\* === OVERLORD SIMPLE THEME END === \*\/\s*/; const SIMPLE_THEME_DEFAULTS = Object.freeze({ accent: "#7a5bff", background: "#020617", surface: "#0f172a", text: "#f1f5f9", corners: "soft", }); function normalizeThemeColor(value, fallback) { return /^#[0-9a-f]{6}$/i.test(String(value || "")) ? String(value).toLowerCase() : fallback; } function mixThemeColors(colorA, colorB, amount) { const parse = (color) => [1, 3, 5].map((offset) => Number.parseInt(color.slice(offset, offset + 2), 16)); const a = parse(colorA); const b = parse(colorB); return `#${a.map((channel, i) => Math.round(channel + ((b[i] - channel) * amount)).toString(16).padStart(2, "0")).join("")}`; } function themeColorRgba(color, alpha) { const channels = [1, 3, 5].map((offset) => Number.parseInt(color.slice(offset, offset + 2), 16)); return `rgba(${channels.join(", ")}, ${alpha})`; } function getSimpleThemeSettings() { return { accent: normalizeThemeColor(simpleThemeAccentInput?.value, SIMPLE_THEME_DEFAULTS.accent), background: normalizeThemeColor(simpleThemeBackgroundInput?.value, SIMPLE_THEME_DEFAULTS.background), surface: normalizeThemeColor(simpleThemeSurfaceInput?.value, SIMPLE_THEME_DEFAULTS.surface), text: normalizeThemeColor(simpleThemeTextInput?.value, SIMPLE_THEME_DEFAULTS.text), corners: ["sharp", "soft", "round"].includes(simpleThemeCornersInput?.value) ? simpleThemeCornersInput.value : SIMPLE_THEME_DEFAULTS.corners, }; } function applySimpleThemeSettings(settings = SIMPLE_THEME_DEFAULTS) { const normalized = { accent: normalizeThemeColor(settings.accent, SIMPLE_THEME_DEFAULTS.accent), background: normalizeThemeColor(settings.background, SIMPLE_THEME_DEFAULTS.background), surface: normalizeThemeColor(settings.surface, SIMPLE_THEME_DEFAULTS.surface), text: normalizeThemeColor(settings.text, SIMPLE_THEME_DEFAULTS.text), corners: ["sharp", "soft", "round"].includes(settings.corners) ? settings.corners : SIMPLE_THEME_DEFAULTS.corners, }; if (simpleThemeAccentInput) simpleThemeAccentInput.value = normalized.accent; if (simpleThemeBackgroundInput) simpleThemeBackgroundInput.value = normalized.background; if (simpleThemeSurfaceInput) simpleThemeSurfaceInput.value = normalized.surface; if (simpleThemeTextInput) simpleThemeTextInput.value = normalized.text; if (simpleThemeCornersInput) simpleThemeCornersInput.value = normalized.corners; updateSimpleThemePreview(); } function buildSimpleThemeCss(settings) { const { accent, background, surface, text, corners } = settings; const accentShades = { 50: mixThemeColors(accent, "#ffffff", 0.92), 100: mixThemeColors(accent, "#ffffff", 0.82), 200: mixThemeColors(accent, "#ffffff", 0.68), 300: mixThemeColors(accent, "#ffffff", 0.48), 400: mixThemeColors(accent, "#ffffff", 0.24), 500: accent, 600: mixThemeColors(accent, "#000000", 0.15), 700: mixThemeColors(accent, "#000000", 0.3), 800: mixThemeColors(accent, "#000000", 0.45), 900: mixThemeColors(accent, "#000000", 0.58), 950: mixThemeColors(accent, "#000000", 0.72), }; const neutralShades = { 50: mixThemeColors(text, "#ffffff", 0.35), 100: text, 200: mixThemeColors(surface, text, 0.86), 300: mixThemeColors(surface, text, 0.72), 400: mixThemeColors(surface, text, 0.55), 500: mixThemeColors(surface, text, 0.4), 600: mixThemeColors(surface, text, 0.26), 700: mixThemeColors(surface, text, 0.16), 800: mixThemeColors(surface, text, 0.08), 900: surface, 950: background, }; const radii = { sharp: ["2px", "3px", "4px", "5px"], soft: ["8px", "10px", "14px", "18px"], round: ["14px", "18px", "24px", "30px"], }[corners]; const paletteNames = ["sky", "blue", "cyan", "indigo", "violet", "purple", "fuchsia"]; const accentVariables = paletteNames.flatMap((name) => ( Object.entries(accentShades).map(([shade, value]) => ` --color-${name}-${shade}: ${value};`) )); const neutralVariables = Object.entries(neutralShades).map(([shade, value]) => ` --color-slate-${shade}: ${value};`); return [ SIMPLE_THEME_START, `/* settings: ${JSON.stringify(settings)} */`, ":root {", ...accentVariables, ...neutralVariables, ` --color-primary: ${accent};`, ` --color-primary-hover: ${accentShades[400]};`, ` --color-text: ${text};`, ` --color-text-bright: ${neutralShades[50]};`, ` --color-text-muted: ${neutralShades[400]};`, ` --color-surface: ${themeColorRgba(surface, 0.72)};`, ` --color-surface-dark: ${themeColorRgba(background, 0.88)};`, ` --color-border: ${themeColorRgba(text, 0.12)};`, ` --ui-border-strong: ${themeColorRgba(accent, 0.45)};`, ` --ui-ring: ${themeColorRgba(accent, 0.28)};`, ` --radius-sm: ${radii[0]};`, ` --radius-md: ${radii[1]};`, ` --radius-lg: ${radii[2]};`, ` --radius-xl: ${radii[3]};`, ` --radius-2xl: ${radii[3]};`, "}", "html, body {", ` background-color: ${background} !important;`, ` color: ${text};`, "}", SIMPLE_THEME_END, ].join("\n"); } function replaceSimpleThemeBlock(css, replacement = "") { const withoutManagedBlock = String(css || "").replace(SIMPLE_THEME_BLOCK_RE, "").trim(); return [replacement, withoutManagedBlock].filter(Boolean).join("\n\n"); } function syncSimpleThemeCss() { if (!appearanceCustomCssInput) return; const enabled = !!simpleThemeEnabledInput?.checked; const settings = getSimpleThemeSettings(); const replacement = enabled ? buildSimpleThemeCss(settings) : ""; if (enabled && brandAccentColorInput) brandAccentColorInput.value = settings.accent; appearanceCustomCssInput.value = replaceSimpleThemeBlock(appearanceCustomCssInput.value, replacement); if (simpleThemeControls) simpleThemeControls.classList.toggle("opacity-50", !enabled); } function loadSimpleThemeFromCss(css) { const block = String(css || "").match(SIMPLE_THEME_BLOCK_RE)?.[0] || ""; if (simpleThemeEnabledInput) simpleThemeEnabledInput.checked = !!block; if (block) { const settingsText = block.match(/\/\* settings: (\{.*\}) \*\//)?.[1]; try { applySimpleThemeSettings(settingsText ? JSON.parse(settingsText) : SIMPLE_THEME_DEFAULTS); } catch { applySimpleThemeSettings(SIMPLE_THEME_DEFAULTS); } } else { applySimpleThemeSettings(SIMPLE_THEME_DEFAULTS); } if (simpleThemeControls) simpleThemeControls.classList.toggle("opacity-50", !block); } function updateSimpleThemePreview() { const { accent, background, surface, text, corners } = getSimpleThemeSettings(); const radius = { sharp: "4px", soft: "12px", round: "22px" }[corners]; if (simpleThemePreview) { simpleThemePreview.style.borderColor = mixThemeColors(surface, text, 0.2); simpleThemePreview.style.borderRadius = radius; } if (simpleThemePreviewBar) { simpleThemePreviewBar.style.backgroundColor = mixThemeColors(surface, background, 0.35); simpleThemePreviewBar.style.color = text; } if (simpleThemePreviewBody) { simpleThemePreviewBody.style.backgroundColor = surface; simpleThemePreviewBody.style.color = text; } if (simpleThemePreviewDot) simpleThemePreviewDot.style.backgroundColor = accent; if (simpleThemePreviewMuted) simpleThemePreviewMuted.style.color = mixThemeColors(surface, text, 0.6); if (simpleThemePreviewButton) { simpleThemePreviewButton.style.backgroundColor = accent; simpleThemePreviewButton.style.borderRadius = radius; } } function initSimpleThemeBuilder() { const inputs = [simpleThemeAccentInput, simpleThemeBackgroundInput, simpleThemeSurfaceInput, simpleThemeTextInput, simpleThemeCornersInput]; for (const input of inputs) { input?.addEventListener("input", () => { if (simpleThemeEnabledInput && !simpleThemeEnabledInput.checked) simpleThemeEnabledInput.checked = true; if (input === simpleThemeAccentInput && brandAccentColorInput) brandAccentColorInput.value = simpleThemeAccentInput.value; updateSimpleThemePreview(); syncSimpleThemeCss(); }); } simpleThemeEnabledInput?.addEventListener("change", syncSimpleThemeCss); simpleThemeResetBtn?.addEventListener("click", () => { applySimpleThemeSettings(SIMPLE_THEME_DEFAULTS); if (simpleThemeEnabledInput) simpleThemeEnabledInput.checked = true; if (brandAccentColorInput) brandAccentColorInput.value = SIMPLE_THEME_DEFAULTS.accent; syncSimpleThemeCss(); }); updateSimpleThemePreview(); } function applyBrandingForm(loginBranding = {}) { if (brandProductNameInput) brandProductNameInput.value = loginBranding.productName || "Overlord"; if (brandTabNameInput) brandTabNameInput.value = loginBranding.tabName || loginBranding.productName || "Overlord"; if (brandFaviconUrlInput) brandFaviconUrlInput.value = loginBranding.faviconUrl || ""; if (brandDashboardBackgroundUrlInput) brandDashboardBackgroundUrlInput.value = loginBranding.dashboardBackgroundUrl || ""; if (brandNavNameInput) brandNavNameInput.value = loginBranding.navName || loginBranding.productName || "Overlord"; if (brandAccentColorInput) brandAccentColorInput.value = /^#[0-9a-fA-F]{6}$/.test(loginBranding.accentColor || "") ? loginBranding.accentColor : "#7a5bff"; if (brandIconClassInput) brandIconClassInput.value = loginBranding.iconClass || "fa-solid fa-crown"; if (brandNavLogoUrlInput) brandNavLogoUrlInput.value = loginBranding.navLogoUrl || ""; if (brandNavLogoAltInput) brandNavLogoAltInput.value = loginBranding.navLogoAlt || ""; if (brandLoginLogoUrlInput) brandLoginLogoUrlInput.value = loginBranding.logoUrl || ""; if (brandLoginLogoAltInput) brandLoginLogoAltInput.value = loginBranding.logoAlt || ""; if (brandLoginTitleInput) brandLoginTitleInput.value = loginBranding.title || "Welcome back"; if (brandLoginSubtitleInput) brandLoginSubtitleInput.value = loginBranding.subtitle || "Sign in to your control plane"; if (brandHeroImageUrlInput) brandHeroImageUrlInput.value = loginBranding.heroImageUrl || ""; if (brandHeroImageAltInput) brandHeroImageAltInput.value = loginBranding.heroImageAlt || ""; if (brandFooterTextInput) brandFooterTextInput.value = loginBranding.footerText || ""; if (brandSupportTextInput) brandSupportTextInput.value = loginBranding.supportText || ""; if (brandSupportUrlInput) brandSupportUrlInput.value = loginBranding.supportUrl || ""; } function collectBrandingForm() { return { productName: String(brandProductNameInput?.value || "").trim(), tabName: String(brandTabNameInput?.value || "").trim(), faviconUrl: String(brandFaviconUrlInput?.value || "").trim(), dashboardBackgroundUrl: String(brandDashboardBackgroundUrlInput?.value || "").trim(), navName: String(brandNavNameInput?.value || "").trim(), accentColor: String(brandAccentColorInput?.value || "").trim(), iconClass: String(brandIconClassInput?.value || "").trim(), navLogoUrl: String(brandNavLogoUrlInput?.value || "").trim(), navLogoAlt: String(brandNavLogoAltInput?.value || "").trim(), logoUrl: String(brandLoginLogoUrlInput?.value || "").trim(), logoAlt: String(brandLoginLogoAltInput?.value || "").trim(), title: String(brandLoginTitleInput?.value || "").trim(), subtitle: String(brandLoginSubtitleInput?.value || "").trim(), heroImageUrl: String(brandHeroImageUrlInput?.value || "").trim(), heroImageAlt: String(brandHeroImageAltInput?.value || "").trim(), footerText: String(brandFooterTextInput?.value || "").trim(), supportText: String(brandSupportTextInput?.value || "").trim(), supportUrl: String(brandSupportUrlInput?.value || "").trim(), }; } function setBrandingUploadName(fileInput) { const label = document.getElementById(`${fileInput.id}-name`); if (label) label.textContent = fileInput.files?.[0]?.name || "No file chosen"; } async function uploadBrandingImage(fileInput, targetInput, kind, label) { const file = fileInput?.files?.[0]; if (!file || !targetInput) return; if (!requireUiPermission("system:appearance", "Appearance settings permission required.")) { fileInput.value = ""; return; } if (file.size > 5 * 1024 * 1024) { showMessage("Branding images must be 5 MB or smaller.", "error"); fileInput.value = ""; return; } const form = new FormData(); form.append("kind", kind); form.append("file", file); fileInput.disabled = true; showMessage(`Uploading ${label}...`); try { const res = await fetch("/api/settings/appearance/image", { method: "POST", credentials: "include", body: form, }); const data = await res.json().catch(() => ({})); if (!res.ok) { showMessage(data.error || `Failed to upload ${label}.`, "error"); return; } targetInput.value = data.url || ""; showMessage(`${label} uploaded. Save Branding to apply it.`); } catch { showMessage(`Failed to upload ${label}.`, "error"); } finally { fileInput.disabled = false; fileInput.value = ""; setBrandingUploadName(fileInput); } } function initBrandingUploads() { if (brandDashboardBackgroundFileInput) { brandDashboardBackgroundFileInput.addEventListener("change", () => { setBrandingUploadName(brandDashboardBackgroundFileInput); uploadBrandingImage(brandDashboardBackgroundFileInput, brandDashboardBackgroundUrlInput, "dashboard-background", "Dashboard background"); }); } if (brandFaviconFileInput) { brandFaviconFileInput.addEventListener("change", () => { setBrandingUploadName(brandFaviconFileInput); uploadBrandingImage(brandFaviconFileInput, brandFaviconUrlInput, "tab-icon", "Browser tab icon"); }); } if (brandNavLogoFileInput) { brandNavLogoFileInput.addEventListener("change", () => { setBrandingUploadName(brandNavLogoFileInput); uploadBrandingImage(brandNavLogoFileInput, brandNavLogoUrlInput, "nav-logo", "Navigation logo"); }); } if (brandLoginLogoFileInput) { brandLoginLogoFileInput.addEventListener("change", () => { setBrandingUploadName(brandLoginLogoFileInput); uploadBrandingImage(brandLoginLogoFileInput, brandLoginLogoUrlInput, "login-logo", "Sign-in logo"); }); } if (brandHeroImageFileInput) { brandHeroImageFileInput.addEventListener("change", () => { setBrandingUploadName(brandHeroImageFileInput); uploadBrandingImage(brandHeroImageFileInput, brandHeroImageUrlInput, "hero-image", "Sign-in background image"); }); } } async function loadAppearanceSettings() { if (!currentUser) return; if (!userHas("system:appearance")) { if (appearancePermissionNote) appearancePermissionNote.classList.remove("hidden"); setAppearanceFormDisabled(true); return; } if (appearancePermissionNote) appearancePermissionNote.classList.add("hidden"); try { const res = await fetch("/api/settings/appearance", { credentials: "include" }); if (!res.ok) { showMessage("Failed to load branding settings.", "error"); return; } const data = await res.json().catch(() => ({})); if (appearanceCustomCssInput) appearanceCustomCssInput.value = data.customCSS || ""; loadSimpleThemeFromCss(data.customCSS || ""); applyBrandingForm(data.loginBranding || {}); setAppearanceFormDisabled(false); } catch { showMessage("Failed to load branding settings.", "error"); } } async function saveAppearanceSettings(event) { event.preventDefault(); if (!requireUiPermission("system:appearance", "Appearance settings permission required.")) { return; } syncSimpleThemeCss(); const customCSS = appearanceCustomCssInput ? appearanceCustomCssInput.value : ""; if (customCSS.length > 51200) { showMessage("CSS exceeds the 50 KB size limit.", "error"); return; } const res = await fetch("/api/settings/appearance", { method: "PUT", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ customCSS, loginBranding: collectBrandingForm() }), }); const data = await res.json().catch(() => ({})); if (!res.ok) { showMessage(data.error || "Failed to save branding.", "error"); return; } applyBrandingForm(data.loginBranding || collectBrandingForm()); showSettingsSuccess("Branding saved. Reload any open page to apply it everywhere."); } const chatSettingsSection = document.getElementById("chat-settings-section"); const chatSettingsForm = document.getElementById("chat-settings-form"); const chatRetentionDaysInput = document.getElementById("chat-retention-days"); async function loadChatSettings() { if (!userHas("system:chat")) return; if (chatSettingsSection) chatSettingsSection.classList.remove("hidden"); try { const res = await fetch("/api/settings/chat", { credentials: "include" }); const data = await res.json().catch(() => ({})); if (res.ok && data.chat) { if (chatRetentionDaysInput) chatRetentionDaysInput.value = data.chat.retentionDays ?? 30; } } catch { console.warn("Failed to load chat settings"); } } async function loadInputArchiveSettings() { try { const res = await fetch("/api/settings/input-archive", { credentials: "include" }); const data = await res.json().catch(() => ({})); if (!res.ok) return; if (inputArchiveMyEnabledInput) inputArchiveMyEnabledInput.checked = !!data.myEnabled; const cfg = data.inputArchive || {}; if (inputArchiveGlobalEnabledInput) inputArchiveGlobalEnabledInput.checked = !!cfg.enabled; if (inputArchiveRetentionDaysInput) inputArchiveRetentionDaysInput.value = cfg.retentionDays ?? 7; if (inputArchiveMaxFileMbInput) inputArchiveMaxFileMbInput.value = Math.max(1, Math.round((Number(cfg.maxFileBytes) || 0) / 1024 / 1024)) || 5; if (inputArchivePollSecondsInput) inputArchivePollSecondsInput.value = cfg.pollIntervalSeconds ?? 300; } catch { console.warn("Failed to load input archive settings"); } } async function saveInputArchivePreference(event) { event.preventDefault(); const res = await fetch("/api/settings/input-archive", { method: "PUT", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ myEnabled: !!inputArchiveMyEnabledInput?.checked }), }); const data = await res.json().catch(() => ({})); if (!res.ok) { showMessage(data.error || "Failed to save input archive preference.", "error"); return; } showSettingsSuccess("Input archive preference saved."); } async function saveInputArchiveAdminSettings(event) { event.preventDefault(); if (!requireUiPermission("system:input-archive", "Input archive settings permission required.")) { return; } const retentionDays = Number(inputArchiveRetentionDaysInput?.value); const maxFileMb = Number(inputArchiveMaxFileMbInput?.value); const pollIntervalSeconds = Number(inputArchivePollSecondsInput?.value); if (!Number.isFinite(retentionDays) || retentionDays < 1 || retentionDays > 365) { showMessage("Archive retention must be 1-365 days.", "error"); return; } if (!Number.isFinite(maxFileMb) || maxFileMb < 1 || maxFileMb > 50) { showMessage("Max file size must be 1-50 MB.", "error"); return; } if (!Number.isFinite(pollIntervalSeconds) || pollIntervalSeconds < 0 || pollIntervalSeconds > 86400) { showMessage("Poll interval must be 0-86400 seconds.", "error"); return; } const res = await fetch("/api/settings/input-archive", { method: "PUT", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ inputArchive: { enabled: !!inputArchiveGlobalEnabledInput?.checked, retentionDays, maxFileBytes: Math.round(maxFileMb * 1024 * 1024), pollIntervalSeconds: Math.round(pollIntervalSeconds), }, }), }); const data = await res.json().catch(() => ({})); if (!res.ok) { showMessage(data.error || "Failed to save input archive settings.", "error"); return; } showSettingsSuccess("Input archive settings saved."); } async function saveChatSettings(event) { event.preventDefault(); if (!requireUiPermission("system:chat", "Team chat settings permission required.")) { return; } const retentionDays = Number(chatRetentionDaysInput?.value); if (!Number.isFinite(retentionDays) || retentionDays < 0 || retentionDays > 365) { showMessage("Retention must be 0-365 days.", "error"); return; } const res = await fetch("/api/settings/chat", { method: "PUT", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ retentionDays }), }); const data = await res.json().catch(() => ({})); if (!res.ok) { showMessage(data.error || "Failed to save chat settings.", "error"); return; } showSettingsSuccess("Chat settings saved."); } function showExportImportMessage(text, type = "ok") { if (!exportImportMessage) return; exportImportMessage.textContent = text; exportImportMessage.classList.remove( "hidden", "text-emerald-200", "border-emerald-700", "bg-emerald-900/30", "text-rose-200", "border-rose-700", "bg-rose-900/30", "text-amber-200", "border-amber-700", "bg-amber-900/30", ); if (type === "error") { exportImportMessage.classList.add("text-rose-200", "border-rose-700", "bg-rose-900/30"); } else if (type === "warning") { exportImportMessage.classList.add("text-amber-200", "border-amber-700", "bg-amber-900/30"); } else { exportImportMessage.classList.add("text-emerald-200", "border-emerald-700", "bg-emerald-900/30"); } } async function exportSettings() { if (!requireUiPermission("system:export-import", "Export/import settings permission required.")) { return; } try { const res = await fetch("/api/settings/export", { credentials: "include" }); if (!res.ok) { const data = await res.json().catch(() => ({})); showExportImportMessage(data.error || "Failed to export settings.", "error"); return; } const blob = await res.blob(); const disposition = res.headers.get("Content-Disposition") || ""; const filenameMatch = disposition.match(/filename="?([^"]+)"?/); const filename = filenameMatch ? filenameMatch[1] : "overlord-server-backup.zip"; const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); showExportImportMessage("Settings exported successfully."); } catch (error) { showExportImportMessage(`Export failed: ${String(error?.message || error)}`, "error"); } } async function importSettings(event) { if (!requireUiPermission("system:export-import", "Export/import settings permission required.")) { event.target.value = ""; return; } const file = event.target.files?.[0]; if (!file) return; event.target.value = ""; if (file.size > 1024 * 1024 * 1024) { showExportImportMessage("File too large (max 1 GB).", "error"); return; } const isLegacyJson = file.name.toLowerCase().endsWith(".json") || file.type === "application/json"; let requestBody; let contentType; if (isLegacyJson) { try { const data = JSON.parse(await file.text()); if (!data || typeof data !== "object") throw new Error(); requestBody = JSON.stringify(data); contentType = "application/json"; } catch { showExportImportMessage("Invalid legacy settings JSON file.", "error"); return; } } else { requestBody = await file.arrayBuffer(); contentType = "application/zip"; } if (!confirm("Stage this server restore? On the next restart it will replace the current database, identity keys, TLS certificate, plugins, and persistent server data.")) { return; } try { const res = await fetch("/api/settings/import", { method: "POST", headers: { "Content-Type": contentType }, credentials: "include", body: requestBody, }); const result = await res.json().catch(() => ({})); if (!res.ok) { showExportImportMessage(result.error || "Import failed.", "error"); return; } const appliedStr = result.applied?.length ? result.applied.join(", ") : "nothing"; const warningStr = result.warnings?.length ? " \u26A0 " + result.warnings.join(" ") : ""; const msgType = result.warnings?.length ? "warning" : "ok"; const restartStr = result.restartRequired ? " Restart the server to apply the restore." : ""; showExportImportMessage(`Imported: ${appliedStr}.${restartStr}${warningStr}`, msgType); await loadSecurityPolicy(); await loadTlsSettings(); await loadOidcSettings(); await loadAppearanceSettings(); } catch (error) { showExportImportMessage(`Import failed: ${String(error?.message || error)}`, "error"); } } async function wipeOfflineClients() { if (!wipeOfflineMessage) return; if (!confirm("Remove ALL offline clients from the dashboard?\n\nThey will reappear if they reconnect later.")) return; wipeOfflineBtn.disabled = true; wipeOfflineMessage.className = "hidden text-sm rounded-lg px-3 py-2 border"; try { const res = await fetch("/api/clients/offline", { method: "DELETE", credentials: "include", }); const data = await res.json().catch(() => ({})); if (!res.ok) { wipeOfflineMessage.textContent = data.error || "Failed to wipe offline clients."; wipeOfflineMessage.className = "text-sm rounded-lg px-3 py-2 border text-rose-200 border-rose-700 bg-rose-900/30"; } else { const n = data.count ?? 0; wipeOfflineMessage.textContent = n === 0 ? "No offline clients found." : `Removed ${n} offline client${n === 1 ? "" : "s"}.`; wipeOfflineMessage.className = "text-sm rounded-lg px-3 py-2 border text-emerald-200 border-emerald-700 bg-emerald-900/30"; } } catch { wipeOfflineMessage.textContent = "Request failed."; wipeOfflineMessage.className = "text-sm rounded-lg px-3 py-2 border text-rose-200 border-rose-700 bg-rose-900/30"; } finally { wipeOfflineBtn.disabled = false; } } // ── Registration Settings ────────────────────────────────────────────────── async function loadRegistrationSettings() { if (!userHas("system:registration")) return; try { const [regRes, groupsRes] = await Promise.all([ fetch("/api/settings/registration", { credentials: "include" }), fetch("/api/permission-groups", { credentials: "include" }), ]); if (!regRes.ok) return; const data = await regRes.json(); const reg = data.registration || {}; const modeEl = document.getElementById("reg-mode"); const roleEl = document.getElementById("reg-default-role"); const maxEl = document.getElementById("reg-max-users"); if (modeEl) modeEl.value = reg.mode || "off"; if (roleEl) roleEl.value = reg.defaultRole || "operator"; if (maxEl) maxEl.value = reg.maxUsersTotal ?? 0; const listEl = document.getElementById("reg-default-groups-list"); if (listEl) { const groups = groupsRes.ok ? (await groupsRes.json()).groups || [] : []; const selected = new Set(reg.defaultGroupIds || []); listEl.innerHTML = groups.length === 0 ? `

No groups defined yet. Create one on the Users page.

` : groups .map( (g) => ` `, ) .join(""); } updateRegSubsections(reg.mode || "off"); if (reg.mode === "key" && userHas("users:manage")) loadRegistrationKeys(); if (reg.mode === "approval" && userHas("users:manage")) loadPendingRegistrations(); } catch (e) { console.error("Failed to load registration settings", e); } } function updateRegSubsections(mode) { const keySection = document.getElementById("reg-key-section"); const pendingSection = document.getElementById("reg-pending-section"); if (keySection) keySection.classList.toggle("hidden", mode !== "key" || !userHas("users:manage")); if (pendingSection) pendingSection.classList.toggle("hidden", mode !== "approval" || !userHas("users:manage")); } function showRegMsg(text, type) { const el = document.getElementById("reg-settings-msg"); if (!el) return; el.textContent = text; el.className = `text-sm rounded-lg px-3 py-2 border ${type === "error" ? "border-rose-800 bg-rose-900/20 text-rose-200" : "border-emerald-800 bg-emerald-900/20 text-emerald-200"}`; el.classList.remove("hidden"); setTimeout(() => el.classList.add("hidden"), 5000); } async function saveRegistrationSettings(e) { e.preventDefault(); if (!requireUiPermission("system:registration", "Registration settings permission required.")) { return; } const mode = document.getElementById("reg-mode")?.value; const defaultRole = document.getElementById("reg-default-role")?.value; const maxUsersTotal = Number(document.getElementById("reg-max-users")?.value) || 0; const defaultGroupIds = Array.from( document.querySelectorAll("#reg-default-groups-list input[type=checkbox]:checked"), ).map((el) => Number(el.dataset.defaultGroup)).filter((n) => Number.isFinite(n)); try { const res = await fetch("/api/settings/registration", { method: "PUT", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode, defaultRole, maxUsersTotal, defaultGroupIds }), }); if (!res.ok) { const data = await res.json().catch(() => ({})); showRegMsg(data.error || "Failed to save", "error"); return; } showSettingsSuccess("Registration settings saved.", (text) => showRegMsg(text, "success")); updateRegSubsections(mode); if (mode === "key" && userHas("users:manage")) loadRegistrationKeys(); if (mode === "approval" && userHas("users:manage")) loadPendingRegistrations(); } catch { showRegMsg("Network error.", "error"); } } async function loadRegistrationKeys() { if (!userHas("users:manage")) return; const tbody = document.getElementById("reg-keys-tbody"); if (!tbody) return; try { const res = await fetch("/api/registration/keys", { credentials: "include" }); if (!res.ok) return; const data = await res.json(); const keys = data.keys || []; if (keys.length === 0) { tbody.innerHTML = 'No keys generated yet.'; return; } tbody.innerHTML = keys.map(k => { const status = k.used_by ? 'Used' : (k.expires_at && k.expires_at < Date.now()) ? 'Expired' : 'Available'; const created = new Date(k.created_at).toLocaleDateString(); const expires = k.expires_at ? new Date(k.expires_at).toLocaleDateString() : "Never"; return ` ${escapeHtml(k.key.substring(0, 12))}... ${escapeHtml(k.label || "—")} ${status} ${created} ${expires} ${!k.used_by ? `` : ""} `; }).join(""); } catch (e) { console.error("Failed to load keys", e); } } 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 label = document.getElementById("reg-key-label")?.value || undefined; const expiresInHours = Number(document.getElementById("reg-key-expires")?.value) || undefined; try { const res = await fetch("/api/registration/keys", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ count, label, expiresInHours }), }); if (!res.ok) { showRegMsg("Failed to generate keys", "error"); return; } showRegMsg(`Generated ${count} key(s).`, "success"); loadRegistrationKeys(); } catch { showRegMsg("Network error.", "error"); } } async function deleteRegistrationKey(keyId) { if (!requireUiPermission("users:manage", "User management permission required to delete registration keys.")) { return; } try { const res = await fetch(`/api/registration/keys/${keyId}`, { method: "DELETE", credentials: "include", }); if (res.ok) loadRegistrationKeys(); } catch {} } async function loadPendingRegistrations() { if (!userHas("users:manage")) return; const tbody = document.getElementById("reg-pending-tbody"); const emptyEl = document.getElementById("reg-pending-empty"); if (!tbody) return; try { const res = await fetch("/api/registration/pending", { credentials: "include" }); if (!res.ok) return; const data = await res.json(); const pending = (data.pending || []).filter(p => p.status === "pending"); if (pending.length === 0) { tbody.innerHTML = ""; if (emptyEl) emptyEl.classList.remove("hidden"); return; } if (emptyEl) emptyEl.classList.add("hidden"); tbody.innerHTML = pending.map(p => { const requested = new Date(p.requested_at).toLocaleString(); return ` ${escapeHtml(p.username)} ${requested} Pending `; }).join(""); } catch (e) { console.error("Failed to load pending registrations", e); } } async function handlePendingAction(id, action) { if (!requireUiPermission("users:manage", "User management permission required to approve registrations.")) { return; } try { const res = await fetch(`/api/registration/pending/${id}/${action}`, { method: "POST", credentials: "include", }); if (!res.ok) { const data = await res.json().catch(() => ({})); showRegMsg(data.error || `Failed to ${action}`, "error"); return; } showRegMsg(`Registration ${action}d.`, "success"); loadPendingRegistrations(); } catch { showRegMsg("Network error.", "error"); } } function initRegistrationHandlers() { const regForm = document.getElementById("registration-form"); if (regForm) regForm.addEventListener("submit", saveRegistrationSettings); const modeEl = document.getElementById("reg-mode"); if (modeEl) modeEl.addEventListener("change", () => { updateRegSubsections(modeEl.value); if (modeEl.value === "key" && userHas("users:manage")) loadRegistrationKeys(); if (modeEl.value === "approval" && userHas("users:manage")) loadPendingRegistrations(); }); const genBtn = document.getElementById("reg-key-generate-btn"); if (genBtn) genBtn.addEventListener("click", generateRegistrationKeys); // Delegate key delete and pending approve/deny document.addEventListener("click", (e) => { const deleteBtn = e.target.closest("[data-delete-key]"); if (deleteBtn) { deleteRegistrationKey(Number(deleteBtn.dataset.deleteKey)); return; } const approveBtn = e.target.closest("[data-approve-pending]"); if (approveBtn) { handlePendingAction(Number(approveBtn.dataset.approvePending), "approve"); return; } const denyBtn = e.target.closest("[data-deny-pending]"); if (denyBtn) { handlePendingAction(Number(denyBtn.dataset.denyPending), "deny"); } }); } // ── File Transfer Limit Settings ────────────────────────────────────────── async function loadFileTransferLimits() { if (!userHas("system:security")) return; try { const res = await fetch("/api/settings/file-transfers", { credentials: "include" }); if (!res.ok) return; const data = await res.json(); const limits = data.fileTransfers || {}; const mb = 1024 * 1024; document.getElementById("ft-max-file-mb").value = Math.round((limits.maxFileBytes || 1024 * mb) / mb); document.getElementById("ft-max-staged-mb").value = Math.round((limits.maxStagedBytes || 4096 * mb) / mb); document.getElementById("ft-max-active-global").value = limits.maxActiveGlobal ?? 16; document.getElementById("ft-max-active-user").value = limits.maxActivePerUser ?? 4; document.getElementById("ft-intent-ttl-minutes").value = Math.round((limits.uploadIntentTtlMs || 30 * 60_000) / 60_000); document.getElementById("ft-pull-ttl-minutes").value = Math.round((limits.uploadPullTtlMs || 30 * 60_000) / 60_000); } catch (error) { console.error("Failed to load file transfer limits", error); } } function showFileTransferLimitsMsg(text, type) { const el = document.getElementById("file-transfer-limits-msg"); if (!el) return; el.textContent = text; el.className = `text-sm rounded-lg px-3 py-2 border ${type === "error" ? "border-rose-800 bg-rose-900/20 text-rose-200" : "border-emerald-800 bg-emerald-900/20 text-emerald-200"}`; el.classList.remove("hidden"); setTimeout(() => el.classList.add("hidden"), 5000); } async function saveFileTransferLimits(event) { event.preventDefault(); if (!requireUiPermission("system:security", "Security settings permission required.")) return; const mb = 1024 * 1024; const maxFileMb = Number(document.getElementById("ft-max-file-mb")?.value); const maxStagedMb = Number(document.getElementById("ft-max-staged-mb")?.value); const maxActiveGlobal = Number(document.getElementById("ft-max-active-global")?.value); const maxActivePerUser = Number(document.getElementById("ft-max-active-user")?.value); const intentMinutes = Number(document.getElementById("ft-intent-ttl-minutes")?.value); const pullMinutes = Number(document.getElementById("ft-pull-ttl-minutes")?.value); if (![maxFileMb, maxStagedMb, maxActiveGlobal, maxActivePerUser, intentMinutes, pullMinutes].every(Number.isFinite)) { showFileTransferLimitsMsg("All limits must be valid numbers.", "error"); return; } if (maxStagedMb < maxFileMb) { showFileTransferLimitsMsg("Total staging quota must be at least the maximum file size.", "error"); return; } if (maxActivePerUser > maxActiveGlobal) { showFileTransferLimitsMsg("Per-user active transfers cannot exceed the global limit.", "error"); return; } try { const res = await fetch("/api/settings/file-transfers", { method: "PUT", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ maxFileBytes: Math.round(maxFileMb * mb), maxStagedBytes: Math.round(maxStagedMb * mb), maxActiveGlobal: Math.round(maxActiveGlobal), maxActivePerUser: Math.round(maxActivePerUser), uploadIntentTtlMs: Math.round(intentMinutes * 60_000), uploadPullTtlMs: Math.round(pullMinutes * 60_000), }), }); const data = await res.json().catch(() => ({})); if (!res.ok) { showFileTransferLimitsMsg(data.error || "Failed to save transfer limits.", "error"); return; } await loadFileTransferLimits(); showFileTransferLimitsMsg("File transfer limits saved and active.", "success"); } catch { showFileTransferLimitsMsg("Network error.", "error"); } } function initFileTransferLimitHandlers() { document.getElementById("file-transfer-limits-form")?.addEventListener("submit", saveFileTransferLimits); } // ── Build Rate Limit Settings ────────────────────────────────────────────── async function loadBuildRateLimitSettings() { if (!userHas("system:build-limits")) return; try { const res = await fetch("/api/settings/build-rate-limit", { credentials: "include" }); if (!res.ok) return; const data = await res.json(); const brl = data.buildRateLimit || {}; const perHourEl = document.getElementById("brl-max-per-hour"); const concUserEl = document.getElementById("brl-max-concurrent-user"); const globalConcEl = document.getElementById("brl-global-concurrent"); if (perHourEl) perHourEl.value = brl.maxBuildsPerHour ?? 5; if (concUserEl) concUserEl.value = brl.maxConcurrentPerUser ?? 1; if (globalConcEl) globalConcEl.value = brl.globalMaxConcurrent ?? 3; } catch (e) { console.error("Failed to load build rate limit settings", e); } } function showBrlMsg(text, type) { const el = document.getElementById("brl-settings-msg"); if (!el) return; el.textContent = text; el.className = `text-sm rounded-lg px-3 py-2 border ${type === "error" ? "border-rose-800 bg-rose-900/20 text-rose-200" : "border-emerald-800 bg-emerald-900/20 text-emerald-200"}`; el.classList.remove("hidden"); setTimeout(() => el.classList.add("hidden"), 5000); } async function saveBuildRateLimitSettings(e) { 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 maxConcurrentPerUser = Number(document.getElementById("brl-max-concurrent-user")?.value) || 1; const globalMaxConcurrent = Number(document.getElementById("brl-global-concurrent")?.value) || 3; try { const res = await fetch("/api/settings/build-rate-limit", { method: "PUT", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ maxBuildsPerHour, maxConcurrentPerUser, globalMaxConcurrent }), }); if (!res.ok) { const data = await res.json().catch(() => ({})); showBrlMsg(data.error || "Failed to save", "error"); return; } showSettingsSuccess("Build rate limit settings saved.", (text) => showBrlMsg(text, "success")); } catch { showBrlMsg("Network error.", "error"); } } function initBuildRateLimitHandlers() { const brlForm = document.getElementById("build-rate-limit-form"); if (brlForm) brlForm.addEventListener("submit", saveBuildRateLimitSettings); } // ── Thumbnail Settings ──────────────────────────────────────────────────── async function loadThumbnailSettings() { if (!userHas("system:thumbnails")) return; try { const res = await fetch("/api/settings/thumbnails", { credentials: "include" }); if (!res.ok) return; const data = await res.json(); const t = data.thumbnails || {}; const dashEl = document.getElementById("thumb-dashboard-enabled"); const wallEl = document.getElementById("thumb-wall-enabled"); if (dashEl) dashEl.checked = t.dashboardEnabled !== false; if (wallEl) wallEl.checked = t.wallEnabled !== false; } catch (e) { console.error("Failed to load thumbnail settings", e); } } function showThumbnailsMsg(text, type) { const el = document.getElementById("thumbnails-msg"); if (!el) return; el.textContent = text; el.className = `text-sm rounded-lg px-3 py-2 border ${type === "error" ? "border-rose-800 bg-rose-900/20 text-rose-200" : "border-emerald-800 bg-emerald-900/20 text-emerald-200"}`; el.classList.remove("hidden"); setTimeout(() => el.classList.add("hidden"), 5000); } async function saveThumbnailSettings(e) { e.preventDefault(); if (!requireUiPermission("system:thumbnails", "Thumbnail settings permission required.")) { return; } const dashboardEnabled = !!document.getElementById("thumb-dashboard-enabled")?.checked; const wallEnabled = !!document.getElementById("thumb-wall-enabled")?.checked; try { const res = await fetch("/api/settings/thumbnails", { method: "PUT", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ dashboardEnabled, wallEnabled }), }); if (!res.ok) { const data = await res.json().catch(() => ({})); showThumbnailsMsg(data.error || "Failed to save", "error"); return; } showSettingsSuccess("Thumbnail settings saved.", (text) => showThumbnailsMsg(text, "success")); } catch { showThumbnailsMsg("Network error.", "error"); } } function initThumbnailHandlers() { const form = document.getElementById("thumbnails-form"); if (form) form.addEventListener("submit", saveThumbnailSettings); } // ── Server Health ───────────────────────────────────────────────────────── function formatBytes(bytes) { return formatSharedBytes(bytes, 1); } function formatUptime(totalSeconds) { const s = Math.floor(totalSeconds); const days = Math.floor(s / 86400); const hours = Math.floor((s % 86400) / 3600); const mins = Math.floor((s % 3600) / 60); const secs = s % 60; if (days > 0) return `${days}d ${hours}h ${mins}m`; if (hours > 0) return `${hours}h ${mins}m ${secs}s`; if (mins > 0) return `${mins}m ${secs}s`; return `${secs}s`; } let latestServerCpuProfile = null; async function loadHealthStats() { if (!userHas("system:health")) return; const loading = document.getElementById("health-loading"); const content = document.getElementById("health-content"); if (loading) { loading.innerHTML = 'Loading...'; loading.classList.remove("hidden"); } if (content) content.classList.add("hidden"); try { const res = await fetch("/api/settings/health", { credentials: "include" }); if (!res.ok) { if (loading) loading.textContent = "Failed to load health stats."; return; } const data = await res.json(); const mem = data.memory || {}; const rss = mem.rss || 0; const heapUsed = mem.heapUsed || 0; const ext = (mem.external || 0) + (mem.arrayBuffers || 0); const set = (id, v) => { const el = document.getElementById(id); if (el) el.textContent = v; }; set("health-uptime", formatUptime(data.uptime || 0)); set("health-rss", formatBytes(rss)); set("health-heap-used", formatBytes(heapUsed)); set("health-external", formatBytes(ext)); const heapPct = rss > 0 ? Math.min(100, (heapUsed / rss) * 100) : 0; const extPct = rss > 0 ? Math.min(100 - heapPct, (ext / rss) * 100) : 0; const heapBar = document.getElementById("health-heap-bar"); const extBar = document.getElementById("health-ext-bar"); const heapBarLabel = document.getElementById("health-heap-bar-label"); const barLabel = document.getElementById("health-bar-label"); if (heapBar) { heapBar.style.width = `${heapPct.toFixed(1)}%`; heapBar.title = `Heap: ${formatBytes(heapUsed)} (${heapPct.toFixed(1)}% of RSS)`; } if (extBar) { extBar.style.width = `${extPct.toFixed(1)}%`; extBar.title = `External/Buffers: ${formatBytes(ext)} (${extPct.toFixed(1)}% of RSS)`; } if (heapBarLabel) { heapBarLabel.textContent = `${heapPct.toFixed(0)}%`; heapBarLabel.classList.toggle("hidden", heapPct < 8); } if (barLabel) { barLabel.textContent = rss > 0 ? `${formatBytes(heapUsed)} heap + ${formatBytes(ext)} ext / ${formatBytes(rss)} RSS` : "-"; } const t = data.components?.thumbnails || {}; set("h-thumb-count", `${t.cachedCount ?? 0} / ${t.cacheMax ?? 0}`); set("h-thumb-bytes", formatBytes(t.cachedBytes || 0)); set("h-thumb-frames", t.pendingFrames ?? 0); set("h-thumb-gen", `${t.genActive ?? 0} active, ${t.genQueued ?? 0} queued`); set("h-thumb-max", t.cacheMax ?? 0); const c = data.components?.clients || {}; set("h-clients-mem", c.inMemory ?? 0); set("h-clients-online", c.online ?? 0); const db = data.components?.database || {}; set("h-db-size", formatBytes(db.fileSizeBytes || 0)); if (loading) loading.classList.add("hidden"); if (content) content.classList.remove("hidden"); } catch (e) { if (loading) loading.textContent = `Error: ${e.message}`; } } function formatMs(ms) { const n = Number(ms) || 0; if (n >= 1000) return `${(n / 1000).toFixed(1)}s`; return `${Math.round(n)}ms`; } function formatPercent(value) { const n = Number(value); if (!Number.isFinite(n)) return "-"; return `${n.toFixed(n >= 10 ? 1 : 2)}%`; } function formatSignedBytes(value) { const n = Number(value) || 0; if (n === 0) return "0 B"; return `${n > 0 ? "+" : "-"}${formatBytes(Math.abs(n))}`; } function setProfileMessage(text, type = "ok") { const el = document.getElementById("server-profile-message"); if (!el) return; el.textContent = text; el.classList.remove( "hidden", "text-emerald-200", "border-emerald-700", "bg-emerald-900/30", "text-cyan-200", "border-cyan-700", "bg-cyan-900/30", "text-rose-200", "border-rose-700", "bg-rose-900/30", ); if (type === "error") { el.classList.add("text-rose-200", "border-rose-700", "bg-rose-900/30"); } else if (type === "busy") { el.classList.add("text-cyan-200", "border-cyan-700", "bg-cyan-900/30"); } else { el.classList.add("text-emerald-200", "border-emerald-700", "bg-emerald-900/30"); } } function renderProfileRows(tbodyId, rows, renderRow, emptyText) { const tbody = document.getElementById(tbodyId); if (!tbody) return; const list = Array.isArray(rows) ? rows : []; if (!list.length) { tbody.innerHTML = `${escapeHtml(emptyText)}`; return; } tbody.innerHTML = list.map(renderRow).join(""); } function renderServerProfileResults(data) { const results = document.getElementById("server-profile-results"); const downloadBtn = document.getElementById("server-profile-download-btn"); const set = (id, value) => { const el = document.getElementById(id); if (el) el.textContent = value; }; const cpu = data?.cpu || {}; const summary = cpu.summary || {}; const beforeMem = data?.memory?.before?.process || {}; const afterMem = data?.memory?.after?.process || {}; const rssDelta = (Number(afterMem.rss) || 0) - (Number(beforeMem.rss) || 0); latestServerCpuProfile = cpu.rawProfile || null; if (downloadBtn) downloadBtn.classList.toggle("hidden", !latestServerCpuProfile); set("profile-duration", formatMs(data?.durationMs || 0)); set("profile-cpu-percent", formatPercent(cpu.processPercent)); set("profile-samples", summary.totalSamples ?? 0); set("profile-rss-delta", formatSignedBytes(rssDelta)); const totalSamples = Number(summary.totalSamples) || 0; renderProfileRows( "profile-functions-body", (summary.topFunctions || []).slice(0, 12), (fn) => { const pct = Number.isFinite(Number(fn.percent)) && Number(fn.percent) > 0 ? Number(fn.percent) : (totalSamples > 0 ? (Number(fn.samples || 0) / totalSamples) * 100 : 0); return `
${escapeHtml(fn.name || "(anonymous)")}
${escapeHtml(fn.location || "(runtime)")}
${formatPercent(pct)} ${Number(fn.samples || 0)} `; }, "No CPU samples were captured.", ); const topObjectTypes = data?.memory?.after?.jscHeap?.topObjectTypes || []; renderProfileRows( "profile-objects-body", topObjectTypes.slice(0, 12), (item) => ` ${escapeHtml(item.type || "-")} ${Number(item.count || 0).toLocaleString()} `, "Heap object details are unavailable.", ); const stacksEl = document.getElementById("profile-stacks-list"); if (stacksEl) { const stacks = (summary.topStacks || []).slice(0, 5); stacksEl.innerHTML = stacks.length ? stacks.map((entry) => { const pct = Number.isFinite(Number(entry.percent)) && Number(entry.percent) > 0 ? Number(entry.percent) : (totalSamples > 0 ? (Number(entry.samples || 0) / totalSamples) * 100 : 0); const stack = Array.isArray(entry.stack) ? entry.stack : []; return `
${formatPercent(pct)} ${Number(entry.samples || 0)} samples
${escapeHtml(stack.join(" <- ") || "(runtime)")}
`; }).join("") : '
No stack samples were captured.
'; } if (results) results.classList.remove("hidden"); } async function runServerProfile() { if (!requireUiPermission("system:profiler", "Server profiler permission required.")) { return; } const btn = document.getElementById("server-profile-btn"); const durationEl = document.getElementById("server-profile-duration"); const durationMs = Number(durationEl?.value || 5000); if (btn) { btn.disabled = true; btn.innerHTML = 'Capturing...'; } setProfileMessage(`Capturing server CPU and memory for ${formatMs(durationMs)}. Keep using the server while this runs.`, "busy"); try { const res = await fetch("/api/settings/profile", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ durationMs }), }); const data = await res.json().catch(() => ({})); if (!res.ok) { throw new Error(data.error || res.statusText || "Profile failed"); } renderServerProfileResults(data); const profilerError = data?.cpu?.profilerError; setProfileMessage( profilerError ? `Memory snapshot captured. CPU profiler was unavailable: ${profilerError}` : "Profile complete. Hot functions and memory object types are listed below.", profilerError ? "error" : "ok", ); } catch (e) { setProfileMessage(`Profile failed: ${e.message}`, "error"); } finally { if (btn) { btn.disabled = false; btn.innerHTML = 'Run Profile'; } } } function downloadServerCpuProfile() { if (!latestServerCpuProfile) return; const blob = new Blob([JSON.stringify(latestServerCpuProfile, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); const stamp = new Date().toISOString().replace(/[:.]/g, "-"); a.href = url; a.download = `overlord-server-${stamp}.cpuprofile`; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } async function runGC() { if (!requireUiPermission("system:health:manage", "Server health maintenance permission required.")) { return; } const gcBtn = document.getElementById("health-gc-btn"); const msgEl = document.getElementById("health-gc-message"); if (gcBtn) gcBtn.disabled = true; try { const res = await fetch("/api/settings/gc", { method: "POST", credentials: "include" }); const data = await res.json().catch(() => ({})); if (msgEl) { msgEl.textContent = res.ok ? `GC complete — freed ~${formatBytes(data.freedBytes || 0)}` : `GC failed: ${data.error || res.statusText}`; msgEl.classList.remove("hidden"); setTimeout(() => msgEl.classList.add("hidden"), 6000); } if (res.ok) await loadHealthStats(); } catch (e) { if (msgEl) { msgEl.textContent = `Error: ${e.message}`; msgEl.classList.remove("hidden"); } } finally { if (gcBtn) gcBtn.disabled = false; } } function initHealthHandlers() { const refreshBtn = document.getElementById("health-refresh-btn"); const gcBtn = document.getElementById("health-gc-btn"); const profileBtn = document.getElementById("server-profile-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 (gcBtn) gcBtn.addEventListener("click", runGC); if (profileBtn) profileBtn.addEventListener("click", runServerProfile); if (downloadProfileBtn) downloadProfileBtn.addEventListener("click", downloadServerCpuProfile); } async function init() { try { await loadCurrentUser(); applyPermissionVisibility(); loadPrefs(); initEasterEggPreference(); await loadMfaStatus(); if (userHas("clients:build")) { if (buildsShowAllWrap && !isAdmin(currentUser?.role)) { buildsShowAllWrap.classList.add("hidden"); } await loadBuilds(); } await loadSecurityPolicy(); await loadTlsSettings(); await loadAppearanceSettings(); await loadChatSettings(); await loadInputArchiveSettings(); await loadBannedIps(); if (userHas("users:manage")) { await renderPermissionsOverview(); } if (userHas("system:registration")) { await loadRegistrationSettings(); initRegistrationHandlers(); } if (userHas("system:build-limits")) { await loadBuildRateLimitSettings(); initBuildRateLimitHandlers(); } if (userHas("system:security")) { await loadFileTransferLimits(); initFileTransferLimitHandlers(); } if (userHas("system:thumbnails")) { await loadThumbnailSettings(); initThumbnailHandlers(); } if (userHas("system:backstage-dll")) { await loadBackstageDllSettings(); initBackstageDllHandlers(); } if (userHas("system:health")) { await loadHealthStats(); initHealthHandlers(); } passwordForm.addEventListener("submit", updatePassword); if (mfaStartBtn) mfaStartBtn.addEventListener("click", startMfaSetup); if (mfaEnableBtn) mfaEnableBtn.addEventListener("click", enableMfa); if (mfaDisableBtn) mfaDisableBtn.addEventListener("click", disableMfa); prefsForm.addEventListener("submit", savePrefs); const sidebarBtn = document.getElementById("pref-nav-sidebar"); const topbarBtn = document.getElementById("pref-nav-topbar"); if (sidebarBtn && topbarBtn) { sidebarBtn.addEventListener("click", () => { if (localStorage.getItem(NAV_MODE_KEY) === "sidebar") return; localStorage.setItem(NAV_MODE_KEY, "sidebar"); window.location.reload(); }); topbarBtn.addEventListener("click", () => { if (localStorage.getItem(NAV_MODE_KEY) === "topbar") return; localStorage.setItem(NAV_MODE_KEY, "topbar"); window.location.reload(); }); } securityForm.addEventListener("submit", saveSecurityPolicy); tlsForm.addEventListener("submit", saveTlsSettings); tlsCertbotAutoBtn.addEventListener("click", runCertbotAutoSetup); if (oidcForm) oidcForm.addEventListener("submit", saveOidcSettings); if (appearanceForm) appearanceForm.addEventListener("submit", saveAppearanceSettings); initSimpleThemeBuilder(); initBrandingUploads(); if (chatSettingsForm) chatSettingsForm.addEventListener("submit", saveChatSettings); if (inputArchiveUserForm) inputArchiveUserForm.addEventListener("submit", saveInputArchivePreference); if (inputArchiveAdminForm) inputArchiveAdminForm.addEventListener("submit", saveInputArchiveAdminSettings); if (exportSettingsBtn) exportSettingsBtn.addEventListener("click", exportSettings); if (importSettingsFile) importSettingsFile.addEventListener("change", importSettings); refreshBansBtn.addEventListener("click", loadBannedIps); bansTableBody.addEventListener("click", handleUnbanClick); if (wipeOfflineBtn) wipeOfflineBtn.addEventListener("click", wipeOfflineClients); if (refreshBuildsBtn) refreshBuildsBtn.addEventListener("click", loadBuilds); if (buildsTableBody) buildsTableBody.addEventListener("click", handleBuildBlockClick); if (buildsShowAllInput) buildsShowAllInput.addEventListener("change", loadBuilds); initSettingsSidebar(); // Compute the scroll offset now, then again after the nav has had a moment // to settle (the adaptive nav controller adjusts mode async based on // viewport width and content overflow), and on every resize. updateSettingsScrollOffset(); setTimeout(updateSettingsScrollOffset, 0); setTimeout(updateSettingsScrollOffset, 200); let resizeTimer = 0; window.addEventListener("resize", () => { clearTimeout(resizeTimer); resizeTimer = setTimeout(updateSettingsScrollOffset, 100); }); // Honor a ?#section-id deep-link on first load so we land on that section. // Run AFTER the offset is set so the section lands under the topbar. if (window.location.hash) { const target = document.getElementById(window.location.hash.slice(1)); if (target) { // Defer to the next frame so scroll-margin-top has been applied. requestAnimationFrame(() => target.scrollIntoView({ behavior: "auto", block: "start" })); } } } catch (error) { console.error("settings init failed", error); showMessage("Failed to load settings.", "error"); } } init(); // ── Backstage DLL Settings ──────────────────────────────────────────────── let _backstageDllPollTimer = null; function showBackstageDllMsg(text, type, targetEl) { const el = targetEl || document.getElementById("backstage-dll-msg"); if (!el) return; el.textContent = text; if (type === "error") { el.className = "text-sm rounded-lg px-3 py-2 border border-rose-800 bg-rose-900/20 text-rose-200"; } else if (type === "info") { el.className = "text-sm rounded-lg px-3 py-2 border border-sky-800 bg-sky-900/20 text-sky-200"; } else { el.className = "text-sm rounded-lg px-3 py-2 border border-emerald-800 bg-emerald-900/20 text-emerald-200"; } el.classList.remove("hidden"); } function renderBackstageDllStatus(status) { if (!status) return; const setText = (id, text) => { const el = document.getElementById(id); if (el) el.textContent = text; }; setText("backstage-dll-export", status.exportName || (status.exists ? "(no randomized export?)" : "-")); setText("backstage-dll-path", status.outputPath || "-"); setText("backstage-dll-size", formatBytes(status.sizeBytes)); setText("backstage-dll-crate", status.crateDir || "(crate not found)"); const tc = status.toolchainDetails || {}; const hint = []; if (tc.cargoOnPath) hint.push("cargo on PATH"); if (tc.rustInstalled) hint.push("rust toolchain cached"); if (tc.rustTargetInstalled) hint.push("windows-gnu target ready"); if (tc.mingwReady) hint.push("mingw-w64 ready"); const tcEl = document.getElementById("backstage-dll-toolchain"); if (tcEl) { tcEl.textContent = hint.length ? `Build tooling: ${hint.join(", ")}` : "Build tooling: none detected (the first rebuild will download it into the data dir)."; } const btn = document.getElementById("backstage-dll-rebuild-btn"); if (btn) btn.disabled = !!status.building; const lastBuild = status.lastBuild; if (lastBuild && lastBuild.finishedAt) { showBackstageDllMsg( `${lastBuild.ok ? "Last build succeeded" : "Last build failed"} (${formatDate(lastBuild.finishedAt)}): ${lastBuild.message}`, lastBuild.ok ? "success" : "error", document.getElementById("backstage-dll-lastbuild"), ); if (status.building) { showBackstageDllMsg("Rebuild in progress...", "info"); } } else if (status.building) { showBackstageDllMsg("Rebuild in progress...", "info"); } } async function loadBackstageDllSettings() { if (!userHas("system:backstage-dll")) return; try { const res = await fetch("/api/settings/backstage-dll", { credentials: "include" }); if (!res.ok) return; const data = await res.json(); renderBackstageDllStatus(data.status || {}); const el = document.getElementById("backstage-dll-rebuild-startup"); if (el) el.checked = !!(data.backstageDll && data.backstageDll.rebuildOnStartup); } catch (e) { console.error("Failed to load backstage DLL settings", e); } } function initBackstageDllHandlers() { const form = document.getElementById("backstage-dll-form"); if (form) form.addEventListener("submit", saveBackstageDllSettings); const btn = document.getElementById("backstage-dll-rebuild-btn"); if (btn) btn.addEventListener("click", triggerBackstageDllRebuild); } async function saveBackstageDllSettings(e) { e.preventDefault(); if (!requireUiPermission("system:backstage-dll", "Backstage DLL permission required.")) return; const rebuildOnStartup = !!document.getElementById("backstage-dll-rebuild-startup")?.checked; try { const res = await fetch("/api/settings/backstage-dll", { method: "PUT", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ rebuildOnStartup }), }); const data = await res.json().catch(() => ({})); if (!res.ok) { showBackstageDllMsg(data.error || "Failed to save", "error"); return; } showBackstageDllMsg("Backstage DLL settings saved.", "success"); renderBackstageDllStatus(data.status || {}); } catch (err) { console.error("Failed to save backstage DLL settings", err); showBackstageDllMsg("Failed to save.", "error"); } } async function triggerBackstageDllRebuild() { if (!requireUiPermission("system:backstage-dll", "Backstage DLL permission required.")) return; try { const res = await fetch("/api/settings/backstage-dll/rebuild", { method: "POST", credentials: "include", }); const data = await res.json().catch(() => ({})); if (!res.ok) { showBackstageDllMsg(data.message || data.error || "Rebuild failed to start.", "error"); return; } showBackstageDllMsg("Rebuild started in the background.", "info"); pollBackstageDllStatus(); } catch (err) { console.error("Failed to start backstage DLL rebuild", err); showBackstageDllMsg("Failed to start rebuild.", "error"); } } function pollBackstageDllStatus() { const tick = async () => { try { const res = await fetch("/api/settings/backstage-dll", { credentials: "include" }); if (!res.ok) return; const data = await res.json(); renderBackstageDllStatus(data.status || {}); if (data.status && data.status.building) { clearTimeout(_backstageDllPollTimer); _backstageDllPollTimer = setTimeout(tick, 2000); } } catch (err) { console.error("Failed to poll backstage DLL status", err); } }; clearTimeout(_backstageDllPollTimer); tick(); }