No stored backups yet.
';
}
// Toggle the "configuration only" warning banner whenever scheduling or the
// backup type changes.
document.querySelector("#backup-settings-form [name=\"enabled\"]")?.addEventListener("change", updateBackupConfigOnlyWarning);
document.querySelector("#backup-settings-form [name=\"type\"]")?.addEventListener("change", updateBackupConfigOnlyWarning);
// --- Gateway defaults (custom 404 / no-response / redirect page) --------------
// Populates the Gateway Defaults form from saved settings and refreshes the
// live preview panel.
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;
renderDefaultSitePreview();
}
// Keep this HTML template in sync with the themed-page literal inside renderDefaultSiteHtml()
// in src/server.js -- duplicated here so the live preview below renders instantly, without a
// round trip to the server, on every keystroke. If the server template ever changes, this must
// be updated to match or the preview will silently drift from what visitors actually see.
function defaultSiteThemedHtml(title, message) {
const safeTitle = extendedEscape(title);
const safeMessage = extendedEscape(message);
return `Enter Custom HTML above to preview it here.
"; return; }
const title = form.elements.title.value || (mode === "welcome" ? "Gateway ready" : "Route not found");
const message = form.elements.message.value || "The gateway is responding, but this address has not been configured.";
frame.srcdoc = defaultSiteThemedHtml(title, message);
}
document.querySelector("#default-site-form")?.addEventListener("input", renderDefaultSitePreview);
document.querySelector("#default-site-form")?.addEventListener("change", renderDefaultSitePreview);
// --- Certificate health thresholds (Security & updates admin panel) -----------
// Populates the health-settings form (renewing-soon / critical / stale-data
// thresholds) from saved settings.
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];
}
// --- Misc admin panel decorators, called after every refresh -------------------
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) return; let tab = tabs.querySelector('[data-admin-tab="audit"]'); if (!tab) { tab = document.createElement("button"); tab.dataset.adminTab = "audit"; tab.textContent = "Audit log"; tabs.insertBefore(tab, tabs.children[1]); } let panel = document.querySelector('[data-admin-panel="audit"]'); if (!panel) { panel = document.createElement("section"); panel.dataset.adminPanel = "audit"; panel.className = "settings-panel hidden"; users.parentElement.insertBefore(panel, users.nextElementSibling); } if (panel.dataset.ready) return; panel.dataset.ready = "1"; panel.innerHTML = '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 = ` with 0-23.
for (let hour = 0; hour < 24; hour++) document.querySelector('#backup-settings-form [name="hour"]').insertAdjacentHTML("beforeend", `${String(hour).padStart(2,"0")}:00 `);
// --- Streaming host: create/edit dialog and card actions -----------------------
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); }
});
// Card options menu open/close toggle.
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)); } });
// --- Redirect host: create/edit dialog and related field wiring ----------------
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 Optional 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 Optional Public — no Access List Protect 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 login
Add a username and password. When editing an existing user, leave the password blank to keep it unchanged.
${credentials.map(credential => `Remove
`).join("")}`; document.querySelector("#add-access-credential").addEventListener("click", () => { const row = document.createElement("div"); row.className = "credential-row"; row.innerHTML = 'Remove '; 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 = ``; 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 hosts This 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 = ''; 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 = ''; 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 = ``; 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 => "" + extendedEscape(host.name || host.domain || "Unnamed host") + " " + host.label + " · " + extendedEscape(host.domain || "No domain") + " ").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.
Create group ' + (state.groups.length ? '' + state.groups.map(group => '
' + 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 = ''; 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 = ''; 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 => "" + extendedEscape(group.name) + " " + (group.members?.length || 0) + " members" + (group.enabled === false ? " · Disabled" : "") + " ").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 => `${extendedEscape(group.name)} ${group.members?.length || 0} members `).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 backups Uses the container’s BACKUP_PASSWORD value. Enable only after configuring that value. ';
}
if (backupPasswordInput && !document.querySelector("#backup-password-toggle")) {
backupPasswordInput.insertAdjacentHTML("afterend", 'Show ');
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 = ''; 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 = ``; 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);