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 }); 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(""); } 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(""); } function renderAccessLists() { const list = document.querySelector("#access-list"); list.classList.toggle("loading", !state.loaded); list.innerHTML = state.accessLists.length ? `
${state.accessLists.map(item => { const assigned = [...state.proxies, ...state.sites, ...state.redirects].filter(host => host.accessListId === item.id); const loginCount = item.credentials?.length || 0; return `
${featureIcon(item, "AL")}

${extendedEscape(item.name)}

${item.enabled === false ? "Protection disabled" : "Protection available"}

${item.networks?.length || 0}Allowed network${item.networks?.length === 1 ? "" : "s"}${loginCount}Login${loginCount === 1 ? "" : "s"}${assigned.length}Assigned host${assigned.length === 1 ? "" : "s"}
`; }).join("")}
` : '

Create your first Access List

Protect your hosts with reusable network and login rules.

'; const options = '' + state.accessLists.filter(item => item.enabled !== false).map(item => ``).join(""); document.querySelectorAll('select[name="accessListId"], #settings-access-list').forEach(select => { const value = select.value; select.innerHTML = options; select.value = value; }); } function renderBackups() { if (!state.settings) return; const form = document.querySelector("#backup-settings-form"), defaults = state.settings.backups || {}; for (const key of ["type","frequency","hour","retention"]) if (form.elements[key] && defaults[key] !== undefined) form.elements[key].value = defaults[key]; form.elements.enabled.checked = Boolean(defaults.enabled); form.elements.includeLogs.checked = Boolean(defaults.includeLogs); form.elements.encrypt.checked = Boolean(defaults.encrypt); document.querySelector("#backup-path").textContent = `Backups are stored in ${state.settings.backupDirectory}. Separate storage can be mounted directly at /data/backups for disk-failure protection.`; document.querySelector("#backup-list").innerHTML = state.backups.length ? state.backups.map(item => `
${extendedEscape(item.filename)}${formatTime(item.createdAt)}
${extendedEscape(item.type)}Site Gateway ${extendedEscape(item.appVersion)}
${formatBytes(item.size)}${item.valid ? "Verified manifest" : "Unreadable manifest"}
Download
`).join("") : '

No stored backups yet.

'; } function renderDefaultSettings() { if (!state.settings) return; const form = document.querySelector("#default-site-form"), value = state.settings.defaultSite || {}; if (!document.querySelector("#default-site-help")) { const help = document.createElement("p"); help.id = "default-site-help"; help.className = "muted"; help.textContent = "The Default Site handles unknown HTTP hostnames. HTTPS requests still require a matching host and certificate."; form.prepend(help); } for (const key of ["mode","title","message","redirectUrl","redirectCode","customHtml"]) if (form.elements[key] && value[key] !== undefined) form.elements[key].value = value[key]; form.elements.preservePath.checked = value.preservePath !== false; } function renderHealthSettings() { if (!state.settings) return; const form = document.querySelector("#health-settings-form"), value = state.settings.certificateHealth || {}; for (const key of ["warningDays","criticalDays","staleMinutes"]) if (value[key] !== undefined) form.elements[key].value = value[key]; } function decorateAccessAssignments() { document.querySelectorAll("#access-list [data-access-id]").forEach(card => { const item = state.accessLists.find(value => value.id === card.dataset.accessId); if (!item || card.querySelector(".access-assignment-preview")) return; const assigned = [...state.proxies, ...state.sites, ...state.redirects].filter(host => host.accessListId === item.id); const preview = document.createElement("p"); preview.className = "access-assignment-preview"; preview.textContent = assigned.length ? `Protects: ${assigned.map(host => host.name || host.domain).join(" · ")}` : "Not assigned to a host"; card.querySelector(".card-footer")?.before(preview); }); } function renderAuditPanel() { if (state.user?.role !== "administrator") return; const tabs = document.querySelector(".admin-tabs"), users = document.querySelector('[data-admin-panel="users"]'); if (!tabs || !users || document.querySelector('[data-admin-panel="audit"]')) return; const tab = document.createElement("button"); tab.dataset.adminTab = "audit"; tab.textContent = "Audit log"; tabs.insertBefore(tab, tabs.children[1]); const panel = document.createElement("section"); panel.dataset.adminPanel = "audit"; panel.className = "settings-panel hidden"; panel.innerHTML = '

Configuration audit log

A history of Site Gateway configuration changes. Audit records cannot be edited or deleted.

Open this tab to load audit records.

'; users.parentElement.insertBefore(panel, users.nextElementSibling); 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); } }); } 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(); }; for (let hour = 0; hour < 24; hour++) document.querySelector('#backup-settings-form [name="hour"]').insertAdjacentHTML("beforeend", ``); document.querySelector("#stream-form").addEventListener("submit", async event => { event.preventDefault(); const form = new FormData(event.target), body = { name: form.get("name"), port: Number(form.get("port")), target: form.get("target"), tcp: form.has("tcp"), udp: form.has("udp"), healthEnabled: form.has("healthEnabled") }; document.querySelector("#stream-error").textContent = ""; try { const id = event.target.dataset.editing; await api(id ? `/api/streams/${id}` : "/api/streams", { method: id ? "PATCH" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); delete event.target.dataset.editing; document.querySelector("#stream-dialog").close(); await refresh(); toast(`Streaming host ${id ? "updated" : "created"} and applied.`); } catch (error) { document.querySelector("#stream-error").textContent = error.message; } }); document.querySelector("#stream-list").addEventListener("click", async event => { const button = event.target.closest("[data-stream-action]"), card = button?.closest("[data-stream-id]"); if (!button || !card) return; const item = state.streams.find(value => value.id === card.dataset.streamId); if (!item) return; try { if (button.dataset.streamAction === "edit") { const form = document.querySelector("#stream-form"); form.reset(); form.dataset.editing = item.id; form.elements.name.value = item.name || ""; form.elements.port.value = item.port; form.elements.target.value = item.target || ""; form.elements.tcp.checked = item.tcp !== false; form.elements.udp.checked = Boolean(item.udp); form.elements.healthEnabled.checked = item.healthEnabled !== false; document.querySelector("#stream-title").textContent = "Edit streaming host"; document.querySelector("#stream-form .button.primary").textContent = "Save & apply"; document.querySelector("#stream-error").textContent = ""; document.querySelector("#stream-dialog").showModal(); return; } if (button.dataset.streamAction === "icon") { card.classList.remove("menu-open"); return openIconPicker("streams", item.id); } if (button.dataset.streamAction === "delete") { if (!confirm(`Delete streaming host “${item.name}”?`)) return; await api(`/api/streams/${item.id}`, { method: "DELETE" }); await refresh(); toast("Streaming host deleted."); return; } await api(`/api/streams/${item.id}/toggle`, { method: "POST" }); await refresh(); toast("Streaming host updated."); } catch (error) { toast(error.message); } }); document.querySelector("#stream-list").addEventListener("click", event => { if (event.target.closest(".menu-button")) { const card = event.target.closest("[data-stream-id]"); const opening = !card.classList.contains("menu-open"); document.querySelectorAll("#stream-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)); } }); document.querySelector("#redirect-form").addEventListener("submit", async event => { event.preventDefault(); const form = new FormData(event.target), body = Object.fromEntries(form); body.domains = String(form.get("domainsText") || "").split(/[\n,]+/).map(value => value.trim()).filter(Boolean); body.preservePath = form.has("preservePath"); document.querySelector("#redirect-error").textContent = ""; try { const id = event.target.dataset.editing; await api(id ? `/api/redirects/${id}` : "/api/redirects", { method:id ? "PATCH" : "POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify(body) }); delete event.target.dataset.editing; document.querySelector("#redirect-dialog").close(); await refresh(); toast(`Redirect Host ${id ? "updated" : "created"} and applied.`); } catch (error) { document.querySelector("#redirect-error").textContent = error.message; } }); document.querySelector("#redirect-list")?.addEventListener("click", event => { if (!event.target.closest("[data-redirect-action=edit]")) return; const row = event.target.closest("[data-redirect-id]"); const item = state.redirects.find(value => value.id === row?.dataset.redirectId); if (!item) return; setTimeout(() => { const form = document.querySelector("#redirect-form"); if (form.elements.domainsText) form.elements.domainsText.value = (item.domains || []).filter(domain => domain !== item.domain).join("\n"); if (form.elements.accessListId) form.elements.accessListId.value = item.accessListId || ""; }, 0); }, true); if (!document.querySelector("#redirect-form [name=domainsText]")) { const source = document.querySelector("#redirect-form [name=domain]"); const label = document.createElement("label"); label.innerHTML = 'Additional source domains OptionalOne 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); } } 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; } }); 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; 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); } }); function themedAccessDialog(title, copy, confirmLabel = "Delete", danger = false) { let dialog = document.querySelector("#access-action-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "access-action-dialog"; document.body.append(dialog); } dialog.innerHTML = `

Access Lists

${extendedEscape(title)}

${copy}

${confirmLabel ? `` : ""}
`; dialog.showModal(); return new Promise(resolve => dialog.addEventListener("close", () => resolve(dialog.returnValue === "confirm"), { once:true })); } 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); }); 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(); 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; } }); 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; } }); document.querySelector("#create-backup").addEventListener("click", async () => { const form = new FormData(document.querySelector("#backup-settings-form")); try { const result = await api("/api/backups", { method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify({type:form.get("type"),includeLogs:form.has("includeLogs"),password:form.get("backupPassword")}) }); state.backups = await api("/api/backups"); renderBackups(); location.href = `/api/backups/${encodeURIComponent(result.filename)}/download`; toast("Backup created. Download starting."); } catch (error) { toast(error.message); } }); 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 = ""; }); 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)); }); 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"); })); 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); }); 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); 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; try { 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; 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:button.classList.contains("toggle") ? !button.classList.contains("on") : button.textContent.trim() === "Enable" }) }); await refresh(); toast("Group updated."); } catch (error) { 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","security","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); }); 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)); }); } 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 { const created = 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(); window.location.href = `/api/backups/${encodeURIComponent(created.filename)}/download`; toast("Backup created. Download starting."); } catch (error) { toast(error.message); } }, true); 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() { 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() { 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() { 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);