Public: anyone can read this; the crew can change it.

Sign-in's passkey lead and note draw §5's btn and muted text, and .passkey-lead, .passkey-note leave the markup #4301

Merged opened this

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

Merged

scout/forge:scout/4271-sign-in-s-passkey-lead-and-note-draw-5-s → main 3 commits fork

  • Merged. scout/4271-sign-in-s-passkey-lead-and-note-draw-5-s was merged into main as 251b6a0.

Review

Sign in to review this change.
3 files changed +68 −6
src/web/pages/site.ts +2 −2
@@ -298,9 +298,9 @@ function authForm(
298298 // newcomer before they look for a door that is not there.
299299 const passkeyFirst = when(
300300 kind === "signin",
301 () => html`<div class="passkey-on passkey-lead">
301 () => html`<div class="passkey-on stack stack-tight mb">
302302 <button type="button" id="passkey-signin" class="btn" data-variant="primary" data-size="lg">Sign in with a passkey</button>
303 <p class="passkey-note small" id="passkey-signin-note" role="status"></p>
303 <p class="muted small break" id="passkey-signin-note" data-fail="error-note" role="status"></p>
304304 </div>
305305 ${when(SIGNUP_SENTENCES[timePhase(ctx.request, "settings", () => getSignup(ctx.app.db))], (said) => html`<p class="muted small mb" id="signup-mode">${said}</p>`)}`,
306306 );
src/web/passkey-client.ts +13 −2
@@ -33,6 +33,16 @@ export const passkeyJs = /* js */ `
3333 });
3434 });
3535 }
36 // A failure is an alert in the danger style, the class the note names in
37 // data-fail; a cancel or a clear goes back to quiet.
38 function say(note, msg, failed) {
39 if (!note) return;
40 var danger = note.getAttribute('data-fail');
41 note.textContent = msg;
42 if (danger) note.classList.toggle(danger, failed);
43 note.classList.toggle('muted', !failed);
44 if (failed) note.setAttribute('role', 'alert'); else note.setAttribute('role', 'status');
45 }
3646 function wire(id, run) {
3747 var el = doc.getElementById(id);
3848 if (!el) return;
@@ -45,9 +55,10 @@ export const passkeyJs = /* js */ `
4555 el.addEventListener('click', function (e) {
4656 e.preventDefault();
4757 el.disabled = true;
48 if (note) note.textContent = '';
58 say(note, '', false);
4959 run().catch(function (err) {
50 if (note) note.textContent = err && err.name === 'NotAllowedError' ? 'Cancelled.' : (err && err.message) || 'That did not work.';
60 var cancelled = err && err.name === 'NotAllowedError';
61 say(note, cancelled ? 'Cancelled.' : (err && err.message) || 'That did not work.', !cancelled);
5162 el.disabled = false;
5263 });
5364 });
test/signin-page.test.ts +53 −2
@@ -48,8 +48,9 @@ describe("/signin", () => {
4848
4949 test("the passkey lead shows only where a passkey can run, and Sign in is primary everywhere else", async () => {
5050 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>/);
51 expect(page).toMatch(/<div class="passkey-on stack stack-tight mb">\s*<button[^>]*id="passkey-signin"/);
52 expect(page).not.toMatch(/passkey-lead|passkey-note/);
53 expect(page).toMatch(/<p class="muted small break" id="passkey-signin-note" data-fail="error-note" role="status"><\/p>\s*<\/div>/);
5354 // Both variants of Sign in, each a named btn() variant picked in markup.
5455 expect(page).toMatch(/<button\s+type="submit"\s+class="btn passkey-on"\s+data-variant="outline"[^>]*>\s*Sign in\s*</);
5556 expect(page).toMatch(/<button\s+type="submit"\s+class="btn passkey-off"\s+data-variant="primary"[^>]*>\s*Sign in\s*</);
@@ -75,3 +76,53 @@ describe("/signin", () => {
7576 expect(page).not.toContain(INVITE);
7677 });
7778 });
79
80 describe("the passkey note", () => {
81 // Runs the client script against a stub DOM: click Sign in with a passkey and let it fail.
82 async function press(fail: { name?: string; message: string }) {
83 const classes = new Set(["muted", "small", "break"]);
84 const attrs: Record<string, string> = { role: "status" };
85 const note = {
86 textContent: "",
87 classList: {
88 toggle: (c: string, on: boolean) => void (on ? classes.add(c) : classes.delete(c)),
89 add: (c: string) => void classes.add(c),
90 },
91 setAttribute: (k: string, v: string) => void (attrs[k] = v),
92 getAttribute: (k: string) => (k === "data-fail" ? "error-note" : null),
93 };
94 let click: (e: { preventDefault(): void }) => void = () => {};
95 const button = { disabled: false, addEventListener: (_: string, f: typeof click) => void (click = f) };
96 const doc = {
97 documentElement: { classList: { add() {} } },
98 getElementById: (id: string) => (id === "passkey-signin" ? button : id === "passkey-signin-note" ? note : null),
99 };
100 const nav = { credentials: {} };
101 const fetchFails = async () => {
102 throw Object.assign(new Error(fail.message), fail.name ? { name: fail.name } : {});
103 };
104 new Function("document", "window", "navigator", "fetch", passkeyJs.replace(/^[\s\S]*?\(function \(\) \{/, "(function () {").replace(/\}\)\s*$/, ""))(
105 doc, { PublicKeyCredential: {} }, nav, fetchFails,
106 );
107 click({ preventDefault() {} });
108 await new Promise((r) => setTimeout(r, 0));
109 return { note, classes, attrs, button };
110 }
111
112 test("a failure is an alert in the danger style, not muted text", async () => {
113 const { note, classes, attrs, button } = await press({ message: "That did not work." });
114 expect(note.textContent).toBe("That did not work.");
115 expect(classes.has("error-note")).toBe(true);
116 expect(classes.has("muted")).toBe(false);
117 expect(attrs.role).toBe("alert");
118 expect(button.disabled).toBe(false);
119 });
120
121 test("a cancel stays quiet", async () => {
122 const { note, classes, attrs } = await press({ name: "NotAllowedError", message: "x" });
123 expect(note.textContent).toBe("Cancelled.");
124 expect(classes.has("error-note")).toBe(false);
125 expect(classes.has("muted")).toBe(true);
126 expect(attrs.role).toBe("status");
127 });
128 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs