Public: anyone can read this; the crew can change it.
Sign-up's username and taken-email sentences live in one module, read by every way an account is made #5608
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/5592-sign-up-s-username-and-taken-email-sente → main 2 commits fork 1 thread · 1 unresolved
7 files
changed +52
−7
src/actions/first_admin.ts +3 −2
| @@ -30,6 +30,7 @@ import { bucketOf } from "../http/peer.ts"; | ||
| 30 | 30 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 31 | 31 | import { limitValue } from "../settings/limits.ts"; |
| 32 | 32 | import { handOver, spawnedOrg } from "../spawn/claim.ts"; |
| 33 | import { EMAIL_TAKEN_MESSAGE, INVALID_USERNAME_MESSAGE } from "./signup_words.ts"; | |
| 33 | 34 | |
| 34 | 35 | const USERNAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,38}$/; |
| 35 | 36 | |
| @@ -48,10 +49,10 @@ export const FIRST_ADMIN_WORDS: Record<FirstAdminError, string> = { | ||
| 48 | 49 | rate_limited: "Too many attempts from here. Wait a minute and try again.", |
| 49 | 50 | refused: |
| 50 | 51 | "That first-admin code does not work: it may be mistyped, used, expired or replaced, or this forge already has an admin. Ask the operator to run --first-admin again.", |
| 51 | invalid_username: "Usernames are letters, digits, dots and dashes.", | |
| 52 | invalid_username: INVALID_USERNAME_MESSAGE, | |
| 52 | 53 | invalid_email: "An email address, please.", |
| 53 | 54 | username_taken: "That username is taken.", |
| 54 | email_taken: "That email already has an account.", | |
| 55 | email_taken: EMAIL_TAKEN_MESSAGE, | |
| 55 | 56 | username_reserved: "That name is reserved.", |
| 56 | 57 | unfinished: "The account could not be finished. Try again.", |
| 57 | 58 | }; |
src/actions/signup_words.ts added +12
| @@ -0,0 +1,12 @@ | ||
| 1 | /** | |
| 2 | * The sentences every way of making an account says when a username is | |
| 3 | * malformed or an email is already held: the sign-up form, the passkey | |
| 4 | * sign-up, a claim, an everyday invite and the first-admin door. One place, | |
| 5 | * so they read the same wherever they are said. | |
| 6 | */ | |
| 7 | ||
| 8 | /** A username outside the safe-name alphabet. */ | |
| 9 | export const INVALID_USERNAME_MESSAGE = "Usernames are letters, digits, dots and dashes."; | |
| 10 | ||
| 11 | /** An email that already belongs to an account. */ | |
| 12 | export const EMAIL_TAKEN_MESSAGE = "That email already has an account."; |
src/web/pages/claim.ts +2 −1
| @@ -58,6 +58,7 @@ import { passkeyJs } from "../passkey-client.ts"; | ||
| 58 | 58 | import { passkeyNote, passkeyState } from "../passkey-state.ts"; |
| 59 | 59 | import { btn, card, field as formField } from "../ui.ts"; |
| 60 | 60 | import { sessionCookie, signinAllowed } from "./site.ts"; |
| 61 | import { EMAIL_TAKEN_MESSAGE } from "../../actions/signup_words.ts"; | |
| 61 | 62 | |
| 62 | 63 | const USERNAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,38}$/; |
| 63 | 64 | |
| @@ -345,7 +346,7 @@ export async function claimSubmit(ctx: Ctx): Promise<Response> { | ||
| 345 | 346 | unburnClaim(ctx.app.db, orgId, token); |
| 346 | 347 | return again( |
| 347 | 348 | made.error === "email_taken" |
| 348 | ? { email: "That email already has an account." } | |
| 349 | ? { email: EMAIL_TAKEN_MESSAGE } | |
| 349 | 350 | : made.error === "username_reserved" |
| 350 | 351 | ? { username: "That name is reserved." } |
| 351 | 352 | : { username: "That username is taken." }, |
src/web/pages/everyday.ts +2 −1
| @@ -33,6 +33,7 @@ import { passkeyNote } from "../passkey-state.ts"; | ||
| 33 | 33 | import { btn, card, field as formField } from "../ui.ts"; |
| 34 | 34 | import { signOutHere } from "../../actions/signins.ts"; |
| 35 | 35 | import { sessionCookie } from "./site.ts"; |
| 36 | import { EMAIL_TAKEN_MESSAGE } from "../../actions/signup_words.ts"; | |
| 36 | 37 | |
| 37 | 38 | const USERNAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,38}$/; |
| 38 | 39 | |
| @@ -193,7 +194,7 @@ export async function everydayCreate(ctx: Ctx): Promise<Response> { | ||
| 193 | 194 | made.error === "username_taken" |
| 194 | 195 | ? "That username is taken." |
| 195 | 196 | : made.error === "email_taken" |
| 196 | ? "That email already has an account." | |
| 197 | ? EMAIL_TAKEN_MESSAGE | |
| 197 | 198 | : made.error === "username_reserved" |
| 198 | 199 | ? "That name is taken by the forge itself; pick another." |
| 199 | 200 | : "That account could not be made.", |
src/web/pages/signup-passkey.ts +3 −2
| @@ -17,6 +17,7 @@ import { clientAddress, clientBucket } from "../../http/peer.ts"; | ||
| 17 | 17 | import { boundedBody, json, str } from "../../http/respond.ts"; |
| 18 | 18 | import { relying, store } from "./passkeys.ts"; |
| 19 | 19 | import { EDGE_NAME_MESSAGE, inviteOf, pinnedTo, safeNext, sessionCookie, signinAllowed, signupSlot } from "./site.ts"; |
| 20 | import { EMAIL_TAKEN_MESSAGE, INVALID_USERNAME_MESSAGE } from "../../actions/signup_words.ts"; | |
| 20 | 21 | import { PASSKEY_EXPIRED, PASSKEY_REJECTED, PASSKEY_TAKEN } from "../passkey-words.ts"; |
| 21 | 22 | |
| 22 | 23 | const refuse = (status: number, message: string) => |
| @@ -24,10 +25,10 @@ const refuse = (status: number, message: string) => | ||
| 24 | 25 | |
| 25 | 26 | /** The words for each way a sign-up can fail, written for anyone. */ |
| 26 | 27 | const WORDS: Record<string, string> = { |
| 27 | invalid_username: "Usernames are letters, digits, dots and dashes.", | |
| 28 | invalid_username: INVALID_USERNAME_MESSAGE, | |
| 28 | 29 | username_taken: "That username is taken.", |
| 29 | 30 | username_reserved: "That name is reserved.", |
| 30 | email_taken: "That email already has an account.", | |
| 31 | email_taken: EMAIL_TAKEN_MESSAGE, | |
| 31 | 32 | invalid_email: "That does not look like an email address.", |
| 32 | 33 | edge_name: EDGE_NAME_MESSAGE, |
| 33 | 34 | human_check: HUMAN_CHECK_MESSAGE, |
src/web/pages/site.ts +2 −1
| @@ -54,6 +54,7 @@ import { existsSync } from "node:fs"; | ||
| 54 | 54 | import { join } from "node:path"; |
| 55 | 55 | import { hasAdmin } from "../../spawn/claim.ts"; |
| 56 | 56 | import { getSignup, type SignupMode } from "../../settings/signup.ts"; |
| 57 | import { EMAIL_TAKEN_MESSAGE } from "../../actions/signup_words.ts"; | |
| 57 | 58 | |
| 58 | 59 | export function sessionCookie( |
| 59 | 60 | value: string, |
| @@ -662,7 +663,7 @@ export async function signupSubmit(ctx: Ctx): Promise<Response> { | ||
| 662 | 663 | }, |
| 663 | 664 | username_taken: { username: "That username is taken." }, |
| 664 | 665 | username_reserved: { username: "That name is reserved." }, |
| 665 | email_taken: { email: "That email already has an account." }, | |
| 666 | email_taken: { email: EMAIL_TAKEN_MESSAGE }, | |
| 666 | 667 | password_too_long: { password: PASSWORD_TOO_LONG }, |
| 667 | 668 | weak_password: { password: "Passwords need at least 12 characters." }, |
| 668 | 669 | invalid_email: { email: "That does not look like an email address." }, |
test/signup-words.test.ts added +28
| @@ -0,0 +1,28 @@ | ||
| 1 | import { describe, expect, test } from "bun:test"; | |
| 2 | import { readFileSync } from "node:fs"; | |
| 3 | import { join } from "node:path"; | |
| 4 | import { EMAIL_TAKEN_MESSAGE, INVALID_USERNAME_MESSAGE } from "../src/actions/signup_words.ts"; | |
| 5 | ||
| 6 | const SENTENCES = { INVALID_USERNAME_MESSAGE, EMAIL_TAKEN_MESSAGE }; | |
| 7 | ||
| 8 | const READERS = [ | |
| 9 | "src/actions/first_admin.ts", | |
| 10 | "src/web/pages/claim.ts", | |
| 11 | "src/web/pages/everyday.ts", | |
| 12 | "src/web/pages/signup-passkey.ts", | |
| 13 | "src/web/pages/site.ts", | |
| 14 | ]; | |
| 15 | ||
| 16 | describe("signup words", () => { | |
| 17 | for (const [name, sentence] of Object.entries(SENTENCES)) | |
| 18 | test(`${name} is a sentence`, () => { | |
| 19 | expect(sentence.trim().length).toBeGreaterThan(0); | |
| 20 | }); | |
| 21 | ||
| 22 | for (const file of READERS) | |
| 23 | test(`${file} reads the sentences rather than typing them`, () => { | |
| 24 | const source = readFileSync(join(import.meta.dir, "..", file), "utf8"); | |
| 25 | for (const sentence of Object.values(SENTENCES)) expect(source).not.toContain(sentence); | |
| 26 | expect(source).toContain("signup_words.ts"); | |
| 27 | }); | |
| 28 | }); |
new above
offline
Nit: the import sits after the ../ui and ./site imports, out of the file order of the other imports. Put it with the other ../../actions imports.