Public: anyone can read this; the crew can change it.
/signin leads with a passkey and says in one sentence when signup is invite-only or closed #2886
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/2882-signin-leads-with-a-passkey-and-says-in → main 3 commits fork 3 threads · 3 unresolved
docs/DESIGN.md +1 −1
Diff not shown — a line 2,427 characters wide (limit 2,000). +1 −1
View filesrc/web/pages/site.ts +29 −8
| @@ -47,6 +47,7 @@ import { existsSync } from "node:fs"; | ||
| 47 | 47 | import { join } from "node:path"; |
| 48 | 48 | import { hasAdmin } from "../../spawn/claim.ts"; |
| 49 | 49 | import { parentIssuerOf } from "../../core/federation.ts"; |
| 50 | import { getSignup, type SignupMode } from "../../settings/signup.ts"; | |
| 50 | 51 | |
| 51 | 52 | export function sessionCookie( |
| 52 | 53 | value: string, |
| @@ -224,6 +225,13 @@ interface AuthErrors { | ||
| 224 | 225 | human?: string; |
| 225 | 226 | } |
| 226 | 227 | |
| 228 | /** What `/signin` says about new accounts in each signup mode; open says nothing. */ | |
| 229 | const SIGNUP_SENTENCES: Record<SignupMode, string | undefined> = { | |
| 230 | open: undefined, | |
| 231 | invite: "This forge is invite-only: new accounts need an invite link.", | |
| 232 | closed: "This forge is closed to new accounts.", | |
| 233 | }; | |
| 234 | ||
| 227 | 235 | function authForm( |
| 228 | 236 | ctx: Ctx, |
| 229 | 237 | kind: "signin" | "signup", |
| @@ -274,7 +282,24 @@ function authForm( | ||
| 274 | 282 | // a passkey, made in the browser (`signup-passkey.ts`). |
| 275 | 283 | const byPasskey = kind === "signup" && ctx.app.config.passkeyOnlySignup === true; |
| 276 | 284 | |
| 285 | // Sign-in leads with the passkey: one press, nothing typed; the password | |
| 286 | // form below stays for accounts that were given one. The button needs a | |
| 287 | // script and a browser with passkeys, so the block is `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. | |
| 292 | const passkeyFirst = when( | |
| 293 | kind === "signin", | |
| 294 | () => html`<div class="passkey-on passkey-lead"> | |
| 295 | <button type="button" id="passkey-signin" class="btn" data-variant="primary" data-size="lg">Sign in with a passkey</button> | |
| 296 | <p class="passkey-note small" id="passkey-signin-note" role="status"></p> | |
| 297 | </div> | |
| 298 | ${when(SIGNUP_SENTENCES[getSignup(ctx.app.db)], (said) => html`<p class="muted small mb" id="signup-mode">${said}</p>`)}`, | |
| 299 | ); | |
| 300 | ||
| 277 | 301 | const body = html`${throughEdge} |
| 302 | ${passkeyFirst} | |
| 278 | 303 | <form method="post" action="${action}"> |
| 279 | 304 | ${formField({ |
| 280 | 305 | label: "Username", |
| @@ -349,9 +374,9 @@ function authForm( | ||
| 349 | 374 | <div class="form-actions"> |
outdated
Demoting Sign in to outline unconditionally leaves a no-JS page with no primary action. Tie it to the passkey being shown, or make the outline styling apply only under :root.js. |
||
| 350 | 375 | ${byPasskey |
| 351 | 376 | ? html`<button type="button" id="passkey-signup" class="btn" data-variant="primary">Create account with a passkey</button>` |
| 352 | : btn(kind === "signin" ? "Sign in" : "Create account", { | |
| 353 | variant: "primary", | |
| 354 | })} | |
| 377 | : kind === "signin" | |
| 378 | ? html`${btn("Sign in", { variant: "outline", extra: "passkey-on" })}${btn("Sign in", { variant: "primary", extra: "passkey-off" })}` | |
| 379 | : btn("Create account", { variant: "primary" })} | |
| 355 | 380 | <span class="spacer"></span> |
| 356 | 381 | ${kind === "signin" |
| 357 | 382 | ? btn("Need an account? Sign up", { |
| @@ -377,11 +402,7 @@ function authForm( | ||
| 377 | 402 | ${card(body)} |
| 378 | 403 | ${when( |
| 379 | 404 | kind === "signin", |
| 380 | () => html`<p class="mt"> | |
| 381 | <button type="button" id="passkey-signin" class="btn" data-variant="outline">Sign in with a passkey</button> | |
| 382 | <span class="error-note small" id="passkey-signin-note" role="status"></span> | |
| 383 | <a class="small" href="/recover">Lost your device?</a> | |
| 384 | </p>`, | |
| 405 | () => html`<p class="mt"><a class="small" href="/recover">Lost your device?</a></p>`, | |
| 385 | 406 | )} |
| 386 | 407 | ${when( |
| 387 | 408 | kind === "signin" ? parentIssuerOf(ctx.app.config) : undefined, |
src/web/passkey-client.ts +2
| @@ -6,6 +6,8 @@ | ||
| 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. | |
| 10 | if (window.PublicKeyCredential && navigator.credentials) doc.documentElement.classList.add('passkey'); | |
| 9 | 11 | function bytes(s) { |
| 10 | 12 | s = s.replace(/-/g, '+').replace(/_/g, '/'); |
| 11 | 13 | while (s.length % 4) s += '='; |
src/web/style/components.ts +10
| @@ -138,6 +138,16 @@ fieldset.field > .check + .check { margin-top: 8px; } | ||
| 138 | 138 | background: color-mix(in srgb, var(--danger) 10%, transparent); |
| 139 | 139 | border-radius: var(--radius-sm); |
| 140 | 140 | } |
| 141 | /* Where a passkey can run — a script, and a browser with WebAuthn, which | |
| 142 | passkey-client.ts marks as :root.passkey — .passkey-on shows and | |
| 143 | .passkey-off hides; everywhere else the other way round. Only ever hides, | |
| 144 | so each element keeps its own display. */ | |
| 145 | :root:not(.passkey) .passkey-on, :root.passkey .passkey-off { display: none; } | |
| 146 | /* /signin's passkey lead: a full-width button whose note takes no room until | |
| 147 | it says something. */ | |
| 148 | .passkey-lead { margin-bottom: 16px; } | |
outdated
This copies the outline button's colours (background, border, hover) into a one-off override of |
||
| 149 | .passkey-lead > .btn { width: 100%; } | |
| 150 | .passkey-lead > .passkey-note:not(:empty) { margin: 8px 0 0; } | |
| 141 | 151 | |
| 142 | 152 | /* ---- badge / label chip / pill / dot ---- */ |
| 143 | 153 | .badge { |
test/signin-page.test.ts added +77
| @@ -0,0 +1,77 @@ | ||
| 1 | /** | |
| 2 | * `/signin` leads with the passkey, before the password form, and says in one | |
| 3 | * sentence when the forge's signup is invite-only or closed (#2882). | |
| 4 | */ | |
| 5 | ||
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { setSignup, type SignupMode } from "../src/settings/signup.ts"; | |
| 12 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 13 | import { passkeyJs } from "../src/web/passkey-client.ts"; | |
| 14 | ||
| 15 | const INVITE = "This forge is invite-only: new accounts need an invite link."; | |
| 16 | const CLOSED = "This forge is closed to new accounts."; | |
| 17 | ||
| 18 | describe("/signin", () => { | |
| 19 | let dataDir: string; | |
| 20 | let app: App; | |
| 21 | let servers: Servers; | |
| 22 | ||
| 23 | beforeAll(async () => { | |
| 24 | dataDir = await mkdtemp(join(tmpdir(), "forge-signin-page-")); | |
| 25 | app = createApp({ dataDir, port: 0, url: "https://forge.example" }); | |
| 26 | servers = await serve(app); | |
| 27 | }); | |
| 28 | afterAll(async () => { | |
| 29 | await servers.stop(); | |
| 30 | app.close(); | |
| 31 | await rm(dataDir, { recursive: true, force: true }); | |
| 32 | }); | |
| 33 | ||
| 34 | const signin = async (mode: SignupMode) => { | |
| 35 | setSignup(app.db, mode, "boot"); | |
| 36 | const res = await fetch(`${servers.url}/signin`); | |
| 37 | expect(res.status).toBe(200); | |
| 38 | return res.text(); | |
| 39 | }; | |
| 40 | ||
| 41 | test("the passkey is the primary button and comes before the password form", async () => { | |
| 42 | const page = await signin("open"); | |
| 43 | const passkey = page.indexOf('id="passkey-signin"'); | |
| 44 | expect(passkey).toBeGreaterThan(-1); | |
| 45 | expect(passkey).toBeLessThan(page.indexOf('<form method="post" action="/signin"')); | |
| 46 | expect(page).toMatch(/<button[^>]*id="passkey-signin"[^>]*data-variant="primary"/); | |
| 47 | }); | |
| 48 | ||
| 49 | test("the passkey lead shows only where a passkey can run, and Sign in is primary everywhere else", async () => { | |
| 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>/); | |
| 53 | // Both variants of Sign in, each a named btn() variant picked in markup. | |
| 54 | expect(page).toMatch(/<button\s+type="submit"\s+class="btn passkey-on"\s+data-variant="outline"[^>]*>\s*Sign in\s*</); | |
| 55 | expect(page).toMatch(/<button\s+type="submit"\s+class="btn passkey-off"\s+data-variant="primary"[^>]*>\s*Sign in\s*</); | |
| 56 | // :root.passkey is set only when the browser has WebAuthn. | |
| 57 | expect(passkeyJs).toContain("if (window.PublicKeyCredential && navigator.credentials) doc.documentElement.classList.add('passkey');"); | |
| 58 | }); | |
| 59 | ||
| 60 | test("open says neither sentence", async () => { | |
| 61 | const page = await signin("open"); | |
| 62 | expect(page).not.toContain(INVITE); | |
| 63 | expect(page).not.toContain(CLOSED); | |
| 64 | }); | |
| 65 | ||
| 66 | test("invite says the forge is invite-only", async () => { | |
| 67 | const page = await signin("invite"); | |
| 68 | expect(page).toContain(INVITE); | |
| 69 | expect(page).not.toContain(CLOSED); | |
| 70 | }); | |
| 71 | ||
| 72 | test("closed says the forge is closed to new accounts", async () => { | |
| 73 | const page = await signin("closed"); | |
| 74 | expect(page).toContain(CLOSED); | |
| 75 | expect(page).not.toContain(INVITE); | |
| 76 | }); | |
| 77 | }); |
This button needs JS. Add class js-only (button and note) so a no-JS visitor does not get a dead primary button at the top. Then the password Sign in has to stay the visible primary without JS.