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

Merged opened this

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

Merged

mason/forge:mason/2882-signin-leads-with-a-passkey-and-says-in → main 3 commits fork 3 threads · 3 unresolved

  • Merged. mason/2882-signin-leads-with-a-passkey-and-says-in was merged into main as 89dbf27.

Review

Sign in to review this change.
5 files changed +119 −9
docs/DESIGN.md +1 −1

Diff not shown — a line 2,427 characters wide (limit 2,000). +1 −1

View file
src/web/pages/site.ts +29 −8
@@ -47,6 +47,7 @@ import { existsSync } from "node:fs";
4747 import { join } from "node:path";
4848 import { hasAdmin } from "../../spawn/claim.ts";
4949 import { parentIssuerOf } from "../../core/federation.ts";
50 import { getSignup, type SignupMode } from "../../settings/signup.ts";
5051
5152 export function sessionCookie(
5253 value: string,
@@ -224,6 +225,13 @@ interface AuthErrors {
224225 human?: string;
225226 }
226227
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
227235 function authForm(
228236 ctx: Ctx,
229237 kind: "signin" | "signup",
@@ -274,7 +282,24 @@ function authForm(
274282 // a passkey, made in the browser (`signup-passkey.ts`).
275283 const byPasskey = kind === "signup" && ctx.app.config.passkeyOnlySignup === true;
276284
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(
outdated

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.

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
277301 const body = html`${throughEdge}
302 ${passkeyFirst}
278303 <form method="post" action="${action}">
279304 ${formField({
280305 label: "Username",
@@ -349,9 +374,9 @@ function authForm(
349374 <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.

350375 ${byPasskey
351376 ? 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" })}
355380 <span class="spacer"></span>
356381 ${kind === "signin"
357382 ? btn("Need an account? Sign up", {
@@ -377,11 +402,7 @@ function authForm(
377402 ${card(body)}
378403 ${when(
379404 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>`,
385406 )}
386407 ${when(
387408 kind === "signin" ? parentIssuerOf(ctx.app.config) : undefined,
src/web/passkey-client.ts +2
@@ -6,6 +6,8 @@
66 export const passkeyJs = /* js */ `
77 (function () {
88 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');
911 function bytes(s) {
1012 s = s.replace(/-/g, '+').replace(/_/g, '/');
1113 while (s.length % 4) s += '=';
src/web/style/components.ts +10
@@ -138,6 +138,16 @@ fieldset.field > .check + .check { margin-top: 8px; }
138138 background: color-mix(in srgb, var(--danger) 10%, transparent);
139139 border-radius: var(--radius-sm);
140140 }
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 .btn[data-variant=primary], with !important-style resets for box-shadow and opacity. docs/DESIGN.md wants the named btn() variants, not hand-rolled restyles. Pick the variant in markup instead, and handle the no-WebAuthn case so the page always has one primary action.

149 .passkey-lead > .btn { width: 100%; }
150 .passkey-lead > .passkey-note:not(:empty) { margin: 8px 0 0; }
141151
142152 /* ---- badge / label chip / pill / dot ---- */
143153 .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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs