Public: anyone can read this; the crew can change it.

Settings › Passkeys speaks the home page's language #9736

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/9726-settings-passkeys-speaks-the-home-page-s → main 2 commits fork

  • Merged. scout/9726-settings-passkeys-speaks-the-home-page-s was merged into main as 4d68fe1.

Review

Sign in to review this change.
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
44 field: `passkeyNote()` writes a `.field` holding one line, quiet `.muted` text that `passkey-client.ts` swaps for
55 `.error` (the class named by `data-fail`) on a failure, with `role="alert"`. A page that re-renders with a failure
66 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";
3535 import { flashTo, page } from "../layout.ts";
3636 import { passkeyJs } from "../passkey-client.ts";
3737 import { passkeyNote } from "../passkey-state.ts";
38 import { card, empty, confirmAction, time } from "../ui.ts";
38 import { confirmAction, row, time } from "../ui.ts";
3939 import { accountShell } from "./account.ts";
4040 import { safeNext, sessionCookie, signinAllowed } from "./site.ts";
4141 import { PASSKEY_EXPIRED, PASSKEY_REJECTED, PASSKEY_TAKEN } from "../passkey-words.ts";
@@ -74,47 +74,36 @@ export function passkeysPage(ctx: Ctx): Response {
7474 const user = ctx.auth.user;
7575 const keys = timePhase(ctx.request, "passkeys", () => listPasskeys(ctx.app.db, user.id));
7676 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>`;
118107 const response = accountShell(ctx, "passkeys", "Passkeys", body, 200, passkeyJs);
119108 notePhase(ctx.request, "render", performance.now() - rendering);
120109 return response;
test/passkey-recovery.test.ts +1 −1
@@ -122,7 +122,7 @@ describe("passkey recovery", () => {
122122 const cookie = done!.headers.get("set-cookie")!;
123123 expect(cookie).toContain("HttpOnly");
124124 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"`);
126126
127127 // Reused: refused, and no second session.
128128 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 &lt;laptop&gt;"');
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", () => {
107107
108108 let page = await (await fetch(`${base}/settings/passkeys`, { headers: { cookie } })).text();
109109 expect(page).toContain("Laptop");
110 expect(page).toContain("<strong>Phone</strong>");
110 expect(page).toContain(`aria-label="Remove Phone"`);
111111 expect(page).toContain(`<div class="field"><span class="muted small" id="passkey-add-note" data-fail="error" role="status"></span></div>`);
112112 expect(page).not.toContain("error-note");
113113
@@ -115,8 +115,8 @@ describe("passkeys", () => {
115115 const removed = await fetch(`${base}/settings/passkeys/${id}/delete`, { method: "POST", headers: { cookie }, redirect: "manual" });
116116 expect(removed.status).toBe(303);
117117 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"`);
120120
121121 const refused = await signIn(one.d);
122122 expect(refused.status).toBe(401);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs