diff --git a/docs/architecture.md b/docs/architecture.md index 0e99d49..36470a5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -184,7 +184,7 @@ Embedded **Hono** sub-app at `/admin` (`src/admin/`), server-rendered HTML via ` - **Auth:** one `ADMIN_SECRET` password. `POST /admin/login` is rate-limited per client IP (dedicated `LOGIN_LIMITER`, 10/60s, fail-open) and compared in constant time (hono's `timingSafeEqual`, which hashes both sides with SHA-256); failures are logged. Success sets a signed cookie via `hono/cookie` (`setSignedCookie`: HMAC-SHA-256 over the issue timestamp, `Path=/admin; HttpOnly; Secure; SameSite=Strict; Max-Age=86400`). A middleware guards every `/admin/*` route except login; `getSignedCookie` verifies in constant time (`crypto.subtle.verify`) and the guard re-checks the 24h age server-side, so a client ignoring `Max-Age` gains nothing. Tampered/expired cookies are pinned by negative-path tests. - **CRUD:** `GET`, `POST`, `PUT`, and `DELETE` under `/admin/api/tokens`. Hash params must be 64-hex; provider scope and status are whitelisted; custom tokens need at least 12 characters. `PUT` patches status and/or expiry - a blank expiry clears it (never expires). Creation, patches, and deletes are serialized through a per-hash `TokenWriter` Durable Object whose own storage is the merge base (KV guarantees neither atomic read-modify-write nor read-your-write, so merging from a KV read let a stale expiry patch resurrect a disabled token); KV is written through for the hot path, only pre-writer hashes bootstrap from a KV read, a deletion tombstone blocks stale echoes, and recreation clears it. Creation checks for an existing hash and returns 409, but KV offers no transaction across that read and write, so concurrent identical creations are not an atomic uniqueness guarantee. -- **UI:** creation returns the plaintext once, base URLs, and an out-of-band row; `PUT` returns a replacement row; `DELETE` returns an empty 200 body. The table localizes expiry and `lastUsed`, marks expired rows, edits expiry in place (click or focus+Enter on the cell, pick a local datetime, saved as UTC ISO exactly as picked - a past pick renders as an expired row immediately; the poll pauses while an editor is focused), loads immediately, and refreshes every 120 seconds while visible. Polls and mutations share the persistent `#tokens` sync scope; in-flight rows dim but stay clickable so same-row gestures reach the writer. A different row's mutation can abort the earlier row swap, leaving that cell stale until the next poll. The immediate POST row avoids waiting for KV propagation, which can take 60 seconds or more in other locations. +- **UI:** creation returns the plaintext once, base URLs, and an out-of-band row; `PUT` returns a replacement row; `DELETE` returns an empty 200 body. The table localizes expiry and `lastUsed`, marks expired rows, edits expiry in place (click or focus+Enter on the cell, pick a local datetime, saved as UTC ISO when the editor closes with a changed value (per-segment change events must not commit half-edited values); a picker "Today" fill snaps to 23:59 visibly in the field before submit while hand-typed times are untouched; a past value renders as an expired row immediately; the poll pauses while an editor is focused), loads immediately, and refreshes every 120 seconds while visible. Polls and mutations share the persistent `#tokens` sync scope; in-flight rows dim but stay clickable so same-row gestures reach the writer. A different row's mutation can abort the earlier row swap, leaving that cell stale until the next poll. The immediate POST row avoids waiting for KV propagation, which can take 60 seconds or more in other locations. - **Errors surface:** a body-level `hx-on::response-error` writes failures into a flash div (htmx swaps nothing on non-2xx by default - previously a wrong password or an expired session silently no-opped), and a 401 mid-session bounces back to the login page. ## 12. Real key handling diff --git a/src/admin/views.ts b/src/admin/views.ts index 91c9dd3..c7c2a53 100644 --- a/src/admin/views.ts +++ b/src/admin/views.ts @@ -38,10 +38,10 @@ tr.empty:not(:only-child){display:none} .notice code{display:block;background:#04140c;padding:8px 10px;border-radius:6px;margin-top:6px;word-break:break-all} .copy{cursor:pointer} .copy.copied::after{content:"✓";float:right;color:#74e0bb} -.edit{background:none;border:0;padding:0;font:inherit;cursor:pointer} +.edit{background:none;border:0;padding:0;font:inherit;cursor:pointer;width:170px;text-align:left} tr.htmx-request button{opacity:.5} .edit:hover{text-decoration:underline dotted} -td input[type=datetime-local]{width:auto;padding:4px 6px;font-size:12px} +td input[type=datetime-local]{width:170px;padding:4px 6px;font-size:12px} .danger{color:#f08a8a;border-color:#5c2a2a} `; @@ -93,7 +93,7 @@ export const tokenRow = (r: TokenRow) => { aria-label="expiry" style="display:none" hx-put="/admin/api/tokens/${r.hash}" - hx-trigger="change" + hx-trigger="commit" /> ${localTime(r.lastUsed)} @@ -192,8 +192,9 @@ export const dashboardPage = () => html` hx-on::send-error="document.getElementById('flash').textContent = 'network error - proxy unreachable'" hx-on::after-request="if (event.detail.successful && event.detail.requestConfig.verb !== 'get') document.getElementById('flash').textContent = ''" hx-on::after-settle="for (const t of document.querySelectorAll('time[datetime]')) t.textContent = new Date(t.getAttribute('datetime')).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })" - hx-on:click="const c = event.target.closest('code.copy'); if (c) navigator.clipboard.writeText(c.textContent).then(() => { c.classList.add('copied'); setTimeout(() => c.classList.remove('copied'), 1000) }); const e = event.target.closest('button.edit'); if (e) { htmx.trigger('#tokens', 'htmx:abort'); const i = e.nextElementSibling; e.style.display = 'none'; i.style.display = ''; const d = new Date(e.dataset.iso); i.value = e.dataset.iso ? new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 16) : ''; try { i.showPicker() } catch {} i.focus() }" - hx-on:focusout="const t = event.target; if (t.matches('#rows input[type=datetime-local]')) { t.style.display = 'none'; t.previousElementSibling.style.display = '' }" + hx-on:click="const c = event.target.closest('code.copy'); if (c) navigator.clipboard.writeText(c.textContent).then(() => { c.classList.add('copied'); setTimeout(() => c.classList.remove('copied'), 1000) }); const o = document.querySelector('#rows .editing'); if (o && !o.parentElement.contains(event.target)) o.dispatchEvent(new Event('focusout', { bubbles: true })); const e = event.target.closest('button.edit'); if (e) { htmx.trigger('#tokens', 'htmx:abort'); const i = e.nextElementSibling; e.style.display = 'none'; i.style.display = ''; i.classList.add('editing'); const d = new Date(e.dataset.iso); i.value = e.dataset.iso ? new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 16) : ''; i.dataset.prefill = i.value; i.focus(); i.offsetWidth; try { i.showPicker() } catch {} }" + hx-on:input="const t = event.target; if (t.type === 'datetime-local' && t.value.slice(11) === new Date().toTimeString().slice(0, 5)) { t.value = t.value.slice(0, 11) + '23:59'; t.dataset.snap = '1'; t.blur(); try { t.showPicker() } catch {} }" + hx-on:focusout="const t = event.target; if (t.matches('#rows input[type=datetime-local]')) { if (t.dataset.snap) { delete t.dataset.snap; return } if (t.value !== t.dataset.prefill) htmx.trigger(t, 'commit'); t.classList.remove('editing'); t.style.display = 'none'; t.previousElementSibling.style.display = '' }" hx-on::config-request="const p = event.detail.parameters; if (p.expiresAt) p.expiresAt = new Date(p.expiresAt).toISOString()" >
@@ -238,7 +239,7 @@ export const dashboardPage = () => html`

Tokens

-
+
Loading…
diff --git a/test/admin.test.ts b/test/admin.test.ts index f862763..e8a99bc 100644 --- a/test/admin.test.ts +++ b/test/admin.test.ts @@ -176,7 +176,7 @@ describe("admin token CRUD", () => { expect(table).toContain('datetime="2030-01-01T00:00:00.000Z"'); expect(table).toContain("2030-01-01 00:00 UTC"); // Each row carries a click-to-edit expiry editor (behavior is delegated at the body). - expect(table).toContain('hx-trigger="change"'); + expect(table).toContain('hx-trigger="commit"'); expect(table).toContain('data-iso="2030-01-01T00:00:00.000Z"'); expect(table).toContain('hx-indicator="closest tr"'); @@ -368,7 +368,7 @@ describe("admin token CRUD", () => { const cookie = await login(); const page = await (await call("/admin", { headers: { cookie } })).text(); expect(page).toContain( - "every 120s [document.visibilityState==='visible' && document.activeElement?.type !== 'datetime-local']", + "every 120s [document.visibilityState==='visible' && !document.querySelector('.editing')]", ); // Pin the hash so HTMX upgrades must update SRI deliberately. expect(page).toContain( @@ -390,8 +390,13 @@ describe("admin token CRUD", () => { expect(page).toContain("code.copy"); expect(page).toContain("navigator.clipboard.writeText"); expect(page).toContain('name="label" placeholder="alice-laptop" required'); - // One body-level converter serves every datetime-local; values save exactly as picked. + // One body-level converter serves every datetime-local. expect(page).toContain("p.expiresAt = new Date(p.expiresAt).toISOString()"); + // Input-time snap: a picker "Today" fill becomes 23:59 in the field; typed times don't match. + expect(page).toContain("t.value.slice(0, 11) + '23:59'"); + expect(page).toContain( + "t.dataset.snap = '1'; t.blur(); try { t.showPicker() } catch {}", + ); // The poll and row mutations share one persistent sync scope (in-flight polls // must never overwrite a newer row swap). expect(page).toContain('id="tokens" hx-sync="this:drop"');