Public: anyone can read this; the crew can change it.
Sign-in shows no "Passkey off" badge, and offers the passkey button only where a passkey can run #7924
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7921-sign-in-shows-no-passkey-off-badge-and-o → main 1 commit fork
2 files
changed +30
−15
src/web/pages/site.ts +8 −9
| @@ -35,7 +35,7 @@ import { SignupSlot, signupWindow } from "../../actions/signup-window.ts"; | ||
| 35 | 35 | import { signinWindow } from "../../core/signin-window.ts"; |
| 36 | 36 | import { field, readForm } from "../form.ts"; |
| 37 | 37 | import { passkeyJs } from "../passkey-client.ts"; |
| 38 | import { passkeyNote, passkeyState } from "../passkey-state.ts"; | |
| 38 | import { passkeyNote } from "../passkey-state.ts"; | |
| 39 | 39 | import { html, when, type Html } from "../html.ts"; |
| 40 | 40 | import { icon } from "../icons.ts"; |
| 41 | 41 | import { flashTo, page, redirect } from "../layout.ts"; |
| @@ -285,18 +285,17 @@ function authForm( | ||
| 285 | 285 | // Sign-in leads with the passkey: one press, nothing typed; the password |
| 286 | 286 | // form below stays for accounts that were given one. The button needs a |
| 287 | 287 | // script and a browser with passkeys, so the block is `data-passkey="on"`: shown |
| 288 | // only once `passkey-client.ts` marks `:root.passkey`. The form's Sign in | |
| 289 | // comes in both variants, outline beside the lead and primary everywhere | |
| 290 | // else, so the page always has one primary action. The sentence tells a | |
| 291 | // newcomer before they look for a door that is not there. | |
| 288 | // only once `passkey-client.ts` marks `:root.passkey`. No "Passkey off" | |
| 289 | // badge here: a visitor signing in did not ask about passkeys, and with no | |
| 290 | // script the page is the password form alone, its margin included (#7921). | |
| 291 | // The form's Sign in comes in both variants, outline beside the lead and | |
| 292 | // primary everywhere else, so the page always has one primary action. The | |
| 293 | // sentence tells a newcomer before they look for a door that is not there. | |
| 292 | 294 | const passkeyFirst = when( |
| 293 | 295 | kind === "signin", |
| 294 | () => html`<div class="stack stack-tight mb"> | |
| 295 | ${passkeyState()} | |
| 296 | <div class="stack stack-tight" data-passkey="on" hidden> | |
| 296 | () => html`<div class="stack stack-tight mb" 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 | 298 | ${passkeyNote("passkey-signin-note")} |
| 299 | </div> | |
| 300 | 299 | </div> |
| 301 | 300 | ${when(SIGNUP_SENTENCES[timePhase(ctx.request, "settings", () => getSignup(ctx.app.db))], (said) => html`<p class="muted small mb" id="signup-mode">${said}</p>`)}`, |
| 302 | 301 | ); |
test/signin-page.test.ts +22 −6
| @@ -32,6 +32,10 @@ describe("/signin", () => { | ||
| 32 | 32 | await rm(dataDir, { recursive: true, force: true }); |
| 33 | 33 | }); |
| 34 | 34 | |
| 35 | // The markup a person sees: the inline client script, shared with the pages | |
| 36 | // that do draw the badge, names it too. | |
| 37 | const markup = (page: string) => page.replace(/<script\b[\s\S]*?<\/script>/g, ""); | |
| 38 | ||
| 35 | 39 | const signin = async (mode: SignupMode) => { |
| 36 | 40 | setSignup(app.db, mode, "boot"); |
| 37 | 41 | const res = await fetch(`${servers.url}/signin`); |
| @@ -47,18 +51,30 @@ describe("/signin", () => { | ||
| 47 | 51 | expect(page).toMatch(/<button[^>]*id="passkey-signin"[^>]*data-variant="primary"/); |
| 48 | 52 | }); |
| 49 | 53 | |
| 50 | test("the passkey state is a badge, and no passkey-on class remains", async () => { | |
| 51 | const html = await signin("open"); | |
| 52 | expect(html).toContain("Passkey off"); | |
| 53 | expect(html).toMatch(/<span\s+class="badge"\s+data-variant="outline"\s*>\s*Passkey off/); | |
| 54 | test("no passkey state word, and the passkey button waits hidden for a browser that can run it (#7921)", async () => { | |
| 55 | const html = markup(await signin("open")); | |
| 56 | expect(html).not.toContain("Passkey off"); | |
| 57 | expect(html).not.toContain("Passkey on"); | |
| 58 | expect(html).not.toContain("data-passkey-state"); | |
| 54 | 59 | expect(html).not.toContain("passkey-on"); |
| 55 | 60 | expect(html).not.toContain('class="passkey'); |
| 56 | expect(insideHiddenDiv(html, "data-passkey-state")).toBe(false); | |
| 61 | expect(insideHiddenDiv(html, 'id="passkey-signin"')).toBe(true); | |
| 62 | }); | |
| 63 | ||
| 64 | test("with no script the page is the password form with one visible Sign in button", async () => { | |
| 65 | const html = markup(await signin("open")); | |
| 66 | // Drop every hidden element, as a browser with no script shows the page. | |
| 67 | const shown = html | |
| 68 | .replace(/<div class="stack stack-tight mb" data-passkey="on" hidden>[\s\S]*?<\/div>\s*<\/div>/, "") | |
| 69 | .replace(/<button[^>]*\shidden>[\s\S]*?<\/button>/g, ""); | |
| 70 | expect(shown).not.toContain("passkey-signin"); | |
| 71 | expect(shown.match(/<button[^>]*>\s*Sign in\s*<\/button>/g)?.length).toBe(1); | |
| 72 | expect(shown).toContain('name="password"'); | |
| 57 | 73 | }); |
| 58 | 74 | |
| 59 | 75 | test("the passkey lead shows only where a passkey can run, and Sign in is primary everywhere else", async () => { |
| 60 | 76 | const page = await signin("open"); |
| 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"/); | |
| 77 | expect(page).toMatch(/<div class="stack stack-tight mb" data-passkey="on" hidden>\s*<button[^>]*id="passkey-signin"/); | |
| 62 | 78 | expect(page).not.toMatch(/passkey-lead|passkey-note/); |
| 63 | 79 | expect(page).toMatch(/<span class="muted small" id="passkey-signin-note" data-fail="error" role="status"><\/span><\/div>\s*<\/div>/); |
| 64 | 80 | // Both variants of Sign in, each a named btn() variant picked in markup. |
new above
offline