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

Merged opened this

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

Merged

scout/forge:scout/4698-the-passkey-state-on-the-site-claim-and → main 2 commits fork 3 threads · 3 unresolved

  • Merged. scout/4698-the-passkey-state-on-the-site-claim-and was merged into main as eff07d3.

Review

Sign in to review this change.
11 files changed +80 −15
docs/DESIGN.md +1 −1
@@ -393,7 +393,7 @@ gone, so this list only shrinks.
393393
394394 - **`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`
395395 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`
397397 - **`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`
398398 - **`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`
399399 - **`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";
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";
5859 import { btn, card, field as formField } from "../ui.ts";
5960 import { sessionCookie, signinAllowed } from "./site.ts";
6061
@@ -193,13 +194,16 @@ function claimForm(
193194 <div class="form-actions">
194195 <button type="submit" class="btn" data-variant="primary">Claim this forge</button>
195196 </div>
196 <div class="passkey-on">
197 <div class="stack stack-tight">
198 ${passkeyState()}

Same: the badge is inside the hidden block; with no JS or no passkeys it is invisible.

199 <div data-passkey="on" hidden>
197200 <p class="muted small mb">
198201 Or skip the password: a passkey signs you in with a fingerprint,
199202 face or device PIN. It needs an email address above.
200203 </p>
201204 <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>
202205 <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>
203207 </div>
204208 </form>`,
205209 )}
src/web/pages/first-admin.ts +2
@@ -23,6 +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";
2627 import { card, field as formField } from "../ui.ts";
2728 import { sessionCookie } from "./site.ts";
2829
@@ -74,6 +75,7 @@ function form(values: Values, errors: Errors): Html {
7475 ...(errors.email ? { error: errors.email } : {}),
7576 })}
7677 ${passkeyFields()}
78 <div class="stack stack-tight mb">${passkeyState()}</div>
7779 <p class="muted small mb">
7880 You sign in with a passkey: a fingerprint, face or device PIN.
7981 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";
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";
3738 import { html, when, type Html } from "../html.ts";
3839 import { icon } from "../icons.ts";
3940 import { flashTo, page, redirect } from "../layout.ts";
@@ -289,16 +290,19 @@ function authForm(
289290
290291 // Sign-in leads with the passkey: one press, nothing typed; the password
291292 // 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
293294 // only once `passkey-client.ts` marks `:root.passkey`. The form's Sign in
294295 // comes in both variants, outline beside the lead and primary everywhere
295296 // else, so the page always has one primary action. The sentence tells a
296297 // newcomer before they look for a door that is not there.
297298 const passkeyFirst = when(
298299 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 hidden wrapper, so "Passkey off" never shows. Render it outside the hidden block.

302 <div class="stack stack-tight" data-passkey="on" hidden>
300303 <button type="button" id="passkey-signin" class="btn" data-variant="primary" data-size="lg">Sign in with a passkey</button>
301304 <p class="muted small break" id="passkey-signin-note" data-fail="error-note" role="status"></p>
305 </div>
302306 </div>
303307 ${when(SIGNUP_SENTENCES[timePhase(ctx.request, "settings", () => getSignup(ctx.app.db))], (said) => html`<p class="muted small mb" id="signup-mode">${said}</p>`)}`,
304308 );
@@ -366,7 +370,7 @@ function authForm(
366370 )}
367371 ${when(
368372 kind === "signup" && !byPasskey,
369 () => html`<div class="passkey-on">
373 () => html`<div data-passkey="on" hidden>
370374 <p class="muted small mb">
371375 Or skip the password: a passkey signs you in with a fingerprint,
372376 face or device PIN. An email address above is optional.
@@ -391,7 +395,7 @@ function authForm(
391395 ${byPasskey
392396 ? html`<button type="button" id="passkey-signup" class="btn" data-variant="primary">Create account with a passkey</button>`
393397 : 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>`
395399 : btn("Create account", { variant: "primary" })}
396400 <span class="spacer"></span>
397401 ${kind === "signin"
src/web/passkey-client.ts +7 −1
@@ -6,8 +6,14 @@
66 export const passkeyJs = /* js */ `
77 (function () {
88 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); }
1011 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; });

each is used under a guard that checks doc.querySelectorAll after the call site is defined; drop the redundant check or move it into each.

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 }
1117 function bytes(s) {
1218 s = s.replace(/-/g, '+').replace(/_/g, '/');
1319 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; }
147147 background: color-mix(in srgb, var(--danger) 10%, transparent);
148148 border-radius: var(--radius-sm);
149149 }
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; }
155150
156151 /* ---- stat tile ---- fixed heights: a changing value shifts nothing */
157152 .stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; }
test/first-admin.test.ts +10
@@ -7,6 +7,7 @@
77 * `POST /api/v1/first-admin`. Ordinary signup still takes a passkey only.
88 */
99
10 import { insideHiddenDiv } from "./helpers/hidden.ts";
1011 import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test";
1112 import { mkdtemp, rm } from "node:fs/promises";
1213 import { tmpdir } from "node:os";
@@ -283,6 +284,15 @@ describe("a person: /first-admin with a passkey", () => {
283284 expect(await (await fetch(`${servers.url}/signin`)).text()).toContain('href="/first-admin"');
284285 });
285286
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
286296 test("the page holds no code and takes it from the fragment", async () => {
287297 const html = await (await fetch(`${servers.url}/first-admin`)).text();
288298 expect(html).not.toContain(code);
test/helpers/hidden.ts added +11
@@ -0,0 +1,11 @@
1 /** Whether the first `needle` in `html` sits inside a `<div … hidden>`. */
2 export function insideHiddenDiv(html: string, needle: string): boolean {
3 const at = html.indexOf(needle);
4 if (at < 0) throw new Error(`${needle} not in the page`);
5 const open: boolean[] = [];
6 for (const m of html.slice(0, at).matchAll(/<(\/?)div\b([^>]*)>/g)) {
7 if (m[1]) open.pop();
8 else open.push(/\shidden(\s|$|=)/.test(m[2] ?? ""));
9 }
10 return open.some(Boolean);
11 }
test/signin-page.test.ts +13 −3
@@ -3,6 +3,7 @@
33 * sentence when the forge's signup is invite-only or closed (#2882).
44 */
55
6 import { insideHiddenDiv } from "./helpers/hidden.ts";
67 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
78 import { mkdtemp, rm } from "node:fs/promises";
89 import { tmpdir } from "node:os";
@@ -46,14 +47,23 @@ describe("/signin", () => {
4647 expect(page).toMatch(/<button[^>]*id="passkey-signin"[^>]*data-variant="primary"/);
4748 });
4849
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
4959 test("the passkey lead shows only where a passkey can run, and Sign in is primary everywhere else", async () => {
5060 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"/);
5262 expect(page).not.toMatch(/passkey-lead|passkey-note/);
5363 expect(page).toMatch(/<p class="muted small break" id="passkey-signin-note" data-fail="error-note" role="status"><\/p>\s*<\/div>/);
5464 // 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*</);
5767 // :root.passkey is set only when the browser has WebAuthn.
5868 expect(passkeyJs).toContain("if (window.PublicKeyCredential && navigator.credentials) doc.documentElement.classList.add('passkey');");
5969 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs