From d02f31b55c4889e8e03f0d2c4957a84c3a6c321a Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Tue, 13 Oct 2026 01:32:36 -0400 Subject: [PATCH] Declare the register steps and the schema slice each one collects --- .../app/features/auth/registerSteps.ts | 49 ++++++++++++++ web/frontend/app/features/auth/schemas.ts | 66 +++++++++++++------ 2 files changed, 95 insertions(+), 20 deletions(-) create mode 100644 web/frontend/app/features/auth/registerSteps.ts diff --git a/web/frontend/app/features/auth/registerSteps.ts b/web/frontend/app/features/auth/registerSteps.ts new file mode 100644 index 00000000..207fc867 --- /dev/null +++ b/web/frontend/app/features/auth/registerSteps.ts @@ -0,0 +1,49 @@ +import type { z } from 'zod'; +import { registerFields, type RegisterSchema } from './schemas'; + +interface RegisterStep { + name: string; + title: string; + subtitle: string; + /** The slice of the register schema this step collects. */ + schema: z.ZodObject; + fields: readonly (keyof RegisterSchema)[]; +} + +const defineStep = (step: Omit): RegisterStep => ({ + ...step, + fields: Object.keys(step.schema.shape) as (keyof RegisterSchema)[], +}); + +export const registerSteps: readonly RegisterStep[] = [ + defineStep({ + name: 'Account', + title: 'Create your account', + subtitle: 'Choose how you will sign in.', + schema: registerFields.pick({ username: true, email: true }), + }), + defineStep({ + name: 'Personal details', + title: 'About you', + subtitle: 'Tell us a little about yourself.', + schema: registerFields.pick({ firstName: true, lastName: true, dateOfBirth: true }), + }), + defineStep({ + name: 'Password', + title: 'Secure your account', + subtitle: 'Pick a strong password.', + schema: registerFields.pick({ password: true, confirmPassword: true }), + }), +]; + +export const lastRegisterStepIndex = registerSteps.length - 1; + +/** The step a field belongs to, so an error can open the step that owns it. */ +export const stepIndexForField = (field: keyof RegisterSchema) => + registerSteps.findIndex((step) => step.fields.includes(field)); + +/** The earliest step owning any of `fields`, or `null` when none of them do. */ +export const earliestStepForFields = (fields: readonly (keyof RegisterSchema)[]) => { + const indexes = fields.map(stepIndexForField).filter((index) => index >= 0); + return indexes.length ? Math.min(...indexes) : null; +}; diff --git a/web/frontend/app/features/auth/schemas.ts b/web/frontend/app/features/auth/schemas.ts index b2fdfeb2..3ea5a5f1 100644 --- a/web/frontend/app/features/auth/schemas.ts +++ b/web/frontend/app/features/auth/schemas.ts @@ -7,27 +7,53 @@ export const loginSchema = z.object({ export type LoginSchema = z.infer; -export const registerSchema = z - .object({ - username: z - .string() - .min(3, 'Username must be at least 3 characters') - .max(20, 'Username must be at most 20 characters'), - firstName: z.string().min(1, 'First name is required'), - lastName: z.string().min(1, 'Last name is required'), - email: z.string().email('Invalid email address'), - dateOfBirth: z.string().min(1, 'Date of birth is required'), - password: z - .string() - .min(8, 'Password must be at least 8 characters') - .regex(/[A-Z]/, 'Password must contain an uppercase letter') - .regex(/[a-z]/, 'Password must contain a lowercase letter') - .regex(/[0-9]/, 'Password must contain a number'), - confirmPassword: z.string().min(1, 'Please confirm your password'), - }) - .refine((data) => data.password === data.confirmPassword, { +export const minimumAgeYears = 19; + +const isOldEnough = (value: string) => { + const dateOfBirth = new Date(value); + if (Number.isNaN(dateOfBirth.getTime())) return false; + + const comingOfAge = new Date( + dateOfBirth.getFullYear() + minimumAgeYears, + dateOfBirth.getMonth(), + dateOfBirth.getDate(), + ); + + return comingOfAge <= new Date(); +}; + +export const registerFields = z.object({ + username: z + .string() + .min(3, 'Username must be at least 3 characters') + .max(64, 'Username must be at most 64 characters') + .regex( + /^[A-Za-z0-9._-]+$/, + 'Username can only contain letters, numbers, dots, underscores and hyphens', + ), + firstName: z.string().min(1, 'First name is required'), + lastName: z.string().min(1, 'Last name is required'), + email: z.email('Invalid email address'), + dateOfBirth: z + .string() + .min(1, 'Date of birth is required') + .refine(isOldEnough, `You must be at least ${minimumAgeYears} years old to register`), + password: z + .string() + .min(8, 'Password must be at least 8 characters') + .regex(/[A-Z]/, 'Password must contain an uppercase letter') + .regex(/[a-z]/, 'Password must contain a lowercase letter') + .regex(/[0-9]/, 'Password must contain a number') + .regex(/[^A-Za-z0-9]/, 'Password must contain a special character'), + confirmPassword: z.string().min(1, 'Please confirm your password'), +}); + +export const registerSchema = registerFields.refine( + (data) => data.password === data.confirmPassword, + { message: 'Passwords must match', path: ['confirmPassword'], - }); + }, +); export type RegisterSchema = z.infer;