Add config-drift detection, backup-encryption readiness gating, dialog cleanup (v0.14.0)

This commit is contained in:
marvin
2026-09-18 11:43:43 -04:00
parent b72afdcee3
commit 968066d39a
7 changed files with 115 additions and 21 deletions
+35 -6
View File
@@ -260,9 +260,9 @@ document.querySelector("#access-list")?.addEventListener("change", async event =
// --- 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 = '<div class="panel-heading"><div><h2>Groups</h2><p class="muted">Organize users for Access List permissions.</p></div><button id="create-group" class="button primary">Create group</button></div>' + (state.groups.length ? '<div class="user-grid">' + state.groups.map(group => '<article class="site-card group-card"><div class="card-top"><div class="site-icon">GR</div><div class="menu-wrap"><button class="icon-button menu-button" aria-label="Group options" aria-expanded="false">•••</button><div class="menu"><button data-group-action="edit" data-group-id="' + group.id + '">Edit group</button><button data-group-action="icon" data-group-id="' + group.id + '">Change icon</button><button data-group-action="delete" data-group-id="' + group.id + '" class="danger-text">Delete group</button></div></div></div><h2>' + extendedEscape(group.name) + '</h2><p class="address">' + (group.members?.length || 0) + ' members</p><div class="card-footer"><span class="status-pill"><span class="status-dot ' + (group.enabled === false ? "disabled" : "running") + '"></span>' + (group.enabled === false ? "Disabled" : "Enabled") + '</span><div class="card-actions"><button class="toggle ' + (group.enabled !== false ? "on" : "") + '" data-group-action="toggle" data-group-id="' + group.id + '" aria-label="' + (group.enabled !== false ? "Disable" : "Enable") + ' group"><span></span></button></div></div></article>').join("") + '</div>' : '<p class="quiet-state padded">No groups yet. Create one to organize users.</p>'); }
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 = '<form class="dialog-card group-editor"><div class="dialog-heading"><div><p class="eyebrow">Administration</p><h2>Edit group</h2></div><button type="button" class="icon-button close-group-dialog">×</button></div><label>Group name<input name="name" required maxlength="80"></label><label>Members <span class="optional">Optional</span></label><p class="muted">Select Site Gateway users who should belong to this group.</p><div class="group-member-options">' + (state.users || []).filter(user => user.status !== "disabled").map(user => '<label class="check-control"><input type="checkbox" name="members" value="' + user.id + '"><span>' + extendedEscape(user.username) + ' <small>' + extendedEscape(user.role || "Standard User") + '</small></span></label>').join("") + '</div><p class="error" data-group-error></p><div class="dialog-actions"><button type="button" class="button secondary close-group-dialog">Cancel</button><button class="button primary">Save group</button></div></form>'; 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(); }
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 = '<form class="dialog-card group-editor"><div class="dialog-heading"><div><p class="eyebrow">Administration</p><h2>Edit group</h2></div></div><label>Group name<input name="name" required maxlength="80"></label><label>Members <span class="optional">Optional</span></label><p class="muted">Select Site Gateway users who should belong to this group.</p><div class="group-member-options">' + (state.users || []).filter(user => user.status !== "disabled").map(user => '<label class="check-control"><input type="checkbox" name="members" value="' + user.id + '"><span>' + extendedEscape(user.username) + ' <small>' + extendedEscape(user.role || "Standard User") + '</small></span></label>').join("") + '</div><p class="error" data-group-error></p><div class="dialog-actions"><button type="button" class="button secondary close-group-dialog">Cancel</button><button class="button primary">Save group</button></div></form>'; 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 = '<form class="dialog-card group-editor"><div class="dialog-heading"><div><p class="eyebrow">Administration</p><h2>Create group</h2></div><button type="button" class="icon-button close-group-dialog">×</button></div><label>Group name<input name="name" required maxlength="80" placeholder="Home users"></label><label>Members <span class="optional">Optional</span></label><p class="muted">Select Site Gateway users who should belong to this group.</p><div class="group-member-options">' + (state.users || []).filter(user => user.status !== "disabled").map(user => '<label class="check-control"><input type="checkbox" name="members" value="' + user.id + '"><span>' + extendedEscape(user.username) + ' <small>' + extendedEscape(user.role || "Standard User") + '</small></span></label>').join("") + '</div><p class="error" data-group-error></p><div class="dialog-actions"><button type="button" class="button secondary close-group-dialog">Cancel</button><button class="button primary">Create group</button></div></form>'; 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(); }
function openNewGroupEditor() { let dialog = document.querySelector("#group-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "group-dialog"; document.body.append(dialog); } dialog.innerHTML = '<form class="dialog-card group-editor"><div class="dialog-heading"><div><p class="eyebrow">Administration</p><h2>Create group</h2></div></div><label>Group name<input name="name" required maxlength="80" placeholder="Home users"></label><label>Members <span class="optional">Optional</span></label><p class="muted">Select Site Gateway users who should belong to this group.</p><div class="group-member-options">' + (state.users || []).filter(user => user.status !== "disabled").map(user => '<label class="check-control"><input type="checkbox" name="members" value="' + user.id + '"><span>' + extendedEscape(user.username) + ' <small>' + extendedEscape(user.role || "Standard User") + '</small></span></label>').join("") + '</div><p class="error" data-group-error></p><div class="dialog-actions"><button type="button" class="button secondary close-group-dialog">Cancel</button><button class="button primary">Create group</button></div></form>'; 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 = "<strong>Allowed groups <span class=\"optional\">Optional</span></strong><p class=\"access-group-help\">Members of enabled groups can sign in with their Site Gateway credentials.</p>" + (state.groups.length ? "<div class=\"access-group-options\">" + state.groups.map(group => "<label class=\"check-control access-group-option\"><input type=\"checkbox\" data-group-option=\"" + group.id + "\"" + (selected.includes(group.id) ? " checked" : "") + "><span>" + extendedEscape(group.name) + " <small>" + (group.members?.length || 0) + " members" + (group.enabled === false ? " · Disabled" : "") + "</small></span></label>").join("") + "</div>" : "<p class=\"access-group-empty\">No groups have been created yet.</p>"); }
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);
@@ -284,11 +284,20 @@ 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) {
function renderEncryptionToggle() {
const encryptionToggle = document.querySelector('#backup-settings-form .encryption-toggle');
if (!encryptionToggle) return;
const available = Boolean(state.config && state.config.backup && state.config.backup.encryptionAvailable);
const savedEncrypt = Boolean(state.settings && state.settings.backups && state.settings.backups.encrypt);
encryptionToggle.className = "encryption-toggle";
encryptionToggle.innerHTML = '<span class="field-label">Encrypt scheduled backups</span><span class="encryption-toggle-box"><input name="encrypt" type="checkbox"><span>Uses the containers <code>BACKUP_PASSWORD</code> value. Enable only after configuring that value.</span></span>';
let message = "Uses the container\u2019s <code>BACKUP_PASSWORD</code> value. Enable only after configuring that value.";
if (!available && savedEncrypt) message = "This is enabled but <code>BACKUP_PASSWORD</code> is no longer configured \u2014 encrypted scheduled backups will fail until it\u2019s set again.";
else if (!available) message = "<code>BACKUP_PASSWORD</code> not configured \u2014 set it in the container\u2019s environment to enable encrypted scheduled backups.";
encryptionToggle.innerHTML = '<span class="field-label">Encrypt scheduled backups</span><span class="encryption-toggle-box"><input name="encrypt" type="checkbox"' + (available ? "" : " disabled") + (savedEncrypt ? " checked" : "") + '><span' + (!available ? ' class="warning-text"' : '') + '>' + message + '</span></span>';
}
renderEncryptionToggle();
setInterval(renderEncryptionToggle, 1000);
if (backupPasswordInput && !document.querySelector("#backup-password-toggle")) {
backupPasswordInput.insertAdjacentHTML("afterend", '<button type="button" id="backup-password-toggle" class="password-toggle" aria-label="Show backup encryption password" aria-pressed="false">Show</button>');
const toggle = document.querySelector("#backup-password-toggle");
@@ -296,7 +305,7 @@ if (backupPasswordInput && !document.querySelector("#backup-password-toggle")) {
}
// 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 = '<form method="dialog" class="dialog-card backup-create-dialog"><div class="dialog-heading"><div><p class="eyebrow">Backup & restore</p><h2>Create a backup</h2></div><button value="cancel" formnovalidate class="icon-button" aria-label="Close">×</button></div><p class="muted">Choose what to include. Site Gateway saves a copy in <code>/data/backups</code> and downloads a copy to your computer.</p><label>Backup type<select name="type"><option value="complete">Complete (Recommended) — configuration plus Hosted Site files</option><option value="configuration">Configuration only — settings and metadata</option></select></label><small class="backup-dialog-help">Complete backups include uploaded files, icons, certificates, and default-site assets. Configuration-only backups do not include uploaded Hosted Site files.</small><label>Encryption password <span class="optional">Optional</span><input name="password" type="password" placeholder="Optional — enter a password" autocomplete="new-password"></label><small class="backup-dialog-help">If provided, this password is required to restore the downloaded archive.</small><div class="dialog-actions"><button value="cancel" formnovalidate class="button secondary">Cancel</button><button value="confirm" class="button primary">Create backup</button></div></form>'; 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);
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 = '<form method="dialog" class="dialog-card backup-create-dialog"><div class="dialog-heading"><div><p class="eyebrow">Backup & restore</p><h2>Create a backup</h2></div></div><p class="muted">Choose what to include. Site Gateway saves a copy in <code>/data/backups</code> and downloads a copy to your computer.</p><label>Backup type<select name="type"><option value="complete">Complete (Recommended) — configuration plus Hosted Site files</option><option value="configuration">Configuration only — settings and metadata</option></select></label><small class="backup-dialog-help">Complete backups include uploaded files, icons, certificates, and default-site assets. Configuration-only backups do not include uploaded Hosted Site files.</small><label>Encryption password <span class="optional">Optional</span><input name="password" type="password" placeholder="Optional — enter a password" autocomplete="new-password"></label><small class="backup-dialog-help">If provided, this password is required to restore the downloaded archive.</small><div class="dialog-actions"><button value="cancel" formnovalidate class="button secondary">Cancel</button><button value="confirm" class="button primary">Create backup</button></div></form>'; 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) ------------
@@ -316,3 +325,23 @@ setInterval(renderRetentionRunStatus, 500); setInterval(renderRetentionPreview,
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 = `<form method="dialog" class="dialog-card compact"><div class="dialog-heading"><div><p class="eyebrow">Log Retention</p><h2>Confirm pruning</h2></div></div><p class="muted">This will remove records older than your saved retention periods.</p><p class="muted">Access: <strong>${counts.access || 0}</strong> · Activity: <strong>${counts.activity || 0}</strong> · Certificates: <strong>${counts.certificate || 0}</strong> · Security: <strong>${counts.security || 0}</strong> · Audit: <strong>${counts.audit || 0}</strong></p><div class="dialog-actions"><button value="cancel" class="button secondary">Cancel</button><button value="confirm" class="button primary">Confirm pruning</button></div></form>`; 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);
// --- Config drift: show a resync callout in Administration > Default site when drift is detected --------
function renderConfigDriftCallout() {
const callout = document.querySelector("#config-drift-callout");
if (!callout) return;
const drift = (state.dashboard?.attention || []).some(item => item.kind === "drift");
callout.hidden = !drift;
}
setInterval(renderConfigDriftCallout, 1000);
document.addEventListener("click", async event => {
const button = event.target.closest("#config-resync");
if (!button) return;
button.disabled = true;
try {
await api("/api/gateway/resync", { method: "POST" });
toast("Gateway configuration re-synced.");
await refresh();
renderConfigDriftCallout();
} catch (error) { toast(error.message); } finally { button.disabled = false; }
});
+13 -12
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -35,6 +35,7 @@ header{display:flex;align-items:flex-end;justify-content:space-between;gap:var(-
h1{font-size:clamp(2rem,4vw,3.2rem);letter-spacing:-.045em;margin:0 0 var(--space-2);line-height:1}
h2{letter-spacing:-.025em}
.muted{color:var(--muted);margin:0;line-height:1.6}
.warning-text{color:var(--warning);margin:0;line-height:1.6}
.button{border:1px solid transparent;border-radius:var(--radius-2xs);padding:11px var(--space-4);color:var(--text);font-weight:720;cursor:pointer}
.button:disabled{opacity:.55;cursor:wait}
.primary{background:var(--green);color:var(--on-green);box-shadow:0 10px 30px rgba(var(--green-rgb),.13)}