// ============================================================================ // features.js -- rendering and event handling for the "extended" views that // live alongside app.js: Streaming Hosts, Redirect Hosts, Access Lists, and the // entire Administration area (Users, Groups, Gateway defaults, Backup & restore, // Security & updates, Logs & Retention). app.js owns Hosted Sites/Proxy Hosts and // the core dialogs; this file owns everything else, wired up as a set of module- // level event listeners plus render functions called from window.renderExtendedViews. // ============================================================================ // --- Small shared helpers ------------------------------------------------- function extendedEscape(value) { return escapeHtml(value); } function featureIcon(item, fallback) { return item.icon ? `` : fallback; } const backupDialogTextFix = new MutationObserver(() => { const dialog = document.querySelector("#create-backup-dialog"); if (dialog) dialog.querySelectorAll("p,small").forEach(node => { if (node.textContent.includes("Hosted Site files")) node.textContent = node.textContent.replaceAll("Hosted Site files", "uploaded hosted-site files"); }); }); backupDialogTextFix.observe(document.body, { childList:true, subtree:true }); // --- Streaming Hosts view --------------------------------------------------- // Renders the Streaming Hosts grid and its shared empty state. function renderStreams() { const list = document.querySelector("#stream-list"), empty = document.querySelector("#stream-empty"); if (!state.loaded) return; empty.classList.toggle("hidden", !state.loaded || state.streams.length > 0); list.innerHTML = state.streams.map(item => { const status = item.status === "running" ? "running" : item.status === "error" ? "error" : "disabled"; const upstream = item.enabled === false || item.upstream?.status === "unmonitored" ? "Monitoring paused" : !item.upstream || item.upstream.status === "pending" ? "Target check pending" : item.upstream.status === "healthy" ? `Target reachable · ${item.upstream.responseMs} ms` : `Target unreachable · ${extendedEscape(item.upstream.error || "check failed")}`; const protocols = [item.tcp !== false ? "TCP" : null, item.udp ? "UDP" : null].filter(Boolean).map(value => `${value}`).join(""); const toggle = ``; return `
${featureIcon(item,"SH")}

${extendedEscape(item.name)}

Port ${item.port}

→ ${extendedEscape(item.target)}

${upstream}

`; }).join(""); } // --- Redirect Hosts view ----------------------------------------------------- // Renders the Redirect Hosts grid. Redirect cards render their own empty state // inline (no separate #redirect-empty toggle needed here). function renderRedirects() { const list = document.querySelector("#redirect-list"), empty = document.querySelector("#redirect-empty"); // Keep the existing cards or empty state mounted while the shared refresh is pending. // The completed response is the only point at which this view should be replaced. if (!state.loaded) return; empty.classList.toggle("hidden", !state.loaded || state.redirects.length > 0); list.innerHTML = state.redirects.map(item => `
${featureIcon(item,"RD")}

${extendedEscape(item.name)}

${extendedEscape(item.domain)}

→ ${extendedEscape(item.target)}${item.preservePath ? " · preserves path" : ""}

`).join(""); } // --- Access Lists view -------------------------------------------------------- // Renders the Access Lists grid (including its own empty state) and refreshes // every "Access List"

Open this tab to load audit records.

'; const load = async () => { const records = await api(`/api/audit?action=${encodeURIComponent(document.querySelector("#audit-action").value)}&status=${encodeURIComponent(document.querySelector("#audit-status").value)}`); document.querySelector("#audit-list").innerHTML = records.length ? records.map(item => `
${item.status === "error" ? "!" : "✓"}${extendedEscape(item.action)}${extendedEscape(item.actor || "System")} · ${extendedEscape(item.status === "error" ? "Failed" : "Success")} · ${extendedEscape(formatTime(item.created_at))}
`).join("") : '

No matching audit records.

'; }; let timer; tab.addEventListener("click", async () => { document.querySelectorAll("[data-admin-tab]").forEach(item => item.classList.toggle("tab-active", item === tab)); document.querySelectorAll("[data-admin-panel]").forEach(item => item.classList.toggle("hidden", item !== panel)); await load(); }); panel.querySelector("#audit-action").addEventListener("input", () => { clearTimeout(timer); timer = setTimeout(load, 300); }); panel.querySelector("#audit-status").addEventListener("change", load); } function hideRestrictedControls() { if (state.user?.role !== "viewer") return; document.querySelectorAll("#access-list .menu-wrap, #redirect-list .menu-wrap, #stream-list .menu-wrap, #access-list [data-access-action=toggle], #redirect-list [data-redirect-action=toggle], #stream-list [data-stream-action=toggle], .create-trigger, #open-create, #create-backup, #import-backup").forEach(element => { element.classList.add("hidden"); element.setAttribute("aria-hidden", "true"); }); } function renderRetentionPanel() { if (state.user?.role !== "administrator") return; const tabs = document.querySelector(".admin-tabs"), users = document.querySelector('[data-admin-panel="users"]'); if (!tabs || !users) return; let tab = tabs.querySelector('[data-admin-tab="retention"]'); if (!tab) { tab = document.createElement("button"); tab.dataset.adminTab = "retention"; tab.textContent = "Logs & retention"; tabs.append(tab); } let panel = document.querySelector('[data-admin-panel="retention"]'); if (!panel) { panel = document.createElement("section"); panel.dataset.adminPanel = "retention"; panel.className = "settings-panel hidden"; users.parentElement.append(panel); } const policy = state.settings?.logsRetention || { accessDays:30, activityDays:90, auditDays:365, certificateDays:365, securityDays:365, pruningEnabled:false }; panel.innerHTML = `

Logs & retention

Choose how long Site Gateway keeps operational and administrative records. Pruning is disabled until you enable it.

`; panel.querySelector("form").addEventListener("submit", async event => { event.preventDefault(); const form = new FormData(event.target); try { const updated = await api("/api/settings", { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ logsRetention:{ accessDays:Number(form.get("accessDays")), activityDays:Number(form.get("activityDays")), auditDays:Number(form.get("auditDays")), certificateDays:Number(form.get("certificateDays")), securityDays:Number(form.get("securityDays")), pruningEnabled:form.has("pruningEnabled") } }) }); state.settings = updated; toast("Log retention policy saved."); } catch (error) { toast(error.message); } }); } // The single entry point app.js calls after every refresh() to re-render every // view and admin panel owned by this file. window.renderExtendedViews = function () { renderStreams(); renderRedirects(); renderAccessLists(); decorateAccessAssignments(); decorateAccessGroups(); decorateAccessToggles(); renderBackups(); renderDefaultSettings(); renderHealthSettings(); renderGroups(); decorateGroupCards(); renderAuditPanel(); renderRetentionPanel(); const retentionPanel = document.querySelector('[data-admin-panel="retention"]'); const retentionHeading = retentionPanel?.querySelector('.panel-heading > div'); if (retentionHeading && !retentionHeading.querySelector('.retention-eyebrow')) retentionHeading.insertAdjacentHTML("afterbegin", '

AUTOMATIC LOG PRUNING

'); const retentionActions = retentionPanel?.querySelector('.retention-actions'); if (retentionPanel && !retentionActions) { retentionPanel.querySelector('.panel-heading')?.insertAdjacentHTML('beforeend', '
'); retentionPanel.querySelector('[data-retention-action="prune"]')?.addEventListener('click', () => toast('Pruning will run when automatic pruning is enabled and the policy is saved.')); retentionPanel.querySelector('[data-retention-action="download"]')?.addEventListener('click', () => toast('Log download is not available yet.')); } normalizeAdminTabOrder(); hideRestrictedControls(); }; // Populate the scheduled-backup "Hour" One alias per line. All source domains use this redirect destination.'; source.closest("label").after(label); } if (!document.querySelector("#redirect-form [name=accessListId]")) { const tls = document.querySelector("#redirect-form [name=tls]")?.closest("label"); if (tls) { const label = document.createElement("label"); label.innerHTML = 'Access List OptionalProtect this redirect and all of its source domains.'; tls.before(label); } } // --- Access List: create/edit dialog -------------------------------------------- document.querySelector("#access-form").addEventListener("submit", async event => { event.preventDefault(); const form = new FormData(event.target), body = { name:form.get("name"), networks:form.get("networks"), deniedNetworks:form.get("deniedNetworks") }; const editorRows = [...document.querySelectorAll("#access-credential-editor .credential-row")]; body.credentials = editorRows.map(row => ({ username: row.querySelector("[name=credentialUsername]").value.trim(), password: row.querySelector("[name=credentialPassword]").value })).filter(entry => entry.username); if (!event.target.dataset.editing) body.groups = [...document.querySelectorAll("#access-create-groups [data-create-group]:checked")].map(input => input.dataset.createGroup); document.querySelector("#access-error").textContent = ""; try { const id = event.target.dataset.editing; await api(id ? `/api/access-lists/${id}` : "/api/access-lists", { method:id ? "PATCH" : "POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify(body) }); delete event.target.dataset.editing; document.querySelector("#access-dialog").close(); await refresh(); toast(`Access List ${id ? "updated" : "created"}.`); } catch (error) { document.querySelector("#access-error").textContent = error.message; } }); // Renders the login-credential rows inside the Access List dialog. function renderCredentialEditor(credentials = []) { const editor = document.querySelector("#access-credential-editor"); editor.classList.remove("hidden"); editor.innerHTML = `
Login accounts

Add a username and password. When editing an existing user, leave the password blank to keep it unchanged.

${credentials.map(credential => `
`).join("")}`; document.querySelector("#add-access-credential").addEventListener("click", () => { const row = document.createElement("div"); row.className = "credential-row"; row.innerHTML = ''; editor.append(row); row.querySelector(".remove-credential").addEventListener("click", () => row.remove()); }); editor.querySelectorAll(".remove-credential").forEach(button => button.addEventListener("click", () => button.closest(".credential-row").remove())); } window.renderCredentialEditor = renderCredentialEditor; // Redirect card options menu actions: edit / change icon / delete. document.querySelector("#redirect-list").addEventListener("click", async event => { const button = event.target.closest("[data-redirect-action]"), card = button?.closest("[data-redirect-id]"); if (!button || !card) return; const item = state.redirects.find(value => value.id === card.dataset.redirectId); if (!item) return; try { if (button.dataset.redirectAction === "edit") { const form = document.querySelector("#redirect-form"); form.reset(); form.dataset.editing = item.id; for (const key of ["name","domain","target","code","tls"]) form.elements[key].value = item[key] || ""; form.elements.preservePath.checked = item.preservePath !== false; document.querySelector("#redirect-dialog").showModal(); return; } if (button.dataset.redirectAction === "delete") { if (!confirm(`Delete redirect “${item.name}”?`)) return; await api(`/api/redirects/${item.id}`, { method:"DELETE" }); } else await api(`/api/redirects/${item.id}`, { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ enabled:!item.enabled }) }); await refresh(); toast("Redirect Host updated."); } catch (error) { toast(error.message); } }); // Themed replacement for a native confirm() dialog, used for destructive // Access List actions. function themedAccessDialog(title, copy, confirmLabel = "Delete", danger = false, eyebrow = "Access Lists") { let dialog = document.querySelector("#access-action-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "access-action-dialog"; document.body.append(dialog); } dialog.innerHTML = `

${extendedEscape(eyebrow)}

${extendedEscape(title)}

${copy}

${confirmLabel ? `` : ""}
`; dialog.showModal(); return new Promise(resolve => dialog.addEventListener("close", () => resolve(dialog.returnValue === "confirm"), { once:true })); } // Access List card actions: edit / change icon / view assignments / toggle / // delete. document.querySelector("#access-list").addEventListener("click", async event => { if (event.target.closest(".menu-button")) { const card = event.target.closest("[data-access-id]"); const opening = !card.classList.contains("menu-open"); document.querySelectorAll("#access-list .menu-open").forEach(item => item.classList.remove("menu-open")); card.classList.toggle("menu-open", opening); card.querySelector(".menu-button")?.setAttribute("aria-expanded", String(opening)); return; } const button = event.target.closest("[data-access-action]"), row = button?.closest("[data-access-id]"); if (!button || !row) return; const item = state.accessLists.find(value => value.id === row.dataset.accessId); if (!item) return; try { const assigned = [...state.proxies, ...state.sites, ...state.redirects].filter(host => host.accessListId === item.id); if (button.dataset.accessAction === "edit") { const latest = (await api("/api/access-lists")).find(value => value.id === item.id) || item; state.accessLists = state.accessLists.map(value => value.id === item.id ? latest : value); const form = document.querySelector("#access-form"); form.reset(); form.querySelector(".access-create-guidance")?.remove(); form.querySelector("#access-create-groups")?.remove(); form.dataset.editing = latest.id; form.elements.name.value = latest.name; form.elements.networks.value = (latest.networks || []).join("\n"); form.elements.deniedNetworks.value = (latest.deniedNetworks || []).join("\n"); const summary = document.querySelector("#access-assignment-summary"); summary.classList.remove("hidden"); summary.innerHTML = ""; renderCredentialEditor(latest.credentials || []); renderAssignmentEditor(latest.id); ensureAssignmentSearch(); renderAccessGroupSelector(latest.id); document.querySelector("#access-dialog").showModal(); return; } if (button.dataset.accessAction === "icon") { row.classList.remove("menu-open"); openIconPicker("access", item.id); return; } if (button.dataset.accessAction === "assignments") { row.classList.remove("menu-open"); await themedAccessDialog(`Assigned hosts · ${item.name}`, assigned.length ? `${assigned.length} protected host${assigned.length === 1 ? "" : "s"}
${assigned.map(host => `${extendedEscape(host.name || host.domain)}${extendedEscape(host.domain || "No domain")}`).join("")}
` : "This Access List is not assigned to any hosts.", ""); return; } if (button.dataset.accessAction === "delete") { const copy = assigned.length ? `This Access List protects ${assigned.length} active host${assigned.length === 1 ? "" : "s"}. Delete it anyway?` : `Delete Access List “${extendedEscape(item.name)}”?`; if (!(await themedAccessDialog("Delete Access List?", copy, "Delete", true))) return; await api(`/api/access-lists/${item.id}`, { method:"DELETE" }); } else { if (item.enabled !== false && assigned.length && !(await themedAccessDialog("Disable Access List?", `Disabling this Access List will remove protection from ${assigned.length} active host${assigned.length === 1 ? "" : "s"}. Continue?`, "Disable", true))) return; await api(`/api/access-lists/${item.id}`, { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ enabled:item.enabled === false }) }); } await refresh(); toast("Access List updated."); } catch (error) { toast(error.message); } }); document.querySelector("#access-list").addEventListener("click", event => { if (!event.target.closest("[data-access-action=edit]")) return; const row = event.target.closest("[data-access-id]"); const item = state.accessLists.find(value => value.id === row?.dataset.accessId); if (!item) return; setTimeout(() => { const assigned = [...state.proxies, ...state.sites, ...state.redirects].filter(host => host.accessListId === item.id); const summary = document.querySelector("#access-assignment-summary"); if (!summary) return; summary.innerHTML = assigned.length ? `Assigned hosts (${assigned.length})
${assigned.map(host => `${extendedEscape(host.name || host.domain)}${extendedEscape(host.domain || "No domain")}`).join("")}
` : `Assigned hostsThis Access List is not assigned to a host yet.`; }, 0); }); // --- Administration: tab switching between admin panel sections ---------------- document.querySelector(".admin-tabs").addEventListener("click", event => { const button = event.target.closest("[data-admin-tab]"); if (!button) return; document.querySelectorAll("[data-admin-tab]").forEach(item => item.classList.toggle("tab-active", item === button)); document.querySelectorAll("[data-admin-panel]").forEach(panel => panel.classList.toggle("hidden", panel.dataset.adminPanel !== button.dataset.adminTab)); document.querySelector("#open-create").classList.toggle("hidden", button.dataset.adminTab !== "users"); }); document.querySelector(".admin-tabs").addEventListener("click", event => { const button = event.target.closest("[data-admin-tab]"); if (!button) return; state.adminTab = button.dataset.adminTab; history.replaceState(null, "", `${location.pathname}${location.search}#administration/${state.adminTab}`); }); if (state.adminTab && state.view === "administration") document.querySelector(`[data-admin-tab="${state.adminTab}"]`)?.click(); // --- Gateway defaults form submit ------------------------------------------------ document.querySelector("#default-site-form").addEventListener("submit", async event => { event.preventDefault(); const form = new FormData(event.target), value = Object.fromEntries(form); value.preservePath = form.has("preservePath"); try { state.settings = await api("/api/settings", { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({defaultSite:value}) }); toast("Default Site validated and applied."); } catch (error) { document.querySelector("#default-error").textContent = error.message; } }); // --- Backup & restore: schedule type change, health settings save, restore // defaults, and factory reset -------------------------------------------------- document.querySelector("#backup-settings-form [name=type]")?.addEventListener("change", event => { document.querySelector("#backup-type-help").textContent = event.target.value === "complete" ? "Complete backups include configuration, uploaded Hosted Site files, icons, default-site assets, and certificate storage. Verify the file count after creation." : "Configuration-only backups include settings and metadata, but not uploaded Hosted Site files."; }); document.querySelector("#backup-settings-form")?.insertAdjacentHTML("beforeend", '

'); document.querySelector("#backup-settings-form").addEventListener("submit", async event => { event.preventDefault(); const form = new FormData(event.target), backups = Object.fromEntries(form); delete backups.backupPassword; backups.enabled = form.has("enabled"); backups.includeLogs = form.has("includeLogs"); backups.encrypt = form.has("encrypt"); try { state.settings = await api("/api/settings", { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({backups}) }); const scheduleStatus = document.querySelector("#backup-schedule-status"); scheduleStatus.className = `inline-status ${backups.enabled ? "status-success" : "status-warning"}`; scheduleStatus.textContent = backups.enabled ? `Scheduled backups enabled · ${backups.frequency} · ${backups.type === "complete" ? "complete backups" : "configuration backups"}.` : "Scheduled backups disabled. Your saved schedule remains available if you enable it later."; } catch (error) { toast(error.message); } }); document.querySelector("#health-settings-form").addEventListener("submit", async event => { event.preventDefault(); const certificateHealth = Object.fromEntries(new FormData(event.target)); try { state.settings = await api("/api/settings", { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({certificateHealth}) }); renderHealthSettings(); toast("Certificate health thresholds saved."); } catch (error) { toast(error.message); } }); const restoreDefaultsButton = document.querySelector("#restore-defaults"); restoreDefaultsButton.insertAdjacentHTML("beforebegin", '

'); const restoreUsername = document.querySelector("#restore-admin-username"), restorePassword = document.querySelector("#restore-admin-password"), restoreConfirmation = document.querySelector("#restore-confirmation"), restoreInlineError = document.querySelector("#restore-defaults-error"); restoreUsername.addEventListener("blur", async () => { if (!restoreUsername.value.trim()) { restoreInlineError.textContent = "Enter the administrator username."; return; } try { const identity = await api("/api/settings/verify-username", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:restoreUsername.value.trim()})}); restoreInlineError.textContent = identity.valid ? "" : "That administrator username was not found."; } catch (error) { restoreInlineError.textContent = error.message; } }); restorePassword.addEventListener("blur", async () => { if (!restorePassword.value) { restoreInlineError.textContent = "Enter the administrator password."; return; } if (!restoreUsername.value.trim()) { restoreInlineError.textContent = "Enter the administrator username first."; return; } try { await api("/api/settings/verify-admin", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:restoreUsername.value.trim(),password:restorePassword.value})}); restoreInlineError.textContent = ""; } catch (error) { restoreInlineError.textContent = "The password is incorrect for the entered administrator."; } }); restoreConfirmation.addEventListener("blur", () => { if (restoreConfirmation.value && restoreConfirmation.value.trim() !== "RESTORE DEFAULT") restoreInlineError.textContent = "Type RESTORE DEFAULT exactly to continue."; }); restoreDefaultsButton.addEventListener("click", async event => { event.preventDefault(); const username = document.querySelector("#restore-admin-username").value.trim(), password = document.querySelector("#restore-admin-password").value, confirmation = document.querySelector("#restore-confirmation").value.trim(); const inlineError = document.querySelector("#restore-defaults-error"); inlineError.textContent = ""; if (!username) { inlineError.textContent = "Enter the administrator username."; return; } try { const identity = await api("/api/settings/verify-username", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username})}); if (!identity.valid) { inlineError.textContent = "That administrator username was not found."; return; } } catch (error) { inlineError.textContent = error.message; return; } if (!password) { inlineError.textContent = "Enter the administrator password."; return; } try { await api("/api/settings/verify-admin", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username,password})}); } catch (error) { inlineError.textContent = "The password is incorrect for the entered administrator."; return; } if (confirmation !== "RESTORE DEFAULT") { inlineError.textContent = "Type RESTORE DEFAULT exactly to continue."; return; } let dialog = document.querySelector("#restore-defaults-confirm-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "restore-defaults-confirm-dialog"; dialog.innerHTML = '

Gateway preferences

Confirm restore defaults

This restores the default site behavior, backup scheduling, certificate thresholds, and interface preferences. Users, routes, hosted files, certificates, logs, backups, and Access Lists will remain unchanged.

'; document.body.append(dialog); } dialog.querySelector('[name="yes"]').value = ""; dialog.showModal(); const result = await new Promise(resolve => dialog.addEventListener("close", () => resolve(dialog.returnValue), { once:true })); if (result !== "confirm" || dialog.querySelector('[name="yes"]').value.trim().toUpperCase() !== "YES") { dialog.querySelector('[name="yes"]').value = ""; document.querySelector("#restore-admin-username").value = ""; document.querySelector("#restore-admin-password").value = ""; document.querySelector("#restore-confirmation").value = ""; document.querySelector("#restore-defaults-error").textContent = ""; return; } try { state.settings = await api("/api/settings/reset-defaults", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username,password,confirmation})}); dialog.querySelector(".dialog-card").innerHTML = '

Gateway preferences

Defaults restored

The gateway preferences were restored successfully. Your data and routes were preserved.

'; state.settings = await api("/api/settings"); renderDefaultSettings(); renderBackups(); document.querySelector("#restore-admin-username").value = ""; document.querySelector("#restore-admin-password").value = ""; document.querySelector("#restore-confirmation").value = ""; dialog.showModal(); setTimeout(() => dialog.close(), 900); } catch (error) { dialog.querySelector("[data-restore-error]").textContent = error.message; if (!dialog.open) dialog.showModal(); } }); const factoryResetForm = document.querySelector("#factory-reset-form"), factoryUsername = factoryResetForm.elements.username, factoryPassword = factoryResetForm.elements.password, factoryConfirmation = factoryResetForm.elements.confirmation, factoryInlineError = document.querySelector("#factory-reset-error"); factoryUsername.addEventListener("blur", async () => { if (!factoryUsername.value.trim()) { factoryInlineError.textContent = "Enter the administrator username."; return; } try { const identity = await api("/api/settings/verify-username", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:factoryUsername.value.trim()})}); factoryInlineError.textContent = identity.valid ? "" : "That administrator username was not found."; } catch (error) { factoryInlineError.textContent = error.message; } }); factoryPassword.addEventListener("blur", async () => { if (!factoryPassword.value) { factoryInlineError.textContent = "Enter the administrator password."; return; } if (!factoryUsername.value.trim()) { factoryInlineError.textContent = "Enter the administrator username first."; return; } try { await api("/api/settings/verify-admin", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:factoryUsername.value.trim(),password:factoryPassword.value})}); factoryInlineError.textContent = ""; } catch (error) { factoryInlineError.textContent = "The password is incorrect for the entered administrator."; } }); factoryConfirmation.addEventListener("blur", () => { if (factoryConfirmation.value && factoryConfirmation.value.trim() !== "FACTORY RESET") factoryInlineError.textContent = "Type FACTORY RESET exactly to continue."; }); document.querySelector("#factory-reset-form").addEventListener("submit", async event => { event.preventDefault(); const form = new FormData(event.target), confirmation = String(form.get("confirmation") || ""), resetError = document.querySelector("#factory-reset-error"), username = String(form.get("username") || "").trim(), password = String(form.get("password") || ""); resetError.textContent = ""; if (!username) { resetError.textContent = "Enter the administrator username."; return; } try { const identity = await api("/api/settings/verify-username", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username})}); if (!identity.valid) { resetError.textContent = "That administrator username was not found."; return; } } catch (error) { resetError.textContent = error.message; return; } if (!password) { resetError.textContent = "Enter the administrator password."; return; } try { await api("/api/settings/verify-admin", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username,password})}); } catch (error) { resetError.textContent = "The password is incorrect for the entered administrator."; return; } if (confirmation !== "FACTORY RESET") { resetError.textContent = "Type FACTORY RESET exactly to continue."; return; } let dialog = document.querySelector("#factory-reset-confirm-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "factory-reset-confirm-dialog"; dialog.innerHTML = '

Permanent action

Confirm factory reset

This will permanently delete all Site Gateway data under /data, including hosted files, routes, users, groups, Access Lists, certificates, logs, backups, and settings. The container will restart and return to the first-install setup screen.

After restart, open the management URL again and use the original installation credentials to begin setup.

'; document.body.append(dialog); } dialog.querySelector('[name="yes"]').value = ""; dialog.showModal(); const result = await new Promise(resolve => dialog.addEventListener("close", () => resolve(dialog.returnValue), { once:true })); if (result !== "confirm" || dialog.querySelector('[name="yes"]').value.trim().toUpperCase() !== "YES") { dialog.querySelector('[name="yes"]').value = ""; factoryResetForm.reset(); factoryInlineError.textContent = ""; return; } try { await api("/api/factory-reset", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(Object.fromEntries(form))}); dialog.querySelector(".dialog-card").innerHTML = '

Permanent action

Factory reset in progress

Site Gateway is deleting its data and restarting. Keep this window open. The first-install setup screen will open automatically when the container is ready.

Restarting in 10 seconds…

'; dialog.showModal(); let seconds = 10; const timer = setInterval(() => { seconds--; const counter = dialog.querySelector("[data-reset-countdown]"); if (counter) counter.textContent = String(seconds); if (seconds <= 0) { clearInterval(timer); if (counter) counter.textContent = "Opening setup…"; dialog.close(); const openSetup = () => { window.location.href = "/"; }; (async () => { for (let attempt = 0; attempt < 30; attempt++) { try { const response = await fetch("/api/session", { cache: "no-store" }); if (response.ok) { openSetup(); return; } } catch {} await new Promise(resolve => setTimeout(resolve, 1000)); } openSetup(); })(); window.setTimeout(openSetup, 5000); } }, 1000); } catch (error) { document.querySelector("#factory-reset-error").textContent = error.message; } }); // --- Backup import / manual create / restore flows ------------------------------- document.querySelector("#import-backup").addEventListener("click", () => document.querySelector("#backup-upload").click()); async function themedConfirm(title, message, actionLabel = "Continue") { let dialog = document.querySelector("#backup-action-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "backup-action-dialog"; document.body.append(dialog); } dialog.innerHTML = `

Backup & restore

${title}

${message}

`; dialog.showModal(); return new Promise(resolve => dialog.addEventListener("close", () => resolve(dialog.returnValue === "confirm"), { once:true })); } document.querySelector("#backup-upload").addEventListener("change", async event => { const file = event.target.files[0]; if (!file) return; const data = new FormData(), password = document.querySelector('#backup-settings-form [name="backupPassword"]').value; data.append("backup", file); data.append("password", password); try { await api("/api/backups/import", { method:"POST", body:data }); state.backups = await api("/api/backups"); renderBackups(); toast("Backup imported. Review it before restoring."); } catch (error) { toast(error.message); } finally { event.target.value = ""; } }); document.querySelector("#backup-list").addEventListener("click", async event => { const button = event.target.closest("[data-backup-action]"), row = button?.closest("[data-backup]"); if (!button || !row) return; const filename = row.dataset.backup; try { if (button.dataset.backupAction === "delete") { if (!await themedConfirm("Delete backup?", `This permanently removes ${filename}. It cannot be restored unless you have another copy.`, "Delete backup")) return; await api(`/api/backups/${encodeURIComponent(filename)}`, {method:"DELETE"}); } else { if (!await themedConfirm("Restore this backup?", "Current data will be replaced after a safety backup is created. Site Gateway validates the archive and can roll back if restoration fails.", "Restore backup")) return; const password = document.querySelector('#backup-settings-form [name="backupPassword"]').value; await api(`/api/backups/${encodeURIComponent(filename)}/restore`, {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({password})}); await refresh(); } state.backups = await api("/api/backups"); renderBackups(); toast(button.dataset.backupAction === "delete" ? "Backup deleted." : "Backup restored."); } catch (error) { toast(error.message); } }); const restoreButton = document.querySelector("#restore-defaults"), restoreCredentialsBlock = document.querySelector(".danger-credentials"); if (restoreButton && restoreCredentialsBlock && !restoreButton.closest(".restore-form")) { const restoreForm = document.createElement("form"); restoreForm.className = "danger-form restore-form"; restoreCredentialsBlock.replaceWith(restoreForm); restoreForm.append(restoreCredentialsBlock, restoreButton); } const restoreCancel = document.createElement("button"); restoreCancel.type = "button"; restoreCancel.className = "button secondary"; restoreCancel.textContent = "Cancel"; restoreCancel.id = "restore-defaults-cancel"; const restoreActions = document.createElement("div"); restoreActions.className = "danger-actions"; restoreButton.parentNode.insertBefore(restoreActions, restoreButton); restoreActions.append(restoreCancel, restoreButton); restoreCancel.addEventListener("click", () => { document.querySelector("#restore-admin-username").value = ""; document.querySelector("#restore-admin-password").value = ""; document.querySelector("#restore-confirmation").value = ""; document.querySelector("#restore-defaults-error").textContent = ""; }); document.querySelector("#factory-reset-cancel")?.addEventListener("click", () => { document.querySelector("#factory-reset-form").reset(); document.querySelector("#factory-reset-error").textContent = ""; }); // --- Documentation accordion (Administration > Documentation) -------------------- document.querySelectorAll("#docs-content article").forEach((article, index) => { article.id = `doc-${index}`; }); document.querySelectorAll("[data-doc-jump]").forEach(button => button.addEventListener("click", () => { const key = button.dataset.docJump; const article = [...document.querySelectorAll("#docs-content article")].find(item => item.dataset.doc.includes(key)); article?.scrollIntoView({ behavior:"instant", block:"start" }); })); document.querySelector("#doc-search").addEventListener("input", event => { const query = event.target.value.trim().toLowerCase(), articles = [...document.querySelectorAll("#docs-content article")]; let topResult = null, topOrder = Infinity; articles.forEach((article, index) => { const eyebrow = (article.querySelector(".eyebrow")?.textContent || "").toLowerCase(), heading = (article.querySelector("h2")?.textContent || "").toLowerCase(), keywords = (article.dataset.doc || "").toLowerCase(), topicMatch = !query || eyebrow.includes(query) || heading.includes(query), keywordMatch = !topicMatch && keywords.includes(query), match = topicMatch || keywordMatch || article.textContent.toLowerCase().includes(query), order = topicMatch ? index : keywordMatch ? index + articles.length : index + articles.length * 2; article.style.order = ""; if (match && order < topOrder) { topOrder = order; topResult = article; } }); articles.forEach(article => article.classList.toggle("hidden", Boolean(query) && article !== topResult)); document.querySelector("#doc-empty").classList.toggle("hidden", Boolean(!query || topResult)); }); // --- Proxy dialog: show/hide the custom-certificate fields based on TLS mode ----- document.querySelector("#proxy-dialog").addEventListener("close", () => document.querySelector("#proxy-dialog details")?.removeAttribute("open")); document.querySelectorAll("#proxy-form, #settings-form").forEach(form => form.elements.tls.addEventListener("change", () => { const fields = form.querySelector("#custom-certificate-fields, .custom-certificate-fields"); fields?.classList.toggle("custom-certificate-visible", form.elements.tls.value === "custom"); })); // --- Access List assignment editor: which hosts use this Access List ------------- function renderAssignmentEditor(accessListId) { const summary = document.querySelector("#access-assignment-summary"); if (!summary) return; const hosts = [...state.sites.map(host => ({ ...host, kind: "sites", label: "Hosted Site" })), ...state.proxies.map(host => ({ ...host, kind: "proxies", label: "Proxy Host" })), ...state.redirects.map(host => ({ ...host, kind: "redirects", label: "Redirect Host" }))]; summary.classList.remove("hidden"); summary.innerHTML = "Protected hosts

Select the routes this Access List should protect. Changes apply immediately.

" + (hosts.length ? hosts.map(host => "").join("") : "Create a Hosted Site, Proxy Host, or Redirect Host first.") + "
"; } document.querySelector("#access-list")?.addEventListener("change", async event => { const checkbox = event.target.closest("[data-assignment-kind]"); if (!checkbox) return; const kind = checkbox.dataset.assignmentKind; try { await api("/api/" + kind + "/" + checkbox.dataset.assignmentId, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ accessListId: checkbox.checked ? document.querySelector("#access-form")?.dataset.editing || "" : "" }) }); await refresh(); const current = document.querySelector("#access-form")?.dataset.editing; if (current) renderAssignmentEditor(current); toast(checkbox.checked ? "Host added to Access List." : "Host removed from Access List."); } catch (error) { checkbox.checked = !checkbox.checked; toast(error.message); } }); document.querySelector("#access-list")?.addEventListener("click", event => { if (!event.target.closest("[data-access-action=edit]")) return; const row = event.target.closest("[data-access-id]"); if (row) setTimeout(() => renderAssignmentEditor(row.dataset.accessId), 0); }); // Search/filter within the assignment editor. function ensureAssignmentSearch() { const editor = document.querySelector("#access-assignment-summary .assignment-editor"); if (!editor || editor.querySelector(".assignment-search")) return; const label = document.createElement("label"); label.className = "assignment-search-label"; label.textContent = "Filter hosts"; const input = document.createElement("input"); input.className = "assignment-search"; input.type = "search"; input.placeholder = "Name, type, or domain"; input.setAttribute("aria-label", "Filter hosts"); label.append(input); editor.before(label); input.addEventListener("input", () => { const query = input.value.trim().toLowerCase(); editor.querySelectorAll(".assignment-option").forEach(option => { option.hidden = query && !option.textContent.toLowerCase().includes(query); }); }); } document.querySelector("#access-list")?.addEventListener("click", () => setTimeout(ensureAssignmentSearch, 0)); document.addEventListener("input", event => { const input = event.target.closest("#access-assignment-summary .assignment-search"); if (!input) return; const query = input.value.trim().toLowerCase(); document.querySelectorAll("#access-assignment-summary .assignment-option").forEach(option => { option.hidden = Boolean(query) && !option.textContent.toLowerCase().includes(query); }); }); document.addEventListener("change", async event => { const checkbox = event.target.closest("#access-assignment-summary [data-assignment-kind]"); if (!checkbox) return; event.stopImmediatePropagation(); const accessListId = document.querySelector("#access-form")?.dataset.editing; if (!accessListId) return; try { await api("/api/access-lists/" + accessListId + "/assignments", { method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ kind:checkbox.dataset.assignmentKind, hostId:checkbox.dataset.assignmentId, assigned:checkbox.checked }) }); await refresh(); renderAssignmentEditor(accessListId); ensureAssignmentSearch(); toast(checkbox.checked ? "Host added to Access List." : "Host removed from Access List."); } catch (error) { checkbox.checked = !checkbox.checked; toast(error.message); } }, true); document.querySelector("#access-list")?.addEventListener("change", async event => { const checkbox = event.target.closest("[data-assignment-kind]"); if (!checkbox) return; event.stopImmediatePropagation(); const accessListId = document.querySelector("#access-form")?.dataset.editing; const kind = checkbox.dataset.assignmentKind; if (!accessListId) { checkbox.checked = !checkbox.checked; toast("Open an Access List before assigning hosts."); return; } try { await api("/api/access-lists/" + accessListId + "/assignments", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ kind, hostId: checkbox.dataset.assignmentId, assigned: checkbox.checked }) }); await refresh(); renderAssignmentEditor(accessListId); ensureAssignmentSearch(); toast(checkbox.checked ? "Host added to Access List." : "Host removed from Access List."); } catch (error) { checkbox.checked = !checkbox.checked; toast(error.message); } }, true); // --- Groups admin panel (dynamically inserted "Groups" tab) ---------------------- function renderGroups() { const tabs = document.querySelector(".admin-tabs"); const usersPanel = document.querySelector('[data-admin-panel="users"]'); if (!tabs || !usersPanel) return; let tab = tabs.querySelector('[data-admin-tab="groups"]'); if (!tab) { tab = document.createElement("button"); tab.dataset.adminTab = "groups"; tab.textContent = "Groups"; tabs.insertBefore(tab, tabs.children[1]); } let panel = document.querySelector('[data-admin-panel="groups"]'); if (!panel) { panel = document.createElement("section"); panel.dataset.adminPanel = "groups"; panel.className = "settings-panel hidden"; usersPanel.parentElement.insertBefore(panel, usersPanel.nextElementSibling); } panel.innerHTML = '

Groups

Organize users for Access List permissions.

' + (state.groups.length ? '
' + state.groups.map(group => '
GR

' + extendedEscape(group.name) + '

' + (group.members?.length || 0) + ' members

').join("") + '
' : '

No groups yet. Create one to organize users.

'); } function openGroupEditor(group) { let dialog = document.querySelector("#group-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "group-dialog"; document.body.append(dialog); } dialog.innerHTML = '

Administration

Edit group

Select Site Gateway users who should belong to this group.

' + (state.users || []).filter(user => user.status !== "disabled").map(user => '').join("") + '

'; dialog.querySelector('[name="name"]').value = group.name; dialog.querySelectorAll('[name="members"]').forEach(input => { input.checked = (group.memberIds || group.members || []).includes(input.value) || (group.members || []).some(value => value === state.users?.find(user => user.id === input.value)?.username); }); dialog.querySelectorAll(".close-group-dialog").forEach(button => button.addEventListener("click", () => dialog.close())); dialog.querySelector("form").addEventListener("submit", async event => { event.preventDefault(); const form = new FormData(event.target); try { await api("/api/groups/" + group.id, { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ name:form.get("name"), members:[...event.target.querySelectorAll('[name="members"]:checked')].map(input => input.value) }) }); dialog.close(); await refresh(); toast("Group updated."); } catch (error) { dialog.querySelector("[data-group-error]").textContent = error.message; } }); dialog.showModal(); } document.addEventListener("click", event => { const button = event.target.closest('[data-admin-panel="groups"] .group-card .menu-button'); if (!button) return; const card = button.closest(".group-card"); const opening = !card.classList.contains("menu-open"); document.querySelectorAll('[data-admin-panel="groups"] .group-card.menu-open').forEach(item => { item.classList.remove("menu-open"); item.querySelector(".menu-button")?.setAttribute("aria-expanded", "false"); }); card.classList.toggle("menu-open", opening); button.setAttribute("aria-expanded", String(opening)); event.preventDefault(); event.stopImmediatePropagation(); }, true); function openNewGroupEditor() { let dialog = document.querySelector("#group-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "group-dialog"; document.body.append(dialog); } dialog.innerHTML = '

Administration

Create group

Select Site Gateway users who should belong to this group.

' + (state.users || []).filter(user => user.status !== "disabled").map(user => '').join("") + '

'; dialog.querySelectorAll(".close-group-dialog").forEach(button => button.addEventListener("click", () => dialog.close())); dialog.querySelector("form").addEventListener("submit", async event => { event.preventDefault(); const form = new FormData(event.target); try { await api("/api/groups", { method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ name:String(form.get("name") || "").trim(), members:[...event.target.querySelectorAll('[name="members"]:checked')].map(input => input.value) }) }); dialog.close(); await refresh(); toast("Group created."); } catch (error) { dialog.querySelector("[data-group-error]").textContent = error.message; } }); dialog.showModal(); } document.addEventListener("click", async event => { if (event.target.id === "create-group") { openNewGroupEditor(); return; } const button = event.target.closest("[data-group-action]"); if (!button) return; const id = button.dataset.groupId; const group = state.groups.find(value => value.id === id); if (button.dataset.groupAction === "edit") { if (group) openGroupEditor(group); return; } if (button.dataset.groupAction === "icon") return; if (button.dataset.groupAction === "delete" && !confirm("Delete this group?")) return; const isToggle = button.dataset.groupAction === "toggle", wasOn = button.classList.contains("on"); if (isToggle && wasOn) { const assignedLists = (state.accessLists || []).filter(list => (list.groups || []).includes(id) && list.enabled !== false); if (assignedLists.length) { const names = assignedLists.map(list => extendedEscape(list.name)).join(", "); if (!(await themedAccessDialog("Disable group?", `Disabling “${extendedEscape(group?.name || "this group")}” will immediately stop its members from signing in through: ${names}. Continue?`, "Disable", true, "Groups"))) return; } } if (isToggle) { button.classList.toggle("on", !wasOn); button.disabled = true; } try { if (button.dataset.groupAction === "delete") await api("/api/groups/" + id, { method:"DELETE" }); else await api("/api/groups/" + id, { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ enabled: isToggle ? !wasOn : button.textContent.trim() === "Enable" }) }); await refresh(); toast("Group updated."); } catch (error) { if (isToggle) { button.classList.toggle("on", wasOn); button.disabled = false; } toast(error.message); } }); function renderAccessGroupSelector(accessListId) { const summary = document.querySelector("#access-assignment-summary"); if (!summary || !state.groups) return; let field = summary.querySelector(".access-group-selector"); if (!field) { field = document.createElement("section"); field.className = "access-group-selector"; summary.prepend(field); } const selected = state.accessLists.find(item => item.id === accessListId)?.groups || []; field.innerHTML = "Allowed groups Optional

Members of enabled groups can sign in with their Site Gateway credentials.

" + (state.groups.length ? "
" + state.groups.map(group => "").join("") + "
" : "

No groups have been created yet.

"); } document.addEventListener("change", async event => { const option = event.target.closest("[data-group-option]"); if (!option) return; const accessListId = document.querySelector("#access-form")?.dataset.editing; if (!accessListId) return; const groups = [...document.querySelectorAll("#access-assignment-summary [data-group-option]:checked")].map(input => input.dataset.groupOption); try { await api("/api/access-lists/" + accessListId + "/groups", { method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ groups }) }); const item = state.accessLists.find(value => value.id === accessListId); if (item) item.groups = groups; renderAccessLists(); decorateAccessGroups(); toast("Access List groups saved."); } catch (error) { option.checked = !option.checked; toast(error.message); } }, true); document.addEventListener("click", event => { const button = event.target.closest("#access-list [data-access-action=toggle]"); if (button) event.stopImmediatePropagation(); }); document.querySelector("#access-list")?.addEventListener("click", event => { if (!event.target.closest("[data-access-action=edit]")) return; const row = event.target.closest("[data-access-id]"); if (row) setTimeout(() => renderAccessGroupSelector(row.dataset.accessId), 10); }); function decorateAccessGroups() { document.querySelectorAll("#access-list [data-access-id]").forEach(card => { const item = state.accessLists.find(value => value.id === card.dataset.accessId); if (!item) return; if (item.groups?.length && !card.querySelector(".access-group-preview")) { const names = item.groups.map(id => state.groups.find(group => group.id === id)?.name).filter(Boolean); if (names.length) { const preview = document.createElement("p"); preview.className = "access-group-preview"; preview.textContent = "Groups: " + names.join(" · "); card.querySelector(".card-footer")?.before(preview); } } if (!card.querySelector("[data-access-action=toggle]")) { const footer = card.querySelector(".card-footer"); const toggle = document.createElement("button"); toggle.className = "toggle " + (item.enabled !== false ? "on" : ""); toggle.dataset.accessAction = "toggle"; toggle.setAttribute("aria-label", item.enabled !== false ? "Disable Access List" : "Enable Access List"); toggle.innerHTML = ""; footer?.querySelector(".card-actions")?.append(toggle); } }); } function renderNewAccessGuidance() { const form = document.querySelector("#access-form"); if (!form || form.dataset.editing || form.querySelector(".access-create-guidance")) return; const assignmentSummary = document.querySelector("#access-assignment-summary"); if (assignmentSummary) { assignmentSummary.classList.add("hidden"); assignmentSummary.innerHTML = ""; } const guidance = document.createElement("p"); guidance.className = "access-create-guidance"; guidance.textContent = "After saving, edit this Access List to assign protected hosts. Allowed groups can be selected now or changed later."; document.querySelector("#access-credential-editor")?.after(guidance); const groupField = document.createElement("section"); groupField.id = "access-create-groups"; groupField.className = "access-create-groups"; groupField.innerHTML = `Allowed groups Optional

Members of enabled groups can sign in with their Site Gateway credentials.

${state.groups?.length ? `
${state.groups.filter(group => group.enabled !== false).map(group => ``).join("")}
` : '

No groups have been created yet. Create one under Administration → Groups.

'}`; guidance.after(groupField); } document.querySelector("#access-list")?.addEventListener("click", () => setTimeout(renderNewAccessGuidance, 0)); document.addEventListener("click", event => { if (event.target.closest(".create-trigger") && state.view === "access") setTimeout(renderNewAccessGuidance, 0); }); function decorateAccessToggles() { document.querySelectorAll("#access-list [data-access-id]").forEach(card => { const item = state.accessLists.find(value => value.id === card.dataset.accessId); const footer = card.querySelector(".card-footer"); if (!footer || !item) return; card.querySelectorAll(".menu [data-access-action=toggle]").forEach(button => button.remove()); if (footer.querySelector("[data-access-action=toggle]")) return; let actions = footer.querySelector(".card-actions"); if (!actions) { actions = document.createElement("div"); actions.className = "card-actions"; footer.append(actions); } const toggle = document.createElement("button"); toggle.className = "toggle " + (item.enabled !== false ? "on" : ""); toggle.dataset.accessAction = "toggle"; toggle.setAttribute("aria-label", (item.enabled !== false ? "Disable" : "Enable") + " Access List"); toggle.innerHTML = ""; actions.append(toggle); }); } function decorateGroupCards() { document.querySelectorAll('[data-admin-panel="groups"] .group-card').forEach(card => { const group = state.groups.find(value => value.id === card.querySelector("[data-group-action]")?.dataset.groupId); if (!group) return; const icon = card.querySelector(".site-icon"); if (icon && icon.textContent.trim() === "GR") icon.innerHTML = featureIcon(group, "GR"); const menu = card.querySelector(".menu"); if (menu && !menu.querySelector("[data-group-action=icon]")) { const button = document.createElement("button"); button.dataset.groupAction = "icon"; button.dataset.groupId = group.id; button.textContent = "Change icon"; menu.prepend(button); } }); } document.addEventListener("click", event => { const button = event.target.closest("[data-group-action=icon]"); if (!button) return; event.preventDefault(); event.stopImmediatePropagation(); openIconPicker("groups", button.dataset.groupId); }, true); function normalizeAdminTabOrder() { const tabs = document.querySelector(".admin-tabs"); if (!tabs) return; const order = ["users","groups","defaults","audit","backups","retention","danger"]; order.forEach((name, index) => { const button = tabs.querySelector(`[data-admin-tab="${name}"]`); if (button) { if (name === "retention") button.textContent = "Logs & Retention"; tabs.append(button); } }); } document.addEventListener("click", event => { if (event.target.closest(".admin-tabs")) setTimeout(normalizeAdminTabOrder, 0); }); // --- Backup encryption password field: placeholder/visibility polish ------------- const backupPasswordInput = document.querySelector('#backup-settings-form [name="backupPassword"]'); if (backupPasswordInput) { backupPasswordInput.placeholder = "Optional — enter a password"; backupPasswordInput.closest(".backup-password-field")?.querySelector(".optional")?.remove(); } const encryptionToggle = document.querySelector('#backup-settings-form .encryption-toggle'); if (encryptionToggle) { encryptionToggle.className = "encryption-toggle"; encryptionToggle.innerHTML = 'Encrypt scheduled backupsUses the container’s BACKUP_PASSWORD value. Enable only after configuring that value.'; } if (backupPasswordInput && !document.querySelector("#backup-password-toggle")) { backupPasswordInput.insertAdjacentHTML("afterend", ''); const toggle = document.querySelector("#backup-password-toggle"); toggle.addEventListener("click", () => { const visible = backupPasswordInput.type === "text"; backupPasswordInput.type = visible ? "password" : "text"; toggle.textContent = visible ? "Show" : "Hide"; toggle.setAttribute("aria-label", visible ? "Show backup encryption password" : "Hide backup encryption password"); toggle.setAttribute("aria-pressed", String(!visible)); }); } // Manual "Create backup" button. document.querySelector("#create-backup")?.addEventListener("click", async event => { event.preventDefault(); event.stopImmediatePropagation(); let dialog = document.querySelector("#create-backup-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "create-backup-dialog"; document.body.append(dialog); } dialog.innerHTML = '

Backup & restore

Create a backup

Choose what to include. Site Gateway saves a copy in /data/backups and downloads a copy to your computer.

Complete backups include uploaded files, icons, certificates, and default-site assets. Configuration-only backups do not include uploaded Hosted Site files.If provided, this password is required to restore the downloaded archive.
'; dialog.showModal(); const result = await new Promise(resolve => dialog.addEventListener("close", () => resolve(dialog.returnValue), {once:true})); if (result !== "confirm") return; const form = dialog.querySelector("form"), type = form.elements.type.value, password = form.elements.password.value; try { await api("/api/backups", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({type,includeLogs:document.querySelector('#backup-settings-form [name="includeLogs"]')?.checked === true,password})}); state.backups = await api("/api/backups"); renderBackups(); toast("Backup created. Use Download in the list below to save it."); } catch (error) { toast(error.message); } }, true); // --- Logs & Retention admin panel (Administration > Logs & Retention) ------------ function normalizeRetentionLayout() { const form = document.querySelector('[data-admin-panel="retention"] .retention-form'); if (!form || form.dataset.normalized === "true") return; const actions = form.querySelector(".dialog-actions"); const fields = [...form.children].filter(child => child !== actions); const section = document.createElement("div"); section.className = "form-section form-section-wide"; const eyebrow = document.createElement("p"); eyebrow.className = "eyebrow"; eyebrow.textContent = "Automatic Log Pruning"; section.append(eyebrow); const grid = document.createElement("div"); grid.className = "form-grid"; fields.forEach(field => grid.append(field)); section.append(grid); form.prepend(section); if (actions) form.append(actions); form.dataset.normalized = "true"; } normalizeRetentionLayout(); function cleanRetentionLabels() { const form = document.querySelector('[data-admin-panel="retention"] .retention-form'); if (!form) return; const descriptions = { 'Access logs':'High-volume request records.', 'Gateway activity':'Operational and configuration events.', 'Audit logs':'Administrative accountability records.', 'Certificate events':'Certificate issuance and health changes.', 'Security events':'Authentication and security-related events.' }; [...form.querySelectorAll('label:not(.check-control)')].forEach(field => { const text = field.firstChild; const name = text?.textContent?.trim().replace(/ \(days\)$/, ''); if (!text || !descriptions[name]) return; if (!text.textContent.includes('(days)')) text.textContent = `${name} (days)`; let help = field.querySelector('small'); if (!help) { help = document.createElement('small'); field.append(help); } help.textContent = descriptions[name]; }); } setTimeout(() => { cleanRetentionLabels(); normalizeRetentionLayout(); }, 0); setInterval(() => { cleanRetentionLabels(); normalizeRetentionLayout(); }, 300); function renderRetentionRunStatus() { if (!state.user) return; const panel = document.querySelector('[data-admin-panel="retention"]'); const form = panel?.querySelector('.retention-form'); if (!panel || !form) return; const value = state.settings?.logsRetention?.lastRunAt ? state.settings.logsRetention : null; let status = panel.querySelector('.retention-run-status'); if (!status) { status = document.createElement('div'); status.className = 'retention-run-status muted'; const actions = form.querySelector('.dialog-actions'); if (actions) actions.before(status); else form.append(status); } status.textContent = value ? `Last run: ${value.lastRunMode || 'manual'} · ${new Date(value.lastRunAt).toLocaleString()} · Snapshot: ${value.lastRunSnapshot || 'available'}` : 'No pruning run yet.'; } async function renderRetentionPreview() { if (!state.user) return; const panel = document.querySelector('[data-admin-panel="retention"]'); if (!panel) return; let preview = panel.querySelector('.retention-preview'); if (!preview) { preview = document.createElement('div'); preview.className = 'retention-preview muted'; const form = panel.querySelector('.retention-form'); const status = panel.querySelector('.retention-run-status'); (status || form)?.before(preview); } try { const data = await api('/api/logs/prune/preview'); const counts = data.counts || {}; const total = Object.values(counts).reduce((sum, value) => sum + Number(value || 0), 0); preview.textContent = data.enabled ? `Eligible to prune: ${total} records · Access ${counts.access || 0} · Activity ${counts.activity || 0} · Certificates ${counts.certificate || 0} · Security ${counts.security || 0} · Audit ${counts.audit || 0}` : 'Pruning is disabled. Enable automatic pruning to preview eligible records.'; } catch { preview.textContent = 'Prune preview unavailable.'; } } async function renderRetentionHistory() { if (!state.user) return; const panel = document.querySelector('[data-admin-panel="retention"]'); if (!panel) return; let history = panel.querySelector('.retention-history'); if (!history) { history = document.createElement('div'); history.className = 'retention-history'; (panel.querySelector('.retention-run-status') || panel.querySelector('.retention-form'))?.after(history); } try { const rows = (await api('/api/audit?action=pruning')).filter(item => /pruning/i.test(item.action)).slice(0, 50); history.innerHTML = `
Prune history${rows.length} runs
` + (rows.length ? `
${rows.map(item => `
${extendedEscape(item.action)}${extendedEscape(item.actor || 'System')} · ${extendedEscape(item.status === 'error' ? 'Failed' : 'Success')} · ${extendedEscape(formatTime(item.created_at))}
`).join('')}
` : '

No pruning runs recorded yet.

'); } catch { history.innerHTML = '

Prune history unavailable.

'; } } function ensureRetentionLoadMore() { const history = document.querySelector('.retention-history'); if (!history || history.querySelector('[data-retention-load-more]')) return; const button = document.createElement('button'); button.className = 'text-button retention-load-more'; button.dataset.retentionLoadMore = 'true'; button.textContent = 'Load more'; history.append(button); } document.addEventListener('click', async event => { const button = event.target.closest('[data-retention-load-more]'); if (!button) return; try { const rows = (await api('/api/audit?action=pruning')).filter(item => /pruning/i.test(item.action)).slice(50); const list = button.parentElement.querySelector('.retention-history-list'); rows.forEach(item => { const row = document.createElement('div'); row.className = 'retention-history-row'; row.innerHTML = `${extendedEscape(item.action)}${extendedEscape(item.actor || 'System')} · ${extendedEscape(item.status === 'error' ? 'Failed' : 'Success')} · ${extendedEscape(formatTime(item.created_at))}`; list?.append(row); }); button.remove(); } catch { button.textContent = 'History unavailable'; } }); function normalizeRetentionActions() { const form = document.querySelector('[data-admin-panel="retention"] .retention-form'); const actions = form?.querySelector('.dialog-actions'); const section = form?.querySelector('.form-section'); if (form && actions && section && actions.previousElementSibling !== section) section.after(actions); } async function expandRetentionHistory() { const history = document.querySelector('.retention-history'); const list = history?.querySelector('.retention-history-list'); if (!history || !list || history.dataset.expanded) return; history.dataset.expanded = 'true'; try { const rows = (await api('/api/audit?action=pruning')).filter(item => /pruning/i.test(item.action)).slice(50, 200); rows.forEach(item => { const row = document.createElement('div'); row.className = 'retention-history-row'; row.innerHTML = `${extendedEscape(item.action)}${extendedEscape(item.actor || 'System')} · ${extendedEscape(item.status === 'error' ? 'Failed' : 'Success')} · ${extendedEscape(formatTime(item.created_at))}`; list.append(row); }); } catch { /* Keep the initial 50 records if expansion is unavailable. */ } } setInterval(ensureRetentionLoadMore, 500); setInterval(normalizeRetentionActions, 500); setInterval(expandRetentionHistory, 1000); setInterval(renderRetentionRunStatus, 500); setInterval(renderRetentionPreview, 2000); setInterval(() => { if (document.querySelector('[data-admin-panel="retention"]:not(.hidden)') && !document.querySelector('.retention-history')) renderRetentionHistory(); }, 1000); setTimeout(renderRetentionPreview, 0); setTimeout(renderRetentionHistory, 0); document.addEventListener("click", async event => { const button = event.target.closest('[data-retention-action="prune"]'); if (!button) return; try { const response = await api("/api/logs/prune", { method: "POST" }); const total = Object.values(response.counts || {}).reduce((sum, value) => sum + value, 0); toast(`Pruning completed. ${total} record${total === 1 ? "" : "s"} removed.`); } catch (error) { toast(error.message); } }); document.addEventListener("click", event => { const button = event.target.closest('[data-retention-action="download"]'); if (!button) return; window.location.href = "/api/logs/download"; }); document.addEventListener("click", async event => { const button = event.target.closest('[data-retention-action="prune"]'); if (!button) return; event.preventDefault(); event.stopImmediatePropagation(); try { const preview = await api("/api/logs/prune/preview"); const counts = preview.counts || {}; const total = Object.values(counts).reduce((sum, value) => sum + value, 0); let dialog = document.querySelector("#retention-prune-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "retention-prune-dialog"; document.body.append(dialog); } dialog.innerHTML = `

Log Retention

Confirm pruning

This will remove records older than your saved retention periods.

Access: ${counts.access || 0} · Activity: ${counts.activity || 0} · Certificates: ${counts.certificate || 0} · Security: ${counts.security || 0} · Audit: ${counts.audit || 0}

`; dialog.showModal(); const result = await new Promise(resolve => dialog.addEventListener("close", () => resolve(dialog.returnValue), { once: true })); if (result !== "confirm") return; const response = await api("/api/logs/prune", { method: "POST" }); toast(`Pruning completed. ${Object.values(response.counts || {}).reduce((sum, value) => sum + value, 0)} record${total === 1 ? "" : "s"} removed.`); } catch (error) { toast(error.message); } }, true);