Compare commits

...

2 Commits

6 changed files with 14 additions and 8 deletions
+1 -1
View File
@@ -12,7 +12,7 @@
<img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?logo=docker&logoColor=white">
<img alt="Architectures" src="https://img.shields.io/badge/platform-amd64%20%7C%20arm64-5965F2">
<img alt="Caddy" src="https://img.shields.io/badge/powered%20by-Caddy-1F88C0">
<img alt="Version" src="https://img.shields.io/badge/version-0.16.13-62E6A7">
<img alt="Version" src="https://img.shields.io/badge/version-0.16.15-62E6A7">
</p>
<p>
<a href="#why-site-gateway">Why Site Gateway</a> ·
+4
View File
@@ -152,3 +152,7 @@ Roughly in priority order:
`v0.16.12` fixes the Backup & Restore tab's "Backup history" section getting stuck on "Loading backup history…" indefinitely. Root cause: `renderBackupHistory()` is only invoked from the periodic `refresh()` cycle, and it bailed out before fetching whenever the Backups tab wasn't the currently active admin tab at that moment — but switching admin tabs only toggles CSS visibility, it never re-triggers a fetch. So if a refresh cycle landed while you were on a different tab, the placeholder text was left in place with no later refresh ever replacing it. Same bug class as v0.16.3's System-tab fix; resolved the same way, by always populating the section's content regardless of the panel's current visibility.
`v0.16.13` fixes three Administration/Logs layout inconsistencies found in live use. First, the Groups tab was missing the stat-count bar ("N Administrators · N Standard Users · ...") that every other listing tab (Users) shows, making it look unfinished by comparison — added a matching Enabled/Disabled group count bar. Second, "Create group" lived in its own row inside the Groups panel instead of the shared top-right header button used by "Create user," "New hosted site," and every other creation action — moved it into that same header slot so it behaves and aligns like all the others. Third, the Logs page's "Refresh logs" button (and Performance's and Certificates') sat directly against the first box below it with no gap, because those three pages are the only ones with no status-summary bar to provide the usual spacing under the page header — added a matching top margin so they're consistent with every other page.
`v0.16.14` fixes the Create button (Create user / Create group) disappearing or showing the wrong label after switching Administration tabs. Root cause: a leftover click handler on the admin tabs bar, written before Groups had a Create button at all, still hard-coded "hide the shared Create button unless the tab is Users" and manually poked tab-active/panel-visibility classes directly -- completely independent of and out of sync with the real logic added in v0.16.13's `render()`. Since that same handler also fires when the app restores your last-viewed tab on page load/refresh, it would immediately stomp the button back to the wrong state. Replaced both old handlers with one that simply updates state and calls the real `render()`, so there's a single source of truth for tab switching instead of two handlers disagreeing with each other.
`v0.16.15` cleans up the Groups tab's layout: removed the redundant "Groups / Organize users for Access List permissions." heading, since the tab button and admin panel description already say what the tab is, and it was adding a bare, boxless line of text found nowhere else in Administration once the tab's own Create button moved to the shared header. The Enabled/Disabled stat bar is now the first thing in the panel, structurally matching how the Users tab's own stat bar is positioned. Also added top spacing between the Administration page's subtitle and the row of tab buttons (System, Users, Groups, ...) below it -- that gap had never been set, so the tabs bar sat flush against the subtitle text.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "site-gateway",
"version": "0.16.13",
"version": "0.16.15",
"private": true,
"description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.",
"type": "module",
+5 -3
View File
@@ -211,8 +211,10 @@ document.querySelector("#access-list").addEventListener("click", event => { if (
// --- 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}`); });
// A single handler updates state and defers to the real render() for tab-active classes, panel
// visibility, and the shared Create button's visibility/text -- render() is the one place that
// logic lives, so this never drifts out of sync with it the way two separate DOM-poking handlers did.
document.querySelector(".admin-tabs").addEventListener("click", event => { const button = event.target.closest("[data-admin-tab]"); if (!button) return; state.adminTab = button.dataset.adminTab; render(); });
if (state.adminTab && state.view === "administration") document.querySelector(`[data-admin-tab="${state.adminTab}"]`)?.click();
@@ -258,7 +260,7 @@ document.addEventListener("change", async event => { const checkbox = event.targ
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); } const enabledCount = state.groups.filter(group => group.enabled !== false).length, disabledCount = state.groups.length - enabledCount; const summaryHtml = '<div id="group-summary" class="summary">' + [["Enabled", enabledCount, "#62e6a7"], ["Disabled", disabledCount, "#ff7185"]].map(([label, count, color]) => `<div><span class="status-dot" style="${count ? `background:${color}` : ""}"></span><strong>${count}</strong><span>${label}</span></div>`).join("") + '</div>'; panel.innerHTML = '<div class="panel-heading"><div><h2>Groups</h2><p class="muted">Organize users for Access List permissions.</p></div></div>' + summaryHtml + (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 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); } const enabledCount = state.groups.filter(group => group.enabled !== false).length, disabledCount = state.groups.length - enabledCount; const summaryHtml = '<div id="group-summary" class="summary">' + [["Enabled", enabledCount, "#62e6a7"], ["Disabled", disabledCount, "#ff7185"]].map(([label, count, color]) => `<div><span class="status-dot" style="${count ? `background:${color}` : ""}"></span><strong>${count}</strong><span>${label}</span></div>`).join("") + '</div>'; panel.innerHTML = summaryHtml + (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></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);
+2 -2
View File
@@ -8,7 +8,7 @@
<title>Site Gateway</title>
<meta name="description" content="Host sites, proxy services, and manage HTTPS from one simple dashboard.">
<link rel="icon" type="image/png" href="/site-gateway-icon-approved.png">
<link rel="stylesheet" href="/styles.css?v=0.16.13">
<link rel="stylesheet" href="/styles.css?v=0.16.15">
</head>
<!-- ================================================================
@@ -439,6 +439,6 @@
<div id="toast" class="toast" role="status"></div>
<div id="update-banner" class="update-banner hidden" role="status"><span>A new version of Site Gateway is available.</span><div class="update-banner-actions"><button id="update-banner-refresh" class="button primary">Refresh</button><button id="update-banner-dismiss" class="text-button">Dismiss</button></div></div>
<!-- App scripts: core (app.js) then extended views/admin (features.js) -->
<script src="/app.js?v=0.16.13" defer></script><script src="/features.js?v=0.16.13" defer></script><script src="/select-enhance.js?v=0.16.13" defer></script>
<script src="/app.js?v=0.16.15" defer></script><script src="/features.js?v=0.16.15" defer></script><script src="/select-enhance.js?v=0.16.15" defer></script>
</body>
</html>
+1 -1
View File
@@ -336,7 +336,7 @@ header{align-items:flex-end}
.aside-footer{margin-top:0}
/* Administration tab bar */
.admin-tabs{display:flex;gap:7px;margin-bottom:22px;padding:5px;border:1px solid var(--line);border-radius:var(--radius-md);background:var(--panel);overflow:auto}
.admin-tabs{display:flex;gap:7px;margin:var(--space-7) 0 22px;padding:5px;border:1px solid var(--line);border-radius:var(--radius-md);background:var(--panel);overflow:auto}
.admin-tabs button{width:auto;white-space:nowrap;padding:9px var(--space-3);border:0;border-radius:8px;background:transparent;color:var(--muted);cursor:pointer}
.admin-tabs .tab-active{background:var(--panel2);color:var(--text)}