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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5068-errors-on-sign-in-passkeys-the-first-adm → main 1 commit fork 1 thread · 1 unresolved
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"; | ||
| 55 | 55 | import { page, redirect } from "../layout.ts"; |
| 56 | 56 | import { makeOptions, PASSKEY_WORDS, passkeyFields, readPasskey } from "../passkey-ceremony.ts"; |
| 57 | 57 | import { passkeyJs } from "../passkey-client.ts"; |
| 58 | import { passkeyState } from "../passkey-state.ts"; | |
| 58 | import { passkeyNote, passkeyState } from "../passkey-state.ts"; | |
| 59 | 59 | import { btn, card, field as formField } from "../ui.ts"; |
| 60 | 60 | import { sessionCookie, signinAllowed } from "./site.ts"; |
| 61 | 61 | |
| @@ -201,7 +201,7 @@ function claimForm( | ||
| 201 | 201 | Or skip the password: a passkey signs you in with a fingerprint, |
| 202 | 202 | face or device PIN. It needs an email address above. |
| 203 | 203 | </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)} | |
| 205 | 205 | <button type="button" id="passkey-make" class="btn" data-variant="outline" data-options="/claim/options" data-needs-email>Claim with a passkey instead</button> |
| 206 | 206 | </div> |
| 207 | 207 | </div> |
src/web/pages/everyday.ts +2 −1
| @@ -29,6 +29,7 @@ import { field, readForm } from "../form.ts"; | ||
| 29 | 29 | import { html, type Html } from "../html.ts"; |
| 30 | 30 | import { flashTo, redirect } from "../layout.ts"; |
| 31 | 31 | import { makeOptions, PASSKEY_WORDS, passkeyFields, readPasskey } from "../passkey-ceremony.ts"; |
| 32 | import { passkeyNote } from "../passkey-state.ts"; | |
| 32 | 33 | import { btn, card, field as formField } from "../ui.ts"; |
| 33 | 34 | import { signOutHere } from "../../actions/signins.ts"; |
| 34 | 35 | import { sessionCookie } from "./site.ts"; |
| @@ -108,7 +109,7 @@ function everydayFields(ctx: Ctx, user: User, offerKeep: boolean): Html { | ||
| 108 | 109 | It signs in with a passkey of its own: a fingerprint, face or |
| 109 | 110 | device PIN. There is no password. |
| 110 | 111 | </p> |
| 111 | <p class="muted small" id="passkey-make-note" data-fail="error-note" role="status"></p> | |
| 112 | ${passkeyNote("passkey-make-note")} | |
| 112 | 113 | <noscript><p class="muted small">Making the passkey needs JavaScript.</p></noscript> |
| 113 | 114 | <div class="form-actions"> |
| 114 | 115 | <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"; | ||
| 23 | 23 | import { page, redirect } from "../layout.ts"; |
| 24 | 24 | import { makeOptions, PASSKEY_WORDS, passkeyFields, readPasskey } from "../passkey-ceremony.ts"; |
| 25 | 25 | import { passkeyJs } from "../passkey-client.ts"; |
| 26 | import { passkeyState } from "../passkey-state.ts"; | |
| 26 | import { passkeyNote, passkeyState } from "../passkey-state.ts"; | |
| 27 | 27 | import { card, field as formField } from "../ui.ts"; |
| 28 | 28 | import { sessionCookie } from "./site.ts"; |
| 29 | 29 | |
| @@ -80,7 +80,7 @@ function form(values: Values, errors: Errors): Html { | ||
| 80 | 80 | You sign in with a passkey: a fingerprint, face or device PIN. |
| 81 | 81 | There is no password to remember. |
| 82 | 82 | </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)} | |
| 84 | 84 | <noscript><p class="muted small">Making the passkey needs JavaScript.</p></noscript> |
| 85 | 85 | <div class="form-actions"> |
| 86 | 86 | <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"; | ||
| 32 | 32 | import { html } from "../html.ts"; |
| 33 | 33 | import { flashTo, page } from "../layout.ts"; |
| 34 | 34 | import { passkeyJs } from "../passkey-client.ts"; |
| 35 | import { passkeyNote } from "../passkey-state.ts"; | |
| 35 | 36 | import { card, empty, confirmAction, time } from "../ui.ts"; |
| 36 | 37 | import { accountShell } from "./account.ts"; |
| 37 | 38 | import { safeNext, sessionCookie, signinAllowed } from "./site.ts"; |
| @@ -96,7 +97,7 @@ export function passkeysPage(ctx: Ctx): Response { | ||
| 96 | 97 | <label for="passkey-name">Name</label> |
| 97 | 98 | <input id="passkey-name" name="name" maxlength="${PASSKEY_NAME_MAX}" autocomplete="off" placeholder="Laptop, phone, security key" /> |
| 98 | 99 | </div> |
| 99 | <p class="error-note" id="passkey-add-note" role="status"></p> | |
| 100 | ${passkeyNote("passkey-add-note")} | |
| 100 | 101 | <noscript><p class="muted small">Adding a passkey needs JavaScript.</p></noscript> |
| 101 | 102 | <div class="form-actions"> |
| 102 | 103 | <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 { | ||
| 261 | 262 | <p class="muted small mb">This signs you in as ${user.username}. Name the device, then follow your browser's prompt.</p> |
| 262 | 263 | <div class="field"><label for="passkey-name">Name</label> |
| 263 | 264 | <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")} | |
| 265 | 266 | <noscript><p class="muted small">Registering a passkey needs JavaScript.</p></noscript> |
| 266 | 267 | <div class="form-actions"><button type="button" id="passkey-recover" class="btn" data-variant="primary">Register and sign in</button></div>`, |
| 267 | 268 | })); |
src/web/pages/site.ts +4 −4
| @@ -34,7 +34,7 @@ import { SignupSlot, signupWindow } from "../../actions/signup-window.ts"; | ||
| 34 | 34 | import { signinWindow } from "../../core/signin-window.ts"; |
| 35 | 35 | import { field, readForm } from "../form.ts"; |
| 36 | 36 | import { passkeyJs } from "../passkey-client.ts"; |
| 37 | import { passkeyState } from "../passkey-state.ts"; | |
| 37 | import { passkeyNote, passkeyState } from "../passkey-state.ts"; | |
| 38 | 38 | import { html, when, type Html } from "../html.ts"; |
| 39 | 39 | import { icon } from "../icons.ts"; |
| 40 | 40 | import { flashTo, page, redirect } from "../layout.ts"; |
| @@ -295,7 +295,7 @@ function authForm( | ||
| 295 | 295 | ${passkeyState()} |
| 296 | 296 | <div class="stack stack-tight" data-passkey="on" hidden> |
| 297 | 297 | <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")} | |
| 299 | 299 | </div> |
| 300 | 300 | </div> |
| 301 | 301 | ${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( | ||
| 359 | 359 | A passkey signs you in with a fingerprint, face or device PIN; there |
| 360 | 360 | is no password to remember. |
| 361 | 361 | </p> |
| 362 | <p class="error-note mb" id="passkey-signup-note" role="alert">${errors.password ?? ""}</p> | |
| 362 | ${passkeyNote("passkey-signup-note", errors.password)} | |
| 363 | 363 | <noscript><p class="muted small">Signing up needs JavaScript for the passkey.</p></noscript>`, |
| 364 | 364 | )} |
| 365 | 365 | ${when( |
| @@ -369,7 +369,7 @@ function authForm( | ||
| 369 | 369 | Or skip the password: a passkey signs you in with a fingerprint, |
| 370 | 370 | face or device PIN. An email address above is optional. |
| 371 | 371 | </p> |
| 372 | <p class="error-note mb" id="passkey-signup-note" role="alert"></p> | |
| 372 | ${passkeyNote("passkey-signup-note")} | |
| 373 | 373 | <button type="button" id="passkey-signup" class="btn" data-variant="outline">Create account with a passkey</button> |
| 374 | 374 | </div>`, |
| 375 | 375 | )} |
src/web/passkey-state.ts +10
| @@ -11,3 +11,13 @@ import { badge } from "./ui.ts"; | ||
| 11 | 11 | export function passkeyState(): Html { |
| 12 | 12 | return html`<span data-passkey-state>${badge("Passkey off", { variant: "outline" })}</span>`; |
| 13 | 13 | } |
| 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>`; | |
| 23 | } |
test/claim-link.test.ts +1 −1
| @@ -225,7 +225,7 @@ describe("the claim link in the fragment, and its re-issue", () => { | ||
| 225 | 225 | const takenPage = await taken.text(); |
| 226 | 226 | expect(takenPage).toContain("That passkey is already registered."); |
| 227 | 227 | expect(takenPage).not.toContain("passkey-note"); |
| 228 | expect(takenPage).toMatch(/<p class="error-note mb" id="passkey-make-note" data-fail="error-note" role="alert">That passkey is already registered\./); | |
| 228 | expect(takenPage).toMatch(/<div class="field"><span class="error" id="passkey-make-note" data-fail="error" role="alert">That passkey is already registered\./); | |
| 229 | 229 | expect(getUserByName(child.db, "taker")).toBeUndefined(); |
| 230 | 230 | expect(listPasskeys(child.db, holder.value.id)).toHaveLength(1); |
| 231 | 231 | // Still unspent: the link gives a challenge yet. |
test/everyday.test.ts +1 −1
| @@ -183,7 +183,7 @@ describe("the everyday account", () => { | ||
| 183 | 183 | const home = await page(root, "/"); |
| 184 | 184 | expect(home).toContain('id="passkey-make"'); |
| 185 | 185 | 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>'); | |
| 187 | 187 | expect(home).not.toContain('name="password"'); |
| 188 | 188 | const res = await make(root, { |
| 189 | 189 | username: "ada-me", |
test/first-admin.test.ts +1 −1
| @@ -310,7 +310,7 @@ describe("a person: /first-admin with a passkey", () => { | ||
| 310 | 310 | expect(res.status).toBe(422); |
| 311 | 311 | const refusedPage = await res.text(); |
| 312 | 312 | 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>/); | |
| 314 | 314 | expect(getUserByName(app.db, "ada")).toBeUndefined(); |
| 315 | 315 | expect(readFirstAdmin(app.db, code)).toBe("ok"); |
| 316 | 316 | }); |
test/passkeys.test.ts +2
| @@ -108,6 +108,8 @@ describe("passkeys", () => { | ||
| 108 | 108 | let page = await (await fetch(`${base}/settings/passkeys`, { headers: { cookie } })).text(); |
| 109 | 109 | expect(page).toContain("Laptop"); |
| 110 | 110 | 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"); | |
| 111 | 113 | |
| 112 | 114 | const id = app.db.query<{ id: string }, []>("SELECT id FROM passkey WHERE name = 'Laptop'").get()!.id; |
| 113 | 115 | 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", () => { | ||
| 60 | 60 | const page = await signin("open"); |
| 61 | 61 | 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"/); |
| 62 | 62 | 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>/); | |
| 64 | 64 | // Both variants of Sign in, each a named btn() variant picked in markup. |
| 65 | 65 | expect(page).toMatch(/<button\s+type="submit"\s+class="btn"\s+data-variant="outline"\s+data-passkey="on"\s+hidden>\s*Sign in\s*</); |
| 66 | 66 | 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", () => { | ||
| 99 | 99 | add: (c: string) => void classes.add(c), |
| 100 | 100 | }, |
| 101 | 101 | 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), | |
| 103 | 103 | }; |
| 104 | 104 | let click: (e: { preventDefault(): void }) => void = () => {}; |
| 105 | 105 | const button = { disabled: false, addEventListener: (_: string, f: typeof click) => void (click = f) }; |
| @@ -122,7 +122,7 @@ describe("the passkey note", () => { | ||
| 122 | 122 | test("a failure is an alert in the danger style, not muted text", async () => { |
| 123 | 123 | const { note, classes, attrs, button } = await press({ message: "That did not work." }); |
| 124 | 124 | expect(note.textContent).toBe("That did not work."); |
| 125 | expect(classes.has("error-note")).toBe(true); | |
| 125 | expect(classes.has("error")).toBe(true); | |
| 126 | 126 | expect(classes.has("muted")).toBe(false); |
| 127 | 127 | expect(attrs.role).toBe("alert"); |
| 128 | 128 | expect(button.disabled).toBe(false); |
| @@ -131,7 +131,7 @@ describe("the passkey note", () => { | ||
| 131 | 131 | test("a cancel stays quiet", async () => { |
| 132 | 132 | const { note, classes, attrs } = await press({ name: "NotAllowedError", message: "x" }); |
| 133 | 133 | expect(note.textContent).toBe("Cancelled."); |
| 134 | expect(classes.has("error-note")).toBe(false); | |
| 134 | expect(classes.has("error")).toBe(false); | |
| 135 | 135 | expect(classes.has("muted")).toBe(true); |
| 136 | 136 | expect(attrs.role).toBe("status"); |
| 137 | 137 | }); |
new above
offline
Consider
overflow-wrap: anywhereon.field .error. The old sign-in note carried.breakand this one does not, so a long error string could overflow a 360px phone.