Public: anyone can read this; the crew can change it.
Sign-in's passkey lead and note draw §5's btn and muted text, and .passkey-lead, .passkey-note leave the markup #4301
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4271-sign-in-s-passkey-lead-and-note-draw-5-s → main 3 commits fork
3 files
changed +68
−6
src/web/pages/site.ts +2 −2
| @@ -298,9 +298,9 @@ function authForm( | ||
| 298 | 298 | // newcomer before they look for a door that is not there. |
| 299 | 299 | const passkeyFirst = when( |
| 300 | 300 | kind === "signin", |
| 301 | () => html`<div class="passkey-on passkey-lead"> | |
| 301 | () => html`<div class="passkey-on stack stack-tight mb"> | |
| 302 | 302 | <button type="button" id="passkey-signin" class="btn" data-variant="primary" data-size="lg">Sign in with a passkey</button> |
| 303 | <p class="passkey-note small" id="passkey-signin-note" role="status"></p> | |
| 303 | <p class="muted small break" id="passkey-signin-note" data-fail="error-note" role="status"></p> | |
| 304 | 304 | </div> |
| 305 | 305 | ${when(SIGNUP_SENTENCES[timePhase(ctx.request, "settings", () => getSignup(ctx.app.db))], (said) => html`<p class="muted small mb" id="signup-mode">${said}</p>`)}`, |
| 306 | 306 | ); |
src/web/passkey-client.ts +13 −2
| @@ -33,6 +33,16 @@ export const passkeyJs = /* js */ ` | ||
| 33 | 33 | }); |
| 34 | 34 | }); |
| 35 | 35 | } |
| 36 | // A failure is an alert in the danger style, the class the note names in | |
| 37 | // data-fail; a cancel or a clear goes back to quiet. | |
| 38 | function say(note, msg, failed) { | |
| 39 | if (!note) return; | |
| 40 | var danger = note.getAttribute('data-fail'); | |
| 41 | note.textContent = msg; | |
| 42 | if (danger) note.classList.toggle(danger, failed); | |
| 43 | note.classList.toggle('muted', !failed); | |
| 44 | if (failed) note.setAttribute('role', 'alert'); else note.setAttribute('role', 'status'); | |
| 45 | } | |
| 36 | 46 | function wire(id, run) { |
| 37 | 47 | var el = doc.getElementById(id); |
| 38 | 48 | if (!el) return; |
| @@ -45,9 +55,10 @@ export const passkeyJs = /* js */ ` | ||
| 45 | 55 | el.addEventListener('click', function (e) { |
| 46 | 56 | e.preventDefault(); |
| 47 | 57 | el.disabled = true; |
| 48 | if (note) note.textContent = ''; | |
| 58 | say(note, '', false); | |
| 49 | 59 | run().catch(function (err) { |
| 50 | if (note) note.textContent = err && err.name === 'NotAllowedError' ? 'Cancelled.' : (err && err.message) || 'That did not work.'; | |
| 60 | var cancelled = err && err.name === 'NotAllowedError'; | |
| 61 | say(note, cancelled ? 'Cancelled.' : (err && err.message) || 'That did not work.', !cancelled); | |
| 51 | 62 | el.disabled = false; |
| 52 | 63 | }); |
| 53 | 64 | }); |
test/signin-page.test.ts +53 −2
| @@ -48,8 +48,9 @@ describe("/signin", () => { | ||
| 48 | 48 | |
| 49 | 49 | test("the passkey lead shows only where a passkey can run, and Sign in is primary everywhere else", async () => { |
| 50 | 50 | const page = await signin("open"); |
| 51 | expect(page).toMatch(/<div class="passkey-on passkey-lead">\s*<button[^>]*id="passkey-signin"/); | |
| 52 | expect(page).toMatch(/<p class="passkey-note small" id="passkey-signin-note" role="status"><\/p>\s*<\/div>/); | |
| 51 | expect(page).toMatch(/<div class="passkey-on stack stack-tight mb">\s*<button[^>]*id="passkey-signin"/); | |
| 52 | expect(page).not.toMatch(/passkey-lead|passkey-note/); | |
| 53 | expect(page).toMatch(/<p class="muted small break" id="passkey-signin-note" data-fail="error-note" role="status"><\/p>\s*<\/div>/); | |
| 53 | 54 | // Both variants of Sign in, each a named btn() variant picked in markup. |
| 54 | 55 | expect(page).toMatch(/<button\s+type="submit"\s+class="btn passkey-on"\s+data-variant="outline"[^>]*>\s*Sign in\s*</); |
| 55 | 56 | expect(page).toMatch(/<button\s+type="submit"\s+class="btn passkey-off"\s+data-variant="primary"[^>]*>\s*Sign in\s*</); |
| @@ -75,3 +76,53 @@ describe("/signin", () => { | ||
| 75 | 76 | expect(page).not.toContain(INVITE); |
| 76 | 77 | }); |
| 77 | 78 | }); |
| 79 | ||
| 80 | describe("the passkey note", () => { | |
| 81 | // Runs the client script against a stub DOM: click Sign in with a passkey and let it fail. | |
| 82 | async function press(fail: { name?: string; message: string }) { | |
| 83 | const classes = new Set(["muted", "small", "break"]); | |
| 84 | const attrs: Record<string, string> = { role: "status" }; | |
| 85 | const note = { | |
| 86 | textContent: "", | |
| 87 | classList: { | |
| 88 | toggle: (c: string, on: boolean) => void (on ? classes.add(c) : classes.delete(c)), | |
| 89 | add: (c: string) => void classes.add(c), | |
| 90 | }, | |
| 91 | setAttribute: (k: string, v: string) => void (attrs[k] = v), | |
| 92 | getAttribute: (k: string) => (k === "data-fail" ? "error-note" : null), | |
| 93 | }; | |
| 94 | let click: (e: { preventDefault(): void }) => void = () => {}; | |
| 95 | const button = { disabled: false, addEventListener: (_: string, f: typeof click) => void (click = f) }; | |
| 96 | const doc = { | |
| 97 | documentElement: { classList: { add() {} } }, | |
| 98 | getElementById: (id: string) => (id === "passkey-signin" ? button : id === "passkey-signin-note" ? note : null), | |
| 99 | }; | |
| 100 | const nav = { credentials: {} }; | |
| 101 | const fetchFails = async () => { | |
| 102 | throw Object.assign(new Error(fail.message), fail.name ? { name: fail.name } : {}); | |
| 103 | }; | |
| 104 | new Function("document", "window", "navigator", "fetch", passkeyJs.replace(/^[\s\S]*?\(function \(\) \{/, "(function () {").replace(/\}\)\s*$/, ""))( | |
| 105 | doc, { PublicKeyCredential: {} }, nav, fetchFails, | |
| 106 | ); | |
| 107 | click({ preventDefault() {} }); | |
| 108 | await new Promise((r) => setTimeout(r, 0)); | |
| 109 | return { note, classes, attrs, button }; | |
| 110 | } | |
| 111 | ||
| 112 | test("a failure is an alert in the danger style, not muted text", async () => { | |
| 113 | const { note, classes, attrs, button } = await press({ message: "That did not work." }); | |
| 114 | expect(note.textContent).toBe("That did not work."); | |
| 115 | expect(classes.has("error-note")).toBe(true); | |
| 116 | expect(classes.has("muted")).toBe(false); | |
| 117 | expect(attrs.role).toBe("alert"); | |
| 118 | expect(button.disabled).toBe(false); | |
| 119 | }); | |
| 120 | ||
| 121 | test("a cancel stays quiet", async () => { | |
| 122 | const { note, classes, attrs } = await press({ name: "NotAllowedError", message: "x" }); | |
| 123 | expect(note.textContent).toBe("Cancelled."); | |
| 124 | expect(classes.has("error-note")).toBe(false); | |
| 125 | expect(classes.has("muted")).toBe(true); | |
| 126 | expect(attrs.role).toBe("status"); | |
| 127 | }); | |
| 128 | }); |
new above
offline