Public: anyone can read this; the crew can change it.
A passkey sign-up needs no email, as a password sign-up already does #3048
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3044-a-passkey-sign-up-needs-no-email-as-a-pa → main 1 commit fork
4 files
changed +23
−6
src/actions/users.ts +5 −2
| @@ -188,9 +188,11 @@ export async function signUpWithPasskey( | ||
| 188 | 188 | return err("human_check"); |
| 189 | 189 | } |
| 190 | 190 | if (findPasskey(app.db, input.passkey.id)) return err("passkey_taken"); |
| 191 | const email = input.email.trim(); | |
| 192 | if (email !== "" && !givenEmail(email)) return err("invalid_email"); | |
| 191 | 193 | const created = await createUser(app.db, { |
| 192 | 194 | username: input.username, |
| 193 | email: input.email, | |
| 195 | email: email === "" ? noEmail(input.username) : email, | |
| 194 | 196 | origin: "passkey", |
| 195 | 197 | slot: from.slot, |
| 196 | 198 | invite: from.invite, |
| @@ -210,7 +212,8 @@ export async function signUpWithPasskey( | ||
| 210 | 212 | }, |
| 211 | 213 | }); |
| 212 | 214 | auditInvite(app, created.value.id); |
| 213 | await welcome(app, created.value); | |
| 215 | // As on the password path: no address given, no link to send. | |
| 216 | if (email !== "") await welcome(app, created.value); | |
| 214 | 217 | return created; |
| 215 | 218 | } |
| 216 | 219 |
src/web/pages/signup-passkey.ts +2 −2
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import { newChallenge, verifyRegistration } from "../../auth/webauthn.ts"; |
| 10 | import { plausibleEmail, signUpWithPasskey, signupClosed, HUMAN_CHECK_MESSAGE, SIGNUP_CLOSED_MESSAGE } from "../../actions/users.ts"; | |
| 10 | import { givenEmail, signUpWithPasskey, signupClosed, HUMAN_CHECK_MESSAGE, SIGNUP_CLOSED_MESSAGE } from "../../actions/users.ts"; | |
| 11 | 11 | import { createSession } from "../../core/sessions.ts"; |
| 12 | 12 | import { getUserByName } from "../../core/users.ts"; |
| 13 | 13 | import { isSafeName } from "../../git/guard.ts"; |
| @@ -39,7 +39,7 @@ const WORDS: Record<string, string> = { | ||
| 39 | 39 | function problem(ctx: Ctx, username: string, email: string): string | undefined { |
| 40 | 40 | if (!isSafeName(username)) return WORDS.invalid_username; |
| 41 | 41 | if (!pinnedTo(ctx, username, inviteOf(ctx))) return WORDS.edge_name; |
| 42 | if (!plausibleEmail(email)) return WORDS.invalid_email; | |
| 42 | if (email !== "" && !givenEmail(email)) return WORDS.invalid_email; | |
| 43 | 43 | if (getUserByName(ctx.app.db, username)) return WORDS.username_taken; |
| 44 | 44 | return undefined; |
| 45 | 45 | } |
src/web/pages/site.ts +1 −1
| @@ -369,7 +369,7 @@ function authForm( | ||
| 369 | 369 | () => html`<div class="passkey-on"> |
| 370 | 370 | <p class="muted small mb"> |
| 371 | 371 | Or skip the password: a passkey signs you in with a fingerprint, |
| 372 | face or device PIN. It needs an email address above. | |
| 372 | face or device PIN. An email address above is optional. | |
| 373 | 373 | </p> |
| 374 | 374 | <p class="error-note mb" id="passkey-signup-note" role="alert"></p> |
| 375 | 375 | <button type="button" id="passkey-signup" class="btn" data-variant="outline">Create account with a passkey</button> |
test/signup-passkey.test.ts +15 −1
| @@ -10,7 +10,8 @@ import { tmpdir } from "node:os"; | ||
| 10 | 10 | import { join } from "node:path"; |
| 11 | 11 | import { createApp, type App } from "../src/app.ts"; |
| 12 | 12 | import { signUp } from "../src/actions/users.ts"; |
| 13 | import { createUser } from "../src/core/users.ts"; | |
| 13 | import { requireVerified } from "../src/actions/verify.ts"; | |
| 14 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 14 | 15 | import { setLimit } from "../src/settings/limits.ts"; |
| 15 | 16 | import { errorText } from "../src/tui/screens/common.ts"; |
| 16 | 17 | import { serve, type Servers } from "../src/http/server.ts"; |
| @@ -51,6 +52,19 @@ describe("passkey sign-up", () => { | ||
| 51 | 52 | await rm(dataDir, { recursive: true, force: true }); |
| 52 | 53 | }); |
| 53 | 54 | |
| 55 | test("a passkey with no email makes the account, as a password does, and signs in", async () => { | |
| 56 | const d = await device(origin(), rp()); | |
| 57 | const res = await signUpWith(d, "wren", ""); | |
| 58 | expect(res.status).toBe(201); | |
| 59 | const set = res.headers.get("set-cookie")!; | |
| 60 | expect(set).toContain("forge_session="); | |
| 61 | const user = getUserByName(app.db, "wren")!; | |
| 62 | expect(user.email).toBe("[email protected]"); | |
| 63 | expect(requireVerified(app, user)).toEqual({ ok: false, error: "unverified" }); | |
| 64 | const mine = await fetch(`${base}/settings/passkeys`, { headers: { cookie: set.split(";")[0]! } }); | |
| 65 | expect(mine.status).toBe(200); | |
| 66 | }); | |
| 67 | ||
| 54 | 68 | test("a passkey and an email make the account, its key and a session", async () => { |
| 55 | 69 | const d = await device(origin(), rp()); |
| 56 | 70 | const res = await signUpWith(d, "nova", "[email protected]"); |
new above
offline