diff --git a/README.md b/README.md index df651bb..916ac76 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 dab418f..b6786ae 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -160,3 +160,5 @@ Roughly in priority order: `v0.16.16` ships a batch of fixes found in live use: the Backup type picker (in both the scheduled-backup form and the manual "Create a backup" dialog) no longer shows a long wrapped sentence as the selected value -- it now shows a short "Complete (Recommended)" / "Configuration only" label with the detail moved into the helper text beneath it, and the in-app documentation now explicitly names the "Backup type" field so it's easy to find by search. The Performance page's "Outliers / Slowest requests" section has been removed, along with the per-row error-count badge in the "Throughput by domain" table -- both added noise without being worth the space for most setups. That table's column headers now stay pinned while scrolling instead of scrolling out of view. Rows for domains with no matching Hosted Site, Proxy Host, or Redirect Host are now badged "Not configured" -- that table is built from Caddy's raw access log, so it always included every hostname a request was ever seen for (including scanner/bot traffic hitting made-up subdomains that fall through to the Default Site handler), not just domains you've actually configured; the badge makes that distinction visible instead of leaving it to guesswork. Finally, the Administration Users tab no longer flashes "No users found." for a moment before the user list has actually loaded. `v0.16.17` fixes the Backup type helper text showing both the Complete and Configuration-only explanations stacked on top of each other on page load or refresh, instead of just the one matching the currently selected option. Root cause: the help text only ever updated on the select's `change` event -- but `renderBackups()` sets the select's value from saved settings on every render without firing a `change` event, so the static placeholder text (which briefly held both sentences as a v0.16.16 authoring mistake) never got replaced until you manually touched the dropdown. Factored the text-selection logic into its own function and call it both on `change` and every time `renderBackups()` runs, so it always matches the select's actual current value. + +`v0.16.18` reworks the API Access tab to match the Users and Groups tabs' layout instead of the old plain data-row list: tokens are now shown as tiles in the same card grid Hosted Sites/Users/Groups use, and a stat bar above them breaks down Active/Revoked and Full access/Read-only counts at a glance. No behavior changed -- Revoke still works the same way it always has (a one-way action; there is no re-enable, since a revoked token's secret is treated as compromised). An earlier idea of adding an enable/disable toggle was dropped once it became clear that would require adding real token-reactivation support on the backend, a deliberate security-posture change rather than a layout fix. diff --git a/package.json b/package.json index a32b265..594532f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "site-gateway", - "version": "0.16.17", + "version": "0.16.18", "private": true, "description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.", "type": "module", diff --git a/src/public/features.js b/src/public/features.js index 5ad0fe1..d946bb6 100644 --- a/src/public/features.js +++ b/src/public/features.js @@ -354,11 +354,17 @@ function apiTokenStatus(token) { } 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"); + if (summary) { + 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(""); + } list.innerHTML = tokens.length ? tokens.map(token => { const status = apiTokenStatus(token); - return `
${extendedEscape(token.name)}${extendedEscape(status.label)} · ${extendedEscape(token.ownerUsername || "unknown")}
${extendedEscape(token.prefix)}…${token.scope === "read-only" ? "Read-only" : "Full access"}
${extendedEscape(formatTime(token.createdAt))}${token.lastUsedAt ? `Last used ${extendedEscape(formatTime(token.lastUsedAt))}` : "Never used"}${token.expiresAt ? ` · expires ${extendedEscape(formatTime(token.expiresAt))}` : ""}
${token.revoked ? "" : ''}
`; + 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.

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

${extendedEscape(error.message)}

`; } } @@ -372,7 +378,7 @@ function renderApiTokensPanel() { if (!panel) { panel = document.createElement("section"); panel.dataset.adminPanel = "api"; panel.className = "settings-panel hidden"; users.parentElement.append(panel); } if (panel.dataset.ready) return; panel.dataset.ready = "1"; - panel.innerHTML = '

Programmatic access

API access tokens

Issue bearer tokens for scripts and integrations. A token acts as the administrator who issued it, and is shown in full only once. Changing that administrator’s password, or disabling their account, revokes every token they issued.

Open this tab to load API tokens.

'; + panel.innerHTML = '

Programmatic access

API access tokens

Issue bearer tokens for scripts and integrations. A token acts as the administrator who issued it, and is shown in full only once. Changing that administrator’s password, or disabling their account, revokes every token they issued.

Open this tab to load API tokens.

'; 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)); diff --git a/src/public/index.html b/src/public/index.html index 32b3c13..f1f957c 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -8,7 +8,7 @@ Site Gateway - + - + diff --git a/src/public/styles.css b/src/public/styles.css index 9a4f630..acbe864 100644 --- a/src/public/styles.css +++ b/src/public/styles.css @@ -960,10 +960,10 @@ select{appearance:none!important;-webkit-appearance:none!important;background-re .container-choice.unreachable:hover{border-color:var(--line)} /* API access tokens */ -.api-token-row{grid-template-columns:auto minmax(150px,1.3fr) minmax(110px,.9fr) minmax(130px,1fr) minmax(150px,1fr)} -.api-token-row.revoked{opacity:.6} +.api-token-card{min-height:0} +.api-token-card.revoked{opacity:.6} +.api-token-card .address{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace} .api-token-secret{display:block;margin-top:var(--space-3);padding:var(--space-3) var(--space-4);border:1px solid var(--line);border-radius:var(--radius-sm);background:rgba(var(--bg-rgb),.4);color:var(--text);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:var(--font-size-sm);line-height:1.6;word-break:break-all} -.api-token-chip{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:var(--font-size-sm)} /* Backup history timeline */ .backup-history-list{display:flex;flex-direction:column;gap:var(--space-2);margin-top:var(--space-4);max-height:min(46vh,520px);overflow:auto}