import { getNotificationsEnabled, setNotificationsEnabled, } from "./notify-client.js"; const PREF_REFRESH_KEY = "overlord_refresh_interval_seconds"; 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 prefsForm = document.getElementById("prefs-form"); const prefNotificationsInput = document.getElementById("pref-notifications"); const prefRefreshSecondsInput = document.getElementById("pref-refresh-seconds"); 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 tlsForm = document.getElementById("tls-form"); const tlsPermissionNote = document.getElementById("tls-permission-note"); const tlsSaveBtn = document.getElementById("tls-save-btn"); 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"); let currentUser = null; let securityConfig = null; let tlsConfig = 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"); } } function escapeHtml(text) { const div = document.createElement("div"); div.textContent = text == null ? "" : String(text); return div.innerHTML; } function formatDate(timestamp) { const ts = Number(timestamp) || 0; if (!ts) return "-"; return new Date(ts).toLocaleString(); } function canManageClientBans(role) { return role === "admin" || role === "operator"; } function isAdmin(role) { return role === "admin"; } 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, securitySaveBtn, ]; for (const control of controls) { if (!control) continue; control.disabled = disabled; } } function setTlsFormDisabled(disabled) { const controls = [ tlsCertbotEnabledInput, tlsCertbotLivePathInput, tlsCertbotDomainInput, tlsCertbotCertFileInput, tlsCertbotKeyFileInput, tlsCertbotCaFileInput, tlsSaveBtn, ]; for (const control of controls) { if (!control) continue; control.disabled = disabled; } } 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); updatePasswordPolicyUi(); } async function loadSecurityPolicy() { if (!currentUser) return; if (!isAdmin(currentUser.role)) { securityPermissionNote.classList.remove("hidden"); setSecurityFormDisabled(true); securityConfig = { passwordMinLength: 6, passwordRequireUppercase: false, passwordRequireLowercase: false, passwordRequireNumber: false, passwordRequireSymbol: 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); 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"; } async function loadTlsSettings() { if (!currentUser) return; if (!isAdmin(currentUser.role)) { 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); } function loadPrefs() { prefNotificationsInput.checked = getNotificationsEnabled(); const refreshSeconds = Number(localStorage.getItem(PREF_REFRESH_KEY) || 8); prefRefreshSecondsInput.value = String(Math.min(120, Math.max(3, refreshSeconds))); } async function loadCurrentUser() { const res = await fetch("/api/auth/me", { credentials: "include" }); if (!res.ok) { window.location.href = "/login.html"; 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(); showMessage("Password updated successfully."); } 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); showMessage("Preferences saved."); } async function saveSecurityPolicy(event) { event.preventDefault(); if (!isAdmin(currentUser?.role)) { showMessage("Admin role required.", "error"); 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, }; 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(); showMessage("Security policy updated."); } async function saveTlsSettings(event) { event.preventDefault(); if (!isAdmin(currentUser?.role)) { showMessage("Admin role required.", "error"); 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(); showMessage("TLS settings updated. Restart server to apply."); } 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(); } async function init() { try { await loadCurrentUser(); loadPrefs(); await loadSecurityPolicy(); await loadTlsSettings(); await loadBannedIps(); passwordForm.addEventListener("submit", updatePassword); prefsForm.addEventListener("submit", savePrefs); securityForm.addEventListener("submit", saveSecurityPolicy); tlsForm.addEventListener("submit", saveTlsSettings); refreshBansBtn.addEventListener("click", loadBannedIps); bansTableBody.addEventListener("click", handleUnbanClick); } catch (error) { console.error("settings init failed", error); showMessage("Failed to load settings.", "error"); } } init();