Public: anyone can read this; the crew can change it.
Settings › Passkeys speaks the home page's language #9736
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9726-settings-passkeys-speaks-the-home-page-s → main 2 commits fork
5 files
changed +102
−46
docs/design/areas/passkey.md +6
| @@ -4,3 +4,9 @@ A passkey control (sign-in, sign-up, claim, first admin, the everyday account, a | ||
| 4 | 4 | field: `passkeyNote()` writes a `.field` holding one line, quiet `.muted` text that `passkey-client.ts` swaps for |
| 5 | 5 | `.error` (the class named by `data-fail`) on a failure, with `role="alert"`. A page that re-renders with a failure |
| 6 | 6 | writes the same shape on the server. |
| 7 | ||
| 8 | Settings › Passkeys (`passkeysPage()` in `src/web/pages/passkeys.ts`) adds no component class. As on the home page it is | |
| 9 | headings over rows: a `<section class="settings-block">` "Your passkeys" with one muted sentence and a `.setting-list` of | |
| 10 | `row()`s (the name; when it was added and last used; a Remove that goes through `/-/confirm`, so it works with JavaScript | |
| 11 | off), then a second `settings-block`, "New passkey", holding the name field and the add button. No card, no table, no | |
| 12 | empty-state box: with none, one plain sentence says so. `test/passkeys-page.test.ts` holds the shape. |
src/web/pages/passkeys.ts +31 −42
| @@ -35,7 +35,7 @@ import { html } from "../html.ts"; | ||
| 35 | 35 | import { flashTo, page } from "../layout.ts"; |
| 36 | 36 | import { passkeyJs } from "../passkey-client.ts"; |
| 37 | 37 | import { passkeyNote } from "../passkey-state.ts"; |
| 38 | import { card, empty, confirmAction, time } from "../ui.ts"; | |
| 38 | import { confirmAction, row, time } from "../ui.ts"; | |
| 39 | 39 | import { accountShell } from "./account.ts"; |
| 40 | 40 | import { safeNext, sessionCookie, signinAllowed } from "./site.ts"; |
| 41 | 41 | import { PASSKEY_EXPIRED, PASSKEY_REJECTED, PASSKEY_TAKEN } from "../passkey-words.ts"; |
| @@ -74,47 +74,36 @@ export function passkeysPage(ctx: Ctx): Response { | ||
| 74 | 74 | const user = ctx.auth.user; |
| 75 | 75 | const keys = timePhase(ctx.request, "passkeys", () => listPasskeys(ctx.app.db, user.id)); |
| 76 | 76 | const rendering = performance.now(); |
| 77 | const body = html`<p class="muted small mb"> | |
| 78 | A passkey signs you in with a fingerprint, face or device PIN instead of | |
| 79 | a password. Add one per device; each can be removed on its own. | |
| 80 | </p> | |
| 81 | <p class="muted small mb" id="passkey-why"> | |
| 82 | Why: the forge signs you in itself, so your account does not depend on | |
| 83 | another service being up or trusted. With a passkey, you can sign in here | |
| 84 | even if the platform sign-in is switched off. | |
| 85 | </p> | |
| 86 | ${keys.length === 0 | |
| 87 | ? empty({ icon: "key", title: "No passkeys yet", note: "Your password still works; add a passkey now, as every new account starts with one." }) | |
| 88 | : card( | |
| 89 | html`<div class="table-scroll"> | |
| 90 | <table class="table" id="passkeys"> | |
| 91 | <thead><tr><th>Name</th><th>Added</th><th>Last used</th><th></th></tr></thead> | |
| 92 | <tbody> | |
| 93 | ${keys.map( | |
| 94 | (k) => html`<tr> | |
| 95 | <td><strong>${k.name}</strong></td> | |
| 96 | <td class="muted">${time(k.createdAt)}</td> | |
| 97 | <td class="muted">${k.lastUsedAt ? time(k.lastUsedAt) : "never"}</td> | |
| 98 | <td>${confirmAction({ action: `/settings/passkeys/${k.id}/delete`, verb: "Remove", size: "sm" })}</td> | |
| 99 | </tr>`, | |
| 100 | )} | |
| 101 | </tbody> | |
| 102 | </table> | |
| 103 | </div>`, | |
| 104 | { pad: false }, | |
| 105 | )} | |
| 106 | ${card( | |
| 107 | html`<div class="field"> | |
| 108 | <label for="passkey-name">Name</label> | |
| 109 | <input id="passkey-name" name="name" maxlength="${PASSKEY_NAME_MAX}" autocomplete="off" placeholder="Laptop, phone, security key" /> | |
| 110 | </div> | |
| 111 | ${passkeyNote("passkey-add-note")} | |
| 112 | <noscript><p class="muted small">Adding a passkey needs JavaScript.</p></noscript> | |
| 113 | <div class="form-actions"> | |
| 114 | <button type="button" id="passkey-add" class="btn" data-variant="primary">Add a passkey</button> | |
| 115 | </div>`, | |
| 116 | { head: html`<h3>New passkey</h3>` }, | |
| 117 | )}`; | |
| 77 | const body = html`<section class="settings-block"> | |
| 78 | <h2>Your passkeys</h2> | |
| 79 | <p class="muted small" id="passkey-why"> | |
| 80 | A passkey signs you in with a fingerprint, face or device PIN, so your | |
| 81 | account never waits on another service; add one per device. | |
| 82 | </p> | |
| 83 | ${keys.length === 0 | |
| 84 | ? html`<p class="small mt">No passkeys yet. Your password still works; add a passkey now, as every new account starts with one.</p>` | |
| 85 | : html`<ul class="list setting-list" id="passkeys"> | |
| 86 | ${keys.map((k) => | |
| 87 | row({ | |
| 88 | title: k.name, | |
| 89 | meta: html`added ${time(k.createdAt)} · ${k.lastUsedAt ? html`last used ${time(k.lastUsedAt)}` : "never used"}`, | |
| 90 | trail: confirmAction({ action: `/settings/passkeys/${k.id}/delete`, verb: "Remove", size: "sm", label: `Remove ${k.name}` }), | |
| 91 | }), | |
| 92 | )} | |
| 93 | </ul>`} | |
| 94 | </section> | |
| 95 | <section class="settings-block"> | |
| 96 | <h2>New passkey</h2> | |
| 97 | <div class="field"> | |
| 98 | <label for="passkey-name">Name</label> | |
| 99 | <input id="passkey-name" name="name" maxlength="${PASSKEY_NAME_MAX}" autocomplete="off" placeholder="Laptop, phone, security key" /> | |
| 100 | </div> | |
| 101 | ${passkeyNote("passkey-add-note")} | |
| 102 | <noscript><p class="muted small">Adding a passkey needs JavaScript.</p></noscript> | |
| 103 | <div class="form-actions"> | |
| 104 | <button type="button" id="passkey-add" class="btn" data-variant="primary">Add a passkey</button> | |
| 105 | </div> | |
| 106 | </section>`; | |
| 118 | 107 | const response = accountShell(ctx, "passkeys", "Passkeys", body, 200, passkeyJs); |
| 119 | 108 | notePhase(ctx.request, "render", performance.now() - rendering); |
| 120 | 109 | return response; |
test/passkey-recovery.test.ts +1 −1
| @@ -122,7 +122,7 @@ describe("passkey recovery", () => { | ||
| 122 | 122 | const cookie = done!.headers.get("set-cookie")!; |
| 123 | 123 | expect(cookie).toContain("HttpOnly"); |
| 124 | 124 | const keys = await fetch(`${base}/settings/passkeys`, { headers: { cookie: cookie.split(";")[0]! } }); |
| 125 | expect(await keys.text()).toContain("<strong>Laptop</strong>"); | |
| 125 | expect(await keys.text()).toContain(`aria-label="Remove Laptop"`); | |
| 126 | 126 | |
| 127 | 127 | // Reused: refused, and no second session. |
| 128 | 128 | const again = await finish(token); |
test/passkeys-page.test.ts added +61
| @@ -0,0 +1,61 @@ | ||
| 1 | /** Settings › Passkeys is drawn as the home page draws a block: headings over rows, no boxes (#9726). */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { addPasskey } from "../src/core/passkeys.ts"; | |
| 8 | import { getUserByName } from "../src/core/users.ts"; | |
| 9 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 10 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 11 | ||
| 12 | let dataDir = ""; | |
| 13 | let app: App, servers: Servers, bearer = ""; | |
| 14 | ||
| 15 | beforeAll(async () => { | |
| 16 | dataDir = await mkdtemp(join(tmpdir(), "forge-pkpage-")); | |
| 17 | app = createApp({ dataDir, port: 0 }); | |
| 18 | servers = await serve(app); | |
| 19 | const admin = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "correct-horse-battery" }); | |
| 20 | if (!admin.ok) throw new Error(admin.error); | |
| 21 | const minted = await fetch(`${servers.url}/api/v1/tokens`, { | |
| 22 | method: "POST", | |
| 23 | headers: { "content-type": "application/json" }, | |
| 24 | body: JSON.stringify({ adminConfirm: true, username: "alice", password: "correct-horse-battery", name: "cli", scope: "admin" }), | |
| 25 | }); | |
| 26 | bearer = ((await minted.json()) as { token: string }).token; | |
| 27 | }); | |
| 28 | ||
| 29 | afterAll(async () => { | |
| 30 | servers.stop(); | |
| 31 | app.close(); | |
| 32 | await rm(dataDir, { recursive: true, force: true }); | |
| 33 | }); | |
| 34 | ||
| 35 | const main = async () => { | |
| 36 | const text = await (await fetch(`${servers.url}/settings/passkeys`, { headers: { authorization: `Bearer ${bearer}` } })).text(); | |
| 37 | return text.slice(text.indexOf("<main"), text.indexOf("</main>")); | |
| 38 | }; | |
| 39 | ||
| 40 | test("with none, the page says so in a sentence, not an empty-state box", async () => { | |
| 41 | const page = await main(); | |
| 42 | expect(page).toContain("No passkeys yet."); | |
| 43 | expect(page).not.toContain('class="card empty"'); | |
| 44 | expect(page).toContain('id="passkey-add"'); | |
| 45 | }); | |
| 46 | ||
| 47 | test("each passkey is a row with when it was added and used, and its own Remove", async () => { | |
| 48 | const alice = getUserByName(app.db, "alice")!; | |
| 49 | const added = addPasskey(app.db, alice.id, "Work <laptop>", { id: "cred-page", alg: "ES256", key: { kty: "EC", crv: "P-256", x: "x", y: "y" }, signCount: 0 }); | |
| 50 | if (!added.ok) throw new Error(added.error); | |
| 51 | const page = await main(); | |
| 52 | expect(page).toContain('<ul class="list setting-list" id="passkeys">'); | |
| 53 | expect(page).toContain('class="row-title"'); | |
| 54 | expect(page).toContain("never used"); | |
| 55 | expect(page).toContain('aria-label="Remove Work <laptop>"'); | |
| 56 | expect(page).not.toContain("Work <laptop>"); | |
| 57 | expect(page).toContain(`/-/confirm?to=${encodeURIComponent(`/settings/passkeys/${added.value}/delete`)}`); | |
| 58 | expect(page).toContain("<h2>Your passkeys</h2>"); | |
| 59 | expect(page).toContain("<h2>New passkey</h2>"); | |
| 60 | for (const boxed of ['class="card', "<table", 'class="table-scroll"']) expect(page).not.toContain(boxed); | |
| 61 | }); |
test/passkeys.test.ts +3 −3
| @@ -107,7 +107,7 @@ describe("passkeys", () => { | ||
| 107 | 107 | |
| 108 | 108 | let page = await (await fetch(`${base}/settings/passkeys`, { headers: { cookie } })).text(); |
| 109 | 109 | expect(page).toContain("Laptop"); |
| 110 | expect(page).toContain("<strong>Phone</strong>"); | |
| 110 | expect(page).toContain(`aria-label="Remove Phone"`); | |
| 111 | 111 | expect(page).toContain(`<div class="field"><span class="muted small" id="passkey-add-note" data-fail="error" role="status"></span></div>`); |
| 112 | 112 | expect(page).not.toContain("error-note"); |
| 113 | 113 | |
| @@ -115,8 +115,8 @@ describe("passkeys", () => { | ||
| 115 | 115 | const removed = await fetch(`${base}/settings/passkeys/${id}/delete`, { method: "POST", headers: { cookie }, redirect: "manual" }); |
| 116 | 116 | expect(removed.status).toBe(303); |
| 117 | 117 | page = await (await fetch(`${base}/settings/passkeys`, { headers: { cookie } })).text(); |
| 118 | expect(page).not.toContain("<strong>Laptop</strong>"); | |
| 119 | expect(page).toContain("<strong>Phone</strong>"); | |
| 118 | expect(page).not.toContain(`aria-label="Remove Laptop"`); | |
| 119 | expect(page).toContain(`aria-label="Remove Phone"`); | |
| 120 | 120 | |
| 121 | 121 | const refused = await signIn(one.d); |
| 122 | 122 | expect(refused.status).toBe(401); |