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

Errors on sign-in, passkeys, the first-admin form and the claim page are §5's field error, not .error-note #5077

Merged opened this

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

Merged

scout/forge:scout/5068-errors-on-sign-in-passkeys-the-first-adm → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/5068-errors-on-sign-in-passkeys-the-first-adm was merged into main as 9f4d7f3.

Review

Sign in to review this change.
12 files changed +38 −18
docs/design/areas/passkey.md added +6
@@ -0,0 +1,6 @@
1 # Passkey
2
3 A passkey control (sign-in, sign-up, claim, first admin, the everyday account, add and recover) reports in §5's form
4 field: `passkeyNote()` writes a `.field` holding one line, quiet `.muted` text that `passkey-client.ts` swaps for
5 `.error` (the class named by `data-fail`) on a failure, with `role="alert"`. A page that re-renders with a failure
6 writes the same shape on the server.
src/web/pages/claim.ts +2 −2
@@ -55,7 +55,7 @@ import { html, type Html } from "../html.ts";
5555 import { page, redirect } from "../layout.ts";
5656 import { makeOptions, PASSKEY_WORDS, passkeyFields, readPasskey } from "../passkey-ceremony.ts";
5757 import { passkeyJs } from "../passkey-client.ts";
58 import { passkeyState } from "../passkey-state.ts";
58 import { passkeyNote, passkeyState } from "../passkey-state.ts";
5959 import { btn, card, field as formField } from "../ui.ts";
6060 import { sessionCookie, signinAllowed } from "./site.ts";
6161
@@ -201,7 +201,7 @@ function claimForm(
201201 Or skip the password: a passkey signs you in with a fingerprint,
202202 face or device PIN. It needs an email address above.
203203 </p>
204 <p class="${errors.passkey ? "error-note" : "muted small"} mb" id="passkey-make-note" data-fail="error-note" role="${errors.passkey ? "alert" : "status"}">${errors.passkey ?? ""}</p>
204 ${passkeyNote("passkey-make-note", errors.passkey)}
205205 <button type="button" id="passkey-make" class="btn" data-variant="outline" data-options="/claim/options" data-needs-email>Claim with a passkey instead</button>
206206 </div>
207207 </div>
src/web/pages/everyday.ts +2 −1
@@ -29,6 +29,7 @@ import { field, readForm } from "../form.ts";
2929 import { html, type Html } from "../html.ts";
3030 import { flashTo, redirect } from "../layout.ts";
3131 import { makeOptions, PASSKEY_WORDS, passkeyFields, readPasskey } from "../passkey-ceremony.ts";
32 import { passkeyNote } from "../passkey-state.ts";
3233 import { btn, card, field as formField } from "../ui.ts";
3334 import { signOutHere } from "../../actions/signins.ts";
3435 import { sessionCookie } from "./site.ts";
@@ -108,7 +109,7 @@ function everydayFields(ctx: Ctx, user: User, offerKeep: boolean): Html {
108109 It signs in with a passkey of its own: a fingerprint, face or
109110 device PIN. There is no password.
110111 </p>
111 <p class="muted small" id="passkey-make-note" data-fail="error-note" role="status"></p>
112 ${passkeyNote("passkey-make-note")}
112113 <noscript><p class="muted small">Making the passkey needs JavaScript.</p></noscript>
113114 <div class="form-actions">
114115 <button type="submit" id="passkey-make" class="btn" data-variant="primary" data-options="/settings/everyday/options">Make it with a passkey</button>
src/web/pages/first-admin.ts +2 −2
@@ -23,7 +23,7 @@ import { html, type Html } from "../html.ts";
2323 import { page, redirect } from "../layout.ts";
2424 import { makeOptions, PASSKEY_WORDS, passkeyFields, readPasskey } from "../passkey-ceremony.ts";
2525 import { passkeyJs } from "../passkey-client.ts";
26 import { passkeyState } from "../passkey-state.ts";
26 import { passkeyNote, passkeyState } from "../passkey-state.ts";
2727 import { card, field as formField } from "../ui.ts";
2828 import { sessionCookie } from "./site.ts";
2929
@@ -80,7 +80,7 @@ function form(values: Values, errors: Errors): Html {
8080 You sign in with a passkey: a fingerprint, face or device PIN.
8181 There is no password to remember.
8282 </p>
83 <p class="${errors.passkey ? "error-note" : "muted small"} mb" id="passkey-make-note" data-fail="error-note" role="${errors.passkey ? "alert" : "status"}">${errors.passkey ?? ""}</p>
83 ${passkeyNote("passkey-make-note", errors.passkey)}
8484 <noscript><p class="muted small">Making the passkey needs JavaScript.</p></noscript>
8585 <div class="form-actions">
8686 <button type="submit" id="passkey-make" class="btn" data-variant="primary" data-options="/first-admin/options">Make the admin with a passkey</button>
src/web/pages/passkeys.ts +3 −2
@@ -32,6 +32,7 @@ import { field, readForm } from "../form.ts";
3232 import { html } from "../html.ts";
3333 import { flashTo, page } from "../layout.ts";
3434 import { passkeyJs } from "../passkey-client.ts";
35 import { passkeyNote } from "../passkey-state.ts";
3536 import { card, empty, confirmAction, time } from "../ui.ts";
3637 import { accountShell } from "./account.ts";
3738 import { safeNext, sessionCookie, signinAllowed } from "./site.ts";
@@ -96,7 +97,7 @@ export function passkeysPage(ctx: Ctx): Response {
9697 <label for="passkey-name">Name</label>
9798 <input id="passkey-name" name="name" maxlength="${PASSKEY_NAME_MAX}" autocomplete="off" placeholder="Laptop, phone, security key" />
9899 </div>
99 <p class="error-note" id="passkey-add-note" role="status"></p>
100 ${passkeyNote("passkey-add-note")}
100101 <noscript><p class="muted small">Adding a passkey needs JavaScript.</p></noscript>
101102 <div class="form-actions">
102103 <button type="button" id="passkey-add" class="btn" data-variant="primary">Add a passkey</button>
@@ -261,7 +262,7 @@ export function recoverPage(ctx: Ctx): Response {
261262 <p class="muted small mb">This signs you in as ${user.username}. Name the device, then follow your browser's prompt.</p>
262263 <div class="field"><label for="passkey-name">Name</label>
263264 <input id="passkey-name" name="name" maxlength="${PASSKEY_NAME_MAX}" autocomplete="off" placeholder="Laptop, phone, security key" /></div>
264 <p class="error-note" id="passkey-recover-note" role="status"></p>
265 ${passkeyNote("passkey-recover-note")}
265266 <noscript><p class="muted small">Registering a passkey needs JavaScript.</p></noscript>
266267 <div class="form-actions"><button type="button" id="passkey-recover" class="btn" data-variant="primary">Register and sign in</button></div>`,
267268 }));
src/web/pages/site.ts +4 −4
@@ -34,7 +34,7 @@ import { SignupSlot, signupWindow } from "../../actions/signup-window.ts";
3434 import { signinWindow } from "../../core/signin-window.ts";
3535 import { field, readForm } from "../form.ts";
3636 import { passkeyJs } from "../passkey-client.ts";
37 import { passkeyState } from "../passkey-state.ts";
37 import { passkeyNote, passkeyState } from "../passkey-state.ts";
3838 import { html, when, type Html } from "../html.ts";
3939 import { icon } from "../icons.ts";
4040 import { flashTo, page, redirect } from "../layout.ts";
@@ -295,7 +295,7 @@ function authForm(
295295 ${passkeyState()}
296296 <div class="stack stack-tight" data-passkey="on" hidden>
297297 <button type="button" id="passkey-signin" class="btn" data-variant="primary" data-size="lg">Sign in with a passkey</button>
298 <p class="muted small break" id="passkey-signin-note" data-fail="error-note" role="status"></p>
298 ${passkeyNote("passkey-signin-note")}
299299 </div>
300300 </div>
301301 ${when(SIGNUP_SENTENCES[timePhase(ctx.request, "settings", () => getSignup(ctx.app.db))], (said) => html`<p class="muted small mb" id="signup-mode">${said}</p>`)}`,
@@ -359,7 +359,7 @@ function authForm(
359359 A passkey signs you in with a fingerprint, face or device PIN; there
360360 is no password to remember.
361361 </p>
362 <p class="error-note mb" id="passkey-signup-note" role="alert">${errors.password ?? ""}</p>
362 ${passkeyNote("passkey-signup-note", errors.password)}
363363 <noscript><p class="muted small">Signing up needs JavaScript for the passkey.</p></noscript>`,
364364 )}
365365 ${when(
@@ -369,7 +369,7 @@ function authForm(
369369 Or skip the password: a passkey signs you in with a fingerprint,
370370 face or device PIN. An email address above is optional.
371371 </p>
372 <p class="error-note mb" id="passkey-signup-note" role="alert"></p>
372 ${passkeyNote("passkey-signup-note")}
373373 <button type="button" id="passkey-signup" class="btn" data-variant="outline">Create account with a passkey</button>
374374 </div>`,
375375 )}
src/web/passkey-state.ts +10
@@ -11,3 +11,13 @@ import { badge } from "./ui.ts";
1111 export function passkeyState(): Html {
1212 return html`<span data-passkey-state>${badge("Passkey off", { variant: "outline" })}</span>`;
1313 }
14
15 /**
16 * The line a passkey control reports to, in §5's field shape: quiet muted text
17 * until a failure, then `.error` in a `.field`. `passkey-client.ts` toggles the
18 * class named by `data-fail`; a page that re-rendered with a failure writes
19 * the same shape itself.
20 */
21 export function passkeyNote(id: string, error?: string): Html {
22 return html`<div class="field"><span class="${error ? "error" : "muted small"}" id="${id}" data-fail="error" role="${error ? "alert" : "status"}">${error ?? ""}</span></div>`;

Consider overflow-wrap: anywhere on .field .error. The old sign-in note carried .break and this one does not, so a long error string could overflow a 360px phone.

23 }
test/everyday.test.ts +1 −1
@@ -183,7 +183,7 @@ describe("the everyday account", () => {
183183 const home = await page(root, "/");
184184 expect(home).toContain('id="passkey-make"');
185185 expect(home).not.toContain("passkey-note");
186 expect(home).toContain('<p class="muted small" id="passkey-make-note" data-fail="error-note" role="status"></p>');
186 expect(home).toContain('<span class="muted small" id="passkey-make-note" data-fail="error" role="status"></span>');
187187 expect(home).not.toContain('name="password"');
188188 const res = await make(root, {
189189 username: "ada-me",
test/first-admin.test.ts +1 −1
@@ -310,7 +310,7 @@ describe("a person: /first-admin with a passkey", () => {
310310 expect(res.status).toBe(422);
311311 const refusedPage = await res.text();
312312 expect(refusedPage).not.toContain("passkey-note");
313 expect(refusedPage).toMatch(/<p class="error-note mb" id="passkey-make-note" data-fail="error-note" role="alert">[^<]+<\/p>/);
313 expect(refusedPage).toMatch(/<div class="field"><span class="error" id="passkey-make-note" data-fail="error" role="alert">[^<]+<\/span><\/div>/);
314314 expect(getUserByName(app.db, "ada")).toBeUndefined();
315315 expect(readFirstAdmin(app.db, code)).toBe("ok");
316316 });
test/passkeys.test.ts +2
@@ -108,6 +108,8 @@ describe("passkeys", () => {
108108 let page = await (await fetch(`${base}/settings/passkeys`, { headers: { cookie } })).text();
109109 expect(page).toContain("Laptop");
110110 expect(page).toContain("<strong>Phone</strong>");
111 expect(page).toContain(`<div class="field"><span class="muted small" id="passkey-add-note" data-fail="error" role="status"></span></div>`);
112 expect(page).not.toContain("error-note");
111113
112114 const id = app.db.query<{ id: string }, []>("SELECT id FROM passkey WHERE name = 'Laptop'").get()!.id;
113115 const removed = await fetch(`${base}/settings/passkeys/${id}/delete`, { method: "POST", headers: { cookie }, redirect: "manual" });
test/signin-page.test.ts +4 −4
@@ -60,7 +60,7 @@ describe("/signin", () => {
6060 const page = await signin("open");
6161 expect(page).toMatch(/<div class="stack stack-tight mb">\s*<span data-passkey-state>[\s\S]*?Passkey off[\s\S]*?<\/span>\s*<div class="stack stack-tight" data-passkey="on" hidden>\s*<button[^>]*id="passkey-signin"/);
6262 expect(page).not.toMatch(/passkey-lead|passkey-note/);
63 expect(page).toMatch(/<p class="muted small break" id="passkey-signin-note" data-fail="error-note" role="status"><\/p>\s*<\/div>/);
63 expect(page).toMatch(/<span class="muted small" id="passkey-signin-note" data-fail="error" role="status"><\/span><\/div>\s*<\/div>/);
6464 // Both variants of Sign in, each a named btn() variant picked in markup.
6565 expect(page).toMatch(/<button\s+type="submit"\s+class="btn"\s+data-variant="outline"\s+data-passkey="on"\s+hidden>\s*Sign in\s*</);
6666 expect(page).toMatch(/<button\s+type="submit"\s+class="btn"\s+data-variant="primary"\s+data-passkey="off">\s*Sign in\s*</);
@@ -99,7 +99,7 @@ describe("the passkey note", () => {
9999 add: (c: string) => void classes.add(c),
100100 },
101101 setAttribute: (k: string, v: string) => void (attrs[k] = v),
102 getAttribute: (k: string) => (k === "data-fail" ? "error-note" : null),
102 getAttribute: (k: string) => (k === "data-fail" ? "error" : null),
103103 };
104104 let click: (e: { preventDefault(): void }) => void = () => {};
105105 const button = { disabled: false, addEventListener: (_: string, f: typeof click) => void (click = f) };
@@ -122,7 +122,7 @@ describe("the passkey note", () => {
122122 test("a failure is an alert in the danger style, not muted text", async () => {
123123 const { note, classes, attrs, button } = await press({ message: "That did not work." });
124124 expect(note.textContent).toBe("That did not work.");
125 expect(classes.has("error-note")).toBe(true);
125 expect(classes.has("error")).toBe(true);
126126 expect(classes.has("muted")).toBe(false);
127127 expect(attrs.role).toBe("alert");
128128 expect(button.disabled).toBe(false);
@@ -131,7 +131,7 @@ describe("the passkey note", () => {
131131 test("a cancel stays quiet", async () => {
132132 const { note, classes, attrs } = await press({ name: "NotAllowedError", message: "x" });
133133 expect(note.textContent).toBe("Cancelled.");
134 expect(classes.has("error-note")).toBe(false);
134 expect(classes.has("error")).toBe(false);
135135 expect(classes.has("muted")).toBe(true);
136136 expect(attrs.role).toBe("status");
137137 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs