Public: anyone can read this; the crew can change it.
The passkey state on the site, claim and first-admin pages draws §5's badge and stack, and no class="passkey…" remains in the markup #4714
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4698-the-passkey-state-on-the-site-claim-and → main 2 commits fork 3 threads · 3 unresolved
11 files
changed +80
−15
docs/DESIGN.md +1 −1
| @@ -393,7 +393,7 @@ gone, so this list only shrinks. | ||
| 393 | 393 | |
| 394 | 394 | - **`style/base.ts`** — the page shell (topbar, main, footer) and the layout and text utilities every page leans on: `.bar` `.bare` `.foot` `.grow` `.hide-lg` `.hide-sm` `.js-only` `.live-pill` `.mark` `.mb` `.mt` `.muted` `.narrow` `.narrow-wide` `.num` `.reduced` `.shell` `.shell-wide` `.skip` `.small` `.spacer` `.sr-only` `.stack` `.stack-tight` `.topbar` `.topbar-tools` `.truncate` `.wordmark` |
| 395 | 395 | A `.field` directly inside a `.bar` drops its bottom margin and the bar aligns to the end, so a labelled input and its button share a baseline |
| 396 | - **`style/components.ts`** — parts of the §5 components that the table does not spell out — the palette's list, the menu's items, a dialog's head and foot, comments, the theme picker — and a few small helpers beside them: `.cat` `.clone` `.clone-text` `.comment` `.comment-body` `.comment-form` `.dash` `.dialog-body` `.dialog-foot` `.dialog-head` `.empty-title` `.error-note` `.field-row` `.form-actions` `.hex` `.ico` `.ico-loader` `.list` `.menu-item` `.menu-panel` `.menu-sep` `.menu-title` `.palette-empty` `.palette-foot` `.palette-group` `.palette-heading` `.palette-input` `.palette-item` `.palette-list` `.passkey` `.passkey-off` `.passkey-on` `.preview` `.sep` `.split` `.sprite` `.swatch` `.table-scroll` `.tell-here` `.theme-apply` `.theme-option` `.tick` `.timeline` `.tl-dot` `.toast-body` `.topbar-search` | |
| 396 | - **`style/components.ts`** — parts of the §5 components that the table does not spell out — the palette's list, the menu's items, a dialog's head and foot, comments, the theme picker — and a few small helpers beside them: `.cat` `.clone` `.clone-text` `.comment` `.comment-body` `.comment-form` `.dash` `.dialog-body` `.dialog-foot` `.dialog-head` `.empty-title` `.error-note` `.field-row` `.form-actions` `.hex` `.ico` `.ico-loader` `.list` `.menu-item` `.menu-panel` `.menu-sep` `.menu-title` `.palette-empty` `.palette-foot` `.palette-group` `.palette-heading` `.palette-input` `.palette-item` `.palette-list` `.preview` `.sep` `.split` `.sprite` `.swatch` `.table-scroll` `.tell-here` `.theme-apply` `.theme-option` `.tick` `.timeline` `.tl-dot` `.toast-body` `.topbar-search` | |
| 397 | 397 | - **`style/components.ts`, settings rows** — a list whose rows are settings (limits, capacity): the `<ul class="list setting-list">` lets title and meta wrap instead of ellipsizing, and a row's controls take their own line under 640px. `.row-fields` is the form in that row's trail (input, Save, Default): always visible, never hover-gated, wrapping under the text before it would clip. Use both for any page whose rows show a name, a current value, and a control to change it; use `.row-actions` only for secondary actions (delete, wake, pin, revoke). Flags, telemetry, cloud, people, crew roles and subscription headroom/budget use `.row-fields`. Classes: `.row-fields` `.setting-list` |
| 398 | 398 | - **`style/content.ts`, page furniture** — what frames a page's content: page and repo heads, the front page and its tiles, explore, backups, boards, the fleet chart's series colours, lineage: `.bform` `.bgrid` `.bmanage` `.bp-h2` `.bp-h3` `.bp-w2` `.bp-w3` `.break` `.bt-bar` `.bt-grid` `.bt-group` `.bt-hist` `.bt-name` `.bt-procs` `.bt-row` `.bt-val` `.commit-body` `.commit-head` `.domain-records` `.fg-s0` `.fg-s1` `.fg-s2` `.fg-s3` `.fg-s4` `.fg-s5` `.fg-s6` `.fg-s7` `.filters` `.flag-form` `.front-admin` `.front-controls` `.front-crew` `.front-featured` `.front-featured-body` `.front-featured-look` `.front-issues` `.front-live` `.front-more` `.front-pick` `.frozen` `.frozen-form` `.frozen-sentence` `.headroom` `.issue-head` `.issue-meta` `.mirror-form` `.number` `.origin-mark` `.origin-name` `.origin-row` `.owner` `.page-head` `.path-head` `.policy-line` `.qf` `.repo-actions` `.repo-audience` `.repo-desc` `.repo-layout` `.repo-lineage` `.repo-title` `.search` `.slash` `.stage-detail` `.stage-dot` `.steps` `.sub` `.sys-pair` `.table-wrap` `.tok` `.where-sep` |
| 399 | 399 | - **`style/content.ts`, code view** — the source view and the syntax-highlight token classes `highlight-html.ts` builds as `tok-${kind}`: `.blob-view` `.ln` `.tok-attribute` `.tok-comment` `.tok-constant` `.tok-decorator` `.tok-deleted` `.tok-emphasis` `.tok-function` `.tok-heading` `.tok-inserted` `.tok-keyword` `.tok-link` `.tok-meta` `.tok-number` `.tok-operator` `.tok-plain` `.tok-property` `.tok-punctuation` `.tok-regex` `.tok-string` `.tok-tag` `.tok-type` `.tok-variable` |
src/web/pages/claim.ts +5 −1
| @@ -55,6 +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 | 59 | import { btn, card, field as formField } from "../ui.ts"; |
| 59 | 60 | import { sessionCookie, signinAllowed } from "./site.ts"; |
| 60 | 61 | |
| @@ -193,13 +194,16 @@ function claimForm( | ||
| 193 | 194 | <div class="form-actions"> |
| 194 | 195 | <button type="submit" class="btn" data-variant="primary">Claim this forge</button> |
| 195 | 196 | </div> |
| 196 | <div class="passkey-on"> | |
| 197 | <div class="stack stack-tight"> | |
| 198 | ${passkeyState()} | |
| 199 | <div data-passkey="on" hidden> | |
| 197 | 200 | <p class="muted small mb"> |
| 198 | 201 | Or skip the password: a passkey signs you in with a fingerprint, |
| 199 | 202 | face or device PIN. It needs an email address above. |
| 200 | 203 | </p> |
| 201 | 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> |
| 202 | 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 | </div> | |
| 203 | 207 | </div> |
| 204 | 208 | </form>`, |
| 205 | 209 | )} |
src/web/pages/first-admin.ts +2
| @@ -23,6 +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 | 27 | import { card, field as formField } from "../ui.ts"; |
| 27 | 28 | import { sessionCookie } from "./site.ts"; |
| 28 | 29 | |
| @@ -74,6 +75,7 @@ function form(values: Values, errors: Errors): Html { | ||
| 74 | 75 | ...(errors.email ? { error: errors.email } : {}), |
| 75 | 76 | })} |
| 76 | 77 | ${passkeyFields()} |
| 78 | <div class="stack stack-tight mb">${passkeyState()}</div> | |
| 77 | 79 | <p class="muted small mb"> |
| 78 | 80 | You sign in with a passkey: a fingerprint, face or device PIN. |
| 79 | 81 | There is no password to remember. |
src/web/pages/site.ts +8 −4
| @@ -34,6 +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 | 38 | import { html, when, type Html } from "../html.ts"; |
| 38 | 39 | import { icon } from "../icons.ts"; |
| 39 | 40 | import { flashTo, page, redirect } from "../layout.ts"; |
| @@ -289,16 +290,19 @@ function authForm( | ||
| 289 | 290 | |
| 290 | 291 | // Sign-in leads with the passkey: one press, nothing typed; the password |
| 291 | 292 | // form below stays for accounts that were given one. The button needs a |
| 292 | // script and a browser with passkeys, so the block is `passkey-on`: shown | |
| 293 | // script and a browser with passkeys, so the block is `data-passkey="on"`: shown | |
| 293 | 294 | // only once `passkey-client.ts` marks `:root.passkey`. The form's Sign in |
| 294 | 295 | // comes in both variants, outline beside the lead and primary everywhere |
| 295 | 296 | // else, so the page always has one primary action. The sentence tells a |
| 296 | 297 | // newcomer before they look for a door that is not there. |
| 297 | 298 | const passkeyFirst = when( |
| 298 | 299 | kind === "signin", |
| 299 | () => html`<div class="passkey-on stack stack-tight mb"> | |
| 300 | () => html`<div class="stack stack-tight mb"> | |
| 301 | ${passkeyState()} | |
The badge is inside the |
||
| 302 | <div class="stack stack-tight" data-passkey="on" hidden> | |
| 300 | 303 | <button type="button" id="passkey-signin" class="btn" data-variant="primary" data-size="lg">Sign in with a passkey</button> |
| 301 | 304 | <p class="muted small break" id="passkey-signin-note" data-fail="error-note" role="status"></p> |
| 305 | </div> | |
| 302 | 306 | </div> |
| 303 | 307 | ${when(SIGNUP_SENTENCES[timePhase(ctx.request, "settings", () => getSignup(ctx.app.db))], (said) => html`<p class="muted small mb" id="signup-mode">${said}</p>`)}`, |
| 304 | 308 | ); |
| @@ -366,7 +370,7 @@ function authForm( | ||
| 366 | 370 | )} |
| 367 | 371 | ${when( |
| 368 | 372 | kind === "signup" && !byPasskey, |
| 369 | () => html`<div class="passkey-on"> | |
| 373 | () => html`<div data-passkey="on" hidden> | |
| 370 | 374 | <p class="muted small mb"> |
| 371 | 375 | Or skip the password: a passkey signs you in with a fingerprint, |
| 372 | 376 | face or device PIN. An email address above is optional. |
| @@ -391,7 +395,7 @@ function authForm( | ||
| 391 | 395 | ${byPasskey |
| 392 | 396 | ? html`<button type="button" id="passkey-signup" class="btn" data-variant="primary">Create account with a passkey</button>` |
| 393 | 397 | : kind === "signin" |
| 394 | ? html`${btn("Sign in", { variant: "outline", extra: "passkey-on" })}${btn("Sign in", { variant: "primary", extra: "passkey-off" })}` | |
| 398 | ? html`<button type="submit" class="btn" data-variant="outline" data-passkey="on" hidden>Sign in</button><button type="submit" class="btn" data-variant="primary" data-passkey="off">Sign in</button>` | |
| 395 | 399 | : btn("Create account", { variant: "primary" })} |
| 396 | 400 | <span class="spacer"></span> |
| 397 | 401 | ${kind === "signin" |
src/web/passkey-client.ts +7 −1
| @@ -6,8 +6,14 @@ | ||
| 6 | 6 | export const passkeyJs = /* js */ ` |
| 7 | 7 | (function () { |
| 8 | 8 | var doc = document; |
| 9 | // Controls marked passkey-on show only where a passkey can actually run. | |
| 9 | // Controls marked data-passkey="on" show only where a passkey can actually run. | |
| 10 | function each(sel, fn) { if (doc.querySelectorAll) Array.prototype.forEach.call(doc.querySelectorAll(sel), fn); } | |
| 10 | 11 | if (window.PublicKeyCredential && navigator.credentials) doc.documentElement.classList.add('passkey'); |
| 12 | if (window.PublicKeyCredential && navigator.credentials) { | |
| 13 | each('[data-passkey="on"]', function (el) { el.hidden = false; }); | |
|
||
| 14 | each('[data-passkey="off"]', function (el) { el.hidden = true; }); | |
| 15 | each('[data-passkey-state] .badge', function (el) { el.textContent = 'Passkey on'; el.setAttribute('data-variant', 'success'); }); | |
| 16 | } | |
| 11 | 17 | function bytes(s) { |
| 12 | 18 | s = s.replace(/-/g, '+').replace(/_/g, '/'); |
| 13 | 19 | while (s.length % 4) s += '='; |
src/web/passkey-state.ts added +13
| @@ -0,0 +1,13 @@ | ||
| 1 | /** | |
| 2 | * The passkey on/off state, drawn as §5's badge. Whether a passkey can run is | |
| 3 | * known only in the browser, so the server renders "off" and `passkey-client.ts` | |
| 4 | * flips the badge to "on" (success) and swaps the `data-passkey="on"` / | |
| 5 | * `"off"` controls by the `hidden` attribute. | |
| 6 | */ | |
| 7 | import { html, type Html } from "./html.ts"; | |
| 8 | import { badge } from "./ui.ts"; | |
| 9 | ||
| 10 | /** The state word as a badge, quiet until the client finds a passkey can run. */ | |
| 11 | export function passkeyState(): Html { | |
| 12 | return html`<span data-passkey-state>${badge("Passkey off", { variant: "outline" })}</span>`; | |
| 13 | } |
src/web/style/components.ts −5
| @@ -147,11 +147,6 @@ fieldset.field > .check + .check { margin-top: 8px; } | ||
| 147 | 147 | background: color-mix(in srgb, var(--danger) 10%, transparent); |
| 148 | 148 | border-radius: var(--radius-sm); |
| 149 | 149 | } |
| 150 | /* Where a passkey can run — a script, and a browser with WebAuthn, which | |
| 151 | passkey-client.ts marks as :root.passkey — .passkey-on shows and | |
| 152 | .passkey-off hides; everywhere else the other way round. Only ever hides, | |
| 153 | so each element keeps its own display. */ | |
| 154 | :root:not(.passkey) .passkey-on, :root.passkey .passkey-off { display: none; } | |
| 155 | 150 | |
| 156 | 151 | /* ---- stat tile ---- fixed heights: a changing value shifts nothing */ |
| 157 | 152 | .stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; } |
test/claim-link.test.ts +10
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | * for there on purpose (#839). The account a link makes has a password or a passkey (#2924). |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { insideHiddenDiv } from "./helpers/hidden.ts"; | |
| 11 | 12 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 12 | 13 | import { mkdtemp, rm } from "node:fs/promises"; |
| 13 | 14 | import { tmpdir } from "node:os"; |
| @@ -141,6 +142,15 @@ describe("the claim link in the fragment, and its re-issue", () => { | ||
| 141 | 142 | expect(html).toContain('name="token"'); |
| 142 | 143 | }); |
| 143 | 144 | |
| 145 | test("the claim page draws the passkey state as a badge, not a passkey-on class", async () => { | |
| 146 | const html = await (await fetch(`${childServers.url}/claim`)).text(); | |
| 147 | expect(html).toContain("Passkey off"); | |
| 148 | expect(html).toMatch(/<span\s+class="badge"\s+data-variant="outline"\s*>\s*Passkey off/); | |
| 149 | expect(html).not.toContain("passkey-on"); | |
| 150 | expect(html).not.toContain('class="passkey'); | |
| 151 | expect(insideHiddenDiv(html, "data-passkey-state")).toBe(false); | |
| 152 | }); | |
| 153 | ||
| 144 | 154 | test("an unclaimed forge's sign-in page leads to the claim page; the parent's does not", async () => { |
| 145 | 155 | expect(await (await fetch(`${childServers.url}/signin`)).text()).toContain('href="/claim"'); |
| 146 | 156 | expect(await (await fetch(`${parentServers.url}/signin`)).text()).not.toContain('href="/claim"'); |
test/first-admin.test.ts +10
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | * `POST /api/v1/first-admin`. Ordinary signup still takes a passkey only. |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { insideHiddenDiv } from "./helpers/hidden.ts"; | |
| 10 | 11 | import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test"; |
| 11 | 12 | import { mkdtemp, rm } from "node:fs/promises"; |
| 12 | 13 | import { tmpdir } from "node:os"; |
| @@ -283,6 +284,15 @@ describe("a person: /first-admin with a passkey", () => { | ||
| 283 | 284 | expect(await (await fetch(`${servers.url}/signin`)).text()).toContain('href="/first-admin"'); |
| 284 | 285 | }); |
| 285 | 286 | |
| 287 | test("the page draws the passkey state as a badge, not a passkey-on class", async () => { | |
| 288 | const html = await (await fetch(`${servers.url}/first-admin`)).text(); | |
| 289 | expect(html).toContain("Passkey off"); | |
| 290 | expect(html).toMatch(/<span\s+class="badge"\s+data-variant="outline"\s*>\s*Passkey off/); | |
| 291 | expect(html).not.toContain("passkey-on"); | |
| 292 | expect(html).not.toContain('class="passkey'); | |
| 293 | expect(insideHiddenDiv(html, "data-passkey-state")).toBe(false); | |
| 294 | }); | |
| 295 | ||
| 286 | 296 | test("the page holds no code and takes it from the fragment", async () => { |
| 287 | 297 | const html = await (await fetch(`${servers.url}/first-admin`)).text(); |
| 288 | 298 | expect(html).not.toContain(code); |
test/signin-page.test.ts +13 −3
| @@ -3,6 +3,7 @@ | ||
| 3 | 3 | * sentence when the forge's signup is invite-only or closed (#2882). |
| 4 | 4 | */ |
| 5 | 5 | |
| 6 | import { insideHiddenDiv } from "./helpers/hidden.ts"; | |
| 6 | 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 7 | 8 | import { mkdtemp, rm } from "node:fs/promises"; |
| 8 | 9 | import { tmpdir } from "node:os"; |
| @@ -46,14 +47,23 @@ describe("/signin", () => { | ||
| 46 | 47 | expect(page).toMatch(/<button[^>]*id="passkey-signin"[^>]*data-variant="primary"/); |
| 47 | 48 | }); |
| 48 | 49 | |
| 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 | expect(html).not.toContain("passkey-on"); | |
| 55 | expect(html).not.toContain('class="passkey'); | |
| 56 | expect(insideHiddenDiv(html, "data-passkey-state")).toBe(false); | |
| 57 | }); | |
| 58 | ||
| 49 | 59 | test("the passkey lead shows only where a passkey can run, and Sign in is primary everywhere else", async () => { |
| 50 | 60 | const page = await signin("open"); |
| 51 | expect(page).toMatch(/<div class="passkey-on stack stack-tight mb">\s*<button[^>]*id="passkey-signin"/); | |
| 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"/); | |
| 52 | 62 | expect(page).not.toMatch(/passkey-lead|passkey-note/); |
| 53 | 63 | expect(page).toMatch(/<p class="muted small break" id="passkey-signin-note" data-fail="error-note" role="status"><\/p>\s*<\/div>/); |
| 54 | 64 | // Both variants of Sign in, each a named btn() variant picked in markup. |
| 55 | expect(page).toMatch(/<button\s+type="submit"\s+class="btn passkey-on"\s+data-variant="outline"[^>]*>\s*Sign in\s*</); | |
| 56 | expect(page).toMatch(/<button\s+type="submit"\s+class="btn passkey-off"\s+data-variant="primary"[^>]*>\s*Sign in\s*</); | |
| 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 | expect(page).toMatch(/<button\s+type="submit"\s+class="btn"\s+data-variant="primary"\s+data-passkey="off">\s*Sign in\s*</); | |
| 57 | 67 | // :root.passkey is set only when the browser has WebAuthn. |
| 58 | 68 | expect(passkeyJs).toContain("if (window.PublicKeyCredential && navigator.credentials) doc.documentElement.classList.add('passkey');"); |
| 59 | 69 | }); |
new above
offline
Same: the badge is inside the hidden block; with no JS or no passkeys it is invisible.