diff --git a/README.md b/README.md index 5c262f5..54b7a78 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Docker Architectures Caddy - Version + Version

Why Site Gateway · diff --git a/ROADMAP.md b/ROADMAP.md index fab5a9b..0b0bdfe 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -166,3 +166,5 @@ Roughly in priority order: `v0.16.19` finishes the API Access tab's alignment with Users and Groups: the "Create token" button now lives in the shared top-right header button used by every other create action instead of its own row inside the panel, and the panel-heading text ("Programmatic access / API access tokens / Issue bearer tokens...") has been removed the same way it was for Groups in v0.16.15, since the tab button's own label already says what the section is -- the stat bar is now the first thing in the panel. Also walked the in-app Documentation view and brought it current with everything shipped since it was last substantively updated: added a full API Access section (creating a token, scope, expiry, the one-time reveal, revoking, and automatic revocation when an issuing administrator's password changes or account is disabled), corrected the Performance section's per-route table description to drop the removed per-row error-count badge and instead document the pinned column headers and the "Not configured" chip added in v0.16.16, and added an API Access entry to the documentation sidebar's contents list. `v0.16.20` audits role enforcement across the app after a run of Administration changes and fixes three places where the frontend showed a control the backend would actually reject for Standard Users and Viewers: the Dashboard's "Resync now" button (Needs Attention drift tile) and the Certificates page's "Run certificate check" button are now hidden for anyone who isn't an administrator, since both call administrator-only endpoints. The Access List editor's "Allowed groups" section -- previously always rendered with an empty `state.groups`, so a Standard User just saw a false "No groups have been created yet." -- now shows an accurate note pointing to an administrator instead, both when creating a new Access List and editing an existing one. Also corrected the in-app documentation: the Users & Groups role summary previously said Viewer "can inspect everything," which wasn't true -- Administration (System, Users, Groups, Backups, API Access, Logs & Retention, Danger Zone) is completely invisible to Viewer, the same as Standard, not merely read-only. The role summary, the Access Lists doc's Groups field, the Certificates doc's Check now section, and the Dashboard doc's Resync now section all now say plainly which actions are administrator-only. + +`v0.16.21` gives API Access tokens full parity with every other tile type. Tokens now get a real, persistent custom icon -- a new `icon`/`icon_slug` column pair on the `api_tokens` table (added via an idempotent `ALTER TABLE`, safe on existing installs), matching storage functions, and a `tokens` branch in the shared icon-upload/search/URL routes -- plus the same "•••" card menu every other tile has, with Change icon and Revoke token moved into it. While wiring this up, found and fixed a real pre-existing bug: Groups' own "Change icon" menu item has been broken since it shipped, because the frontend code that actually saves an icon never mapped the `groups` kind to anything and silently fell through to the Hosted Sites endpoint, which always 404'd. Also finished the rest of the API Access fix list: the Full access/Read-only counts in the summary bar now only tally active tokens, so they stay consistent with the Active/Revoked split instead of quietly including tokens that can no longer authenticate; a "Hide revoked" toggle sits at the right of that same summary bar for anyone who's revoked enough tokens over time that the tile grid gets cluttered; and the documentation now explains why a revoked token can't be deleted outright -- the record stays for the same accountability reasons the Audit log is never editable. diff --git a/package.json b/package.json index bda0db1..e735b23 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "site-gateway", - "version": "0.16.20", + "version": "0.16.21", "private": true, "description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.", "type": "module", diff --git a/src/public/app.js b/src/public/app.js index 1de79f6..abf54c5 100644 --- a/src/public/app.js +++ b/src/public/app.js @@ -773,12 +773,13 @@ $("#icon-search").addEventListener("input", event => { } catch (error) { $("#icon-results").innerHTML = ""; $("#icon-error").textContent = error.message; } }, 280); }); +async function refreshIconTargetView() { await refresh(); if (state.iconTarget?.kind === "tokens") await window.loadApiTokens?.(); } async function saveIcon(slug) { - if (!state.iconTarget) return; const base = state.iconTarget.kind === "proxy" ? "proxies" : state.iconTarget.kind === "redirect" ? "redirects" : state.iconTarget.kind === "streams" ? "streams" : state.iconTarget.kind === "access" ? "access-lists" : state.iconTarget.kind === "users" ? "users" : "sites"; + if (!state.iconTarget) return; const base = state.iconTarget.kind === "proxy" ? "proxies" : state.iconTarget.kind === "redirect" ? "redirects" : state.iconTarget.kind === "streams" ? "streams" : state.iconTarget.kind === "access" ? "access-lists" : state.iconTarget.kind === "users" ? "users" : state.iconTarget.kind === "groups" ? "groups" : state.iconTarget.kind === "tokens" ? "tokens" : "sites"; $("#icon-error").textContent = ""; try { await api(`/api/${base}/${state.iconTarget.id}/icon`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ slug }) }); - $("#icon-dialog").close(); await refresh(); toast(slug ? "Icon saved locally." : "Two-letter fallback restored."); + $("#icon-dialog").close(); await refreshIconTargetView(); toast(slug ? "Icon saved locally." : "Two-letter fallback restored."); } catch (error) { $("#icon-error").textContent = error.message; } } $("#icon-results").addEventListener("click", event => { const choice = event.target.closest("[data-slug]"); if (choice) saveIcon(choice.dataset.slug); }); @@ -786,13 +787,13 @@ $("#reset-icon").addEventListener("click", event => { event.preventDefault(); sa $("#icon-upload").addEventListener("change", async event => { const file = event.target.files[0]; if (!file || !state.iconTarget) return; const data = new FormData(); data.append("icon", file); $("#icon-error").textContent = ""; - try { const base = state.iconTarget.kind === "proxy" ? "proxies" : state.iconTarget.kind === "redirect" ? "redirects" : state.iconTarget.kind === "streams" ? "streams" : state.iconTarget.kind === "access" ? "access-lists" : state.iconTarget.kind === "users" ? "users" : "sites"; await api(`/api/${base}/${state.iconTarget.id}/icon`, { method: "POST", body: data }); $("#icon-dialog").close(); await refresh(); toast("Custom icon saved locally."); } + try { const base = state.iconTarget.kind === "proxy" ? "proxies" : state.iconTarget.kind === "redirect" ? "redirects" : state.iconTarget.kind === "streams" ? "streams" : state.iconTarget.kind === "access" ? "access-lists" : state.iconTarget.kind === "users" ? "users" : state.iconTarget.kind === "groups" ? "groups" : state.iconTarget.kind === "tokens" ? "tokens" : "sites"; await api(`/api/${base}/${state.iconTarget.id}/icon`, { method: "POST", body: data }); $("#icon-dialog").close(); await refreshIconTargetView(); toast("Custom icon saved locally."); } catch (error) { $("#icon-error").textContent = error.message; } }); $("#save-icon-url").addEventListener("click", async () => { const value = $("#icon-url").value.trim(); if (!/^https:\/\//i.test(value)) { $("#icon-error").textContent = "Enter a trusted HTTPS image URL."; return; } - if (!state.iconTarget) return; const base = state.iconTarget.kind === "proxy" ? "proxies" : state.iconTarget.kind === "redirect" ? "redirects" : state.iconTarget.kind === "streams" ? "streams" : state.iconTarget.kind === "access" ? "access-lists" : state.iconTarget.kind === "users" ? "users" : "sites"; - try { await api(`/api/${base}/${state.iconTarget.id}/icon`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url: value }) }); $("#icon-dialog").close(); await refresh(); toast("Icon URL saved."); } + if (!state.iconTarget) return; const base = state.iconTarget.kind === "proxy" ? "proxies" : state.iconTarget.kind === "redirect" ? "redirects" : state.iconTarget.kind === "streams" ? "streams" : state.iconTarget.kind === "access" ? "access-lists" : state.iconTarget.kind === "users" ? "users" : state.iconTarget.kind === "groups" ? "groups" : state.iconTarget.kind === "tokens" ? "tokens" : "sites"; + try { await api(`/api/${base}/${state.iconTarget.id}/icon`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url: value }) }); $("#icon-dialog").close(); await refreshIconTargetView(); toast("Icon URL saved."); } catch (error) { $("#icon-error").textContent = error.message; } }); diff --git a/src/public/features.js b/src/public/features.js index 7f4d0cd..8e11431 100644 --- a/src/public/features.js +++ b/src/public/features.js @@ -356,18 +356,29 @@ async function loadApiTokens() { const list = document.querySelector("#api-token-list"); if (!list) return; const summary = document.querySelector("#api-token-summary"); try { - const tokens = await api("/api/tokens"); + const tokens = state.apiTokens = await api("/api/tokens"); if (summary) { + // Full access / Read-only only count ACTIVE tokens -- a revoked token's scope no longer + // means anything operationally, so folding it into these counts would make them disagree + // with Active + Revoked, which already account for every token issued. const counts = { active: 0, revoked: 0, full: 0, readOnly: 0 }; - for (const token of tokens) { if (token.revoked) counts.revoked += 1; else counts.active += 1; if (token.scope === "read-only") counts.readOnly += 1; else counts.full += 1; } - summary.innerHTML = [["Active", counts.active, "#62e6a7"], ["Revoked", counts.revoked, "#ff7185"], ["Full access", counts.full, "#6ea8ff"], ["Read-only", counts.readOnly, "#b58cff"]].map(([label, count, color]) => `

${count}${label}
`).join(""); + for (const token of tokens) { if (token.revoked) { counts.revoked += 1; continue; } counts.active += 1; if (token.scope === "read-only") counts.readOnly += 1; else counts.full += 1; } + summary.innerHTML = [["Active", counts.active, "#62e6a7"], ["Revoked", counts.revoked, "#ff7185"], ["Full access", counts.full, "#6ea8ff"], ["Read-only", counts.readOnly, "#b58cff"]].map(([label, count, color]) => `
${count}${label}
`).join("") + ``; } - list.innerHTML = tokens.length ? tokens.map(token => { + const visibleTokens = state.hideRevokedTokens ? tokens.filter(token => !token.revoked) : tokens; + list.innerHTML = visibleTokens.length ? visibleTokens.map(token => { const status = apiTokenStatus(token); - return `
TK

${extendedEscape(token.name)}

${extendedEscape(token.prefix)}… · ${token.scope === "read-only" ? "Read-only" : "Full access"}

${extendedEscape(token.ownerUsername || "unknown")} · created ${extendedEscape(formatTime(token.createdAt))}

${token.lastUsedAt ? `Last used ${extendedEscape(formatTime(token.lastUsedAt))}` : "Never used"}${token.expiresAt ? ` · expires ${extendedEscape(formatTime(token.expiresAt))}` : ""}

`; - }).join("") : '

No API tokens have been issued yet.

'; + const menu = ``; + return `
${featureIcon(token, "TK")}
${menu}

${extendedEscape(token.name)}

${extendedEscape(token.prefix)}… · ${token.scope === "read-only" ? "Read-only" : "Full access"}

${extendedEscape(token.ownerUsername || "unknown")} · created ${extendedEscape(formatTime(token.createdAt))}

${token.lastUsedAt ? `Last used ${extendedEscape(formatTime(token.lastUsedAt))}` : "Never used"}${token.expiresAt ? ` · expires ${extendedEscape(formatTime(token.expiresAt))}` : ""}

`; + }).join("") : `

${tokens.length ? "No active tokens — uncheck \u201cHide revoked\u201d to see revoked tokens." : "No API tokens have been issued yet."}

`; } catch (error) { list.innerHTML = `

${extendedEscape(error.message)}

`; } } +document.addEventListener("change", event => { + const checkbox = event.target.closest("#api-token-hide-revoked"); if (!checkbox) return; + state.hideRevokedTokens = checkbox.checked; + loadApiTokens(); +}); +window.loadApiTokens = loadApiTokens; function renderApiTokensPanel() { if (state.user?.role !== "administrator") return; const tabs = document.querySelector(".admin-tabs"), users = document.querySelector('[data-admin-panel="users"]'); @@ -412,6 +423,21 @@ async function openCreateApiTokenDialog() { showIssuedApiToken(result); } catch (error) { toast(error.message, "error"); } } +// Menu-open/close toggle for API token cards, matching the same pattern used for Groups' menu. +document.addEventListener("click", event => { + const button = event.target.closest("#api-token-list .api-token-card .menu-button"); if (!button) return; + const card = button.closest(".api-token-card"); const opening = !card.classList.contains("menu-open"); + document.querySelectorAll("#api-token-list .api-token-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); +document.addEventListener("click", event => { + const button = event.target.closest('#api-token-list [data-token-action="icon"]'); if (!button) return; + const row = button.closest("[data-token-id]"); if (!row) return; + event.preventDefault(); event.stopImmediatePropagation(); + row.classList.remove("menu-open"); + openIconPicker("tokens", row.dataset.tokenId); +}, true); document.addEventListener("click", async event => { const button = event.target.closest('[data-token-action="revoke"]'); if (!button) return; const row = button.closest("[data-token-id]"); if (!row) return; diff --git a/src/public/index.html b/src/public/index.html index 401b84a..385d660 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -8,7 +8,7 @@ Site Gateway - + - +