update signup page
Build & Push / Pipeline Tests (push) Successful in 1m31s
Test / Type Check (all packages) (push) Successful in 52s
Build & Push / Build & Push Docker Image (push) Successful in 4m3s
Test / API Unit Tests (push) Successful in 1m2s
Test / Homepage Unit Tests (push) Successful in 43s
Test / Carplace Unit Tests (push) Successful in 41s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Successful in 42s
Test / API Integration Tests (push) Successful in 1m2s

This commit is contained in:
root
2026-07-20 23:21:24 -04:00
parent 93fba7a318
commit 7ecd85e9b7
4 changed files with 127 additions and 10 deletions
@@ -5,6 +5,9 @@ import { z } from 'zod'
* See progressive-signup-plan.md Section 3. * See progressive-signup-plan.md Section 3.
*/ */
export const accountStartSchema = z.object({ export const accountStartSchema = z.object({
firstName: z.string().trim().min(1).max(80),
lastName: z.string().trim().min(1).max(80),
companyName: z.string().trim().min(2).max(120),
email: z.string().email(), email: z.string().email(),
password: z.string().min(8).max(128), password: z.string().min(8).max(128),
preferredLanguage: z.enum(['en', 'fr', 'ar']).default('en'), preferredLanguage: z.enum(['en', 'fr', 'ar']).default('en'),
@@ -42,7 +42,7 @@ export async function startAccount(body: AccountStartInput) {
const company = await tx.company.create({ const company = await tx.company.create({
data: { data: {
name: '', name: body.companyName,
slug, slug,
email: body.email, email: body.email,
address: {}, address: {},
@@ -53,7 +53,7 @@ export async function startAccount(body: AccountStartInput) {
await tx.brandSettings.create({ await tx.brandSettings.create({
data: { data: {
companyId: company.id, companyId: company.id,
displayName: body.email.split('@')[0] || 'My Workspace', displayName: body.companyName,
subdomain: slug, subdomain: slug,
defaultLocale: body.preferredLanguage, defaultLocale: body.preferredLanguage,
defaultCurrency: 'MAD', defaultCurrency: 'MAD',
@@ -78,8 +78,8 @@ export async function startAccount(body: AccountStartInput) {
data: { data: {
companyId: company.id, companyId: company.id,
clerkUserId: `local_owner_${company.id}`, clerkUserId: `local_owner_${company.id}`,
firstName: '', firstName: body.firstName,
lastName: '', lastName: body.lastName,
email: body.email, email: body.email,
passwordHash, passwordHash,
emailVerificationToken: hashPublicAccessToken(verificationToken), emailVerificationToken: hashPublicAccessToken(verificationToken),
@@ -128,6 +128,9 @@ describe('auth API boundaries', () => {
it('POST /auth/account/start validates minimal signup and returns 201', async () => { it('POST /auth/account/start validates minimal signup and returns 201', async () => {
const res = await request(app).post('/api/v1/auth/account/start').send({ const res = await request(app).post('/api/v1/auth/account/start').send({
firstName: 'Aya',
lastName: 'Benali',
companyName: 'Atlas Cars',
email: 'owner@example.com', email: 'owner@example.com',
password: 'super-secret', password: 'super-secret',
preferredLanguage: 'fr', preferredLanguage: 'fr',
@@ -141,6 +144,9 @@ describe('auth API boundaries', () => {
}, },
}) })
expect(accountService.startAccount).toHaveBeenCalledWith({ expect(accountService.startAccount).toHaveBeenCalledWith({
firstName: 'Aya',
lastName: 'Benali',
companyName: 'Atlas Cars',
email: 'owner@example.com', email: 'owner@example.com',
password: 'super-secret', password: 'super-secret',
preferredLanguage: 'fr', preferredLanguage: 'fr',
@@ -149,6 +155,9 @@ describe('auth API boundaries', () => {
it('POST /auth/account/start rejects malformed minimal signup payloads before service execution', async () => { it('POST /auth/account/start rejects malformed minimal signup payloads before service execution', async () => {
const res = await request(app).post('/api/v1/auth/account/start').send({ const res = await request(app).post('/api/v1/auth/account/start').send({
firstName: 'Aya',
lastName: 'Benali',
companyName: 'A',
email: 'not-email', email: 'not-email',
password: 'short', password: 'short',
}) })
@@ -48,8 +48,12 @@ export default function SignUpForm({
? requestedLanguage ? requestedLanguage
: language; : language;
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [companyName, setCompanyName] = useState("");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [preferredLanguage, setPreferredLanguage] = useState< const [preferredLanguage, setPreferredLanguage] = useState<
"en" | "fr" | "ar" "en" | "fr" | "ar"
@@ -131,11 +135,18 @@ export default function SignUpForm({
const dict = { const dict = {
en: { en: {
title: "Create account", title: "Create account",
subtitle: "Enter your email and password to get started.", subtitle: "Enter your details to create your workspace.",
firstName: "First name",
firstNamePlaceholder: "Aya",
lastName: "Last name",
lastNamePlaceholder: "Benali",
company: "Company",
companyPlaceholder: "Atlas Cars",
email: "Email", email: "Email",
emailPlaceholder: "you@company.com", emailPlaceholder: "you@company.com",
password: "Password", password: "Password",
passwordPlaceholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022", passwordPlaceholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",
confirmPassword: "Confirm password",
passwordHint: "Minimum 8 characters", passwordHint: "Minimum 8 characters",
languageLabel: "Preferred language", languageLabel: "Preferred language",
create: "Create account", create: "Create account",
@@ -143,6 +154,7 @@ export default function SignUpForm({
alreadyHave: "Already have an account?", alreadyHave: "Already have an account?",
signIn: "Sign in", signIn: "Sign in",
errorEmailTaken: "An account with this email already exists.", errorEmailTaken: "An account with this email already exists.",
errorPasswordMismatch: "Passwords do not match.",
errorGeneric: "Something went wrong. Please try again.", errorGeneric: "Something went wrong. Please try again.",
successTitle: "Check your email", successTitle: "Check your email",
successMessage: successMessage:
@@ -151,11 +163,18 @@ export default function SignUpForm({
}, },
fr: { fr: {
title: "Cr\u00e9er un compte", title: "Cr\u00e9er un compte",
subtitle: "Saisissez votre email et mot de passe pour commencer.", subtitle: "Saisissez vos informations pour cr\u00e9er votre espace.",
firstName: "Pr\u00e9nom",
firstNamePlaceholder: "Aya",
lastName: "Nom",
lastNamePlaceholder: "Benali",
company: "Entreprise",
companyPlaceholder: "Atlas Cars",
email: "Email", email: "Email",
emailPlaceholder: "vous@entreprise.com", emailPlaceholder: "vous@entreprise.com",
password: "Mot de passe", password: "Mot de passe",
passwordPlaceholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022", passwordPlaceholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",
confirmPassword: "Confirmer le mot de passe",
passwordHint: "Minimum 8 caract\u00e8res", passwordHint: "Minimum 8 caract\u00e8res",
languageLabel: "Langue pr\u00e9f\u00e9r\u00e9e", languageLabel: "Langue pr\u00e9f\u00e9r\u00e9e",
create: "Cr\u00e9er un compte", create: "Cr\u00e9er un compte",
@@ -163,6 +182,7 @@ export default function SignUpForm({
alreadyHave: "Vous avez d\u00e9j\u00e0 un compte ?", alreadyHave: "Vous avez d\u00e9j\u00e0 un compte ?",
signIn: "Se connecter", signIn: "Se connecter",
errorEmailTaken: "Un compte avec cet email existe d\u00e9j\u00e0.", errorEmailTaken: "Un compte avec cet email existe d\u00e9j\u00e0.",
errorPasswordMismatch: "Les mots de passe ne correspondent pas.",
errorGeneric: "Une erreur est survenue. Veuillez r\u00e9essayer.", errorGeneric: "Une erreur est survenue. Veuillez r\u00e9essayer.",
successTitle: "V\u00e9rifiez votre email", successTitle: "V\u00e9rifiez votre email",
successMessage: successMessage:
@@ -173,12 +193,20 @@ export default function SignUpForm({
title: title:
"\u0623\u0646\u0634\u0626 \u062d\u0633\u0627\u0628\u064b\u0627", "\u0623\u0646\u0634\u0626 \u062d\u0633\u0627\u0628\u064b\u0627",
subtitle: subtitle:
"\u0623\u062f\u062e\u0644 \u0628\u0631\u064a\u062f\u0643 \u0627\u0644\u0625\u0644\u0643\u062a\u0631\u0648\u0646\u064a \u0648\u0643\u0644\u0645\u0629 \u0627\u0644\u0645\u0631\u0648\u0631 \u0644\u0644\u0628\u062f\u0621.", "\u0623\u062f\u062e\u0644 \u0628\u064a\u0627\u0646\u0627\u062a\u0643 \u0644\u0625\u0646\u0634\u0627\u0621 \u0645\u0633\u0627\u062d\u0629 \u0639\u0645\u0644\u0643.",
firstName: "\u0627\u0644\u0627\u0633\u0645 \u0627\u0644\u0623\u0648\u0644",
firstNamePlaceholder: "Aya",
lastName: "\u0627\u0633\u0645 \u0627\u0644\u0639\u0627\u0626\u0644\u0629",
lastNamePlaceholder: "Benali",
company: "\u0627\u0644\u0634\u0631\u0643\u0629",
companyPlaceholder: "Atlas Cars",
email: email:
"\u0627\u0644\u0628\u0631\u064a\u062f \u0627\u0644\u0625\u0644\u0643\u062a\u0631\u0648\u0646\u064a", "\u0627\u0644\u0628\u0631\u064a\u062f \u0627\u0644\u0625\u0644\u0643\u062a\u0631\u0648\u0646\u064a",
emailPlaceholder: "you@company.com", emailPlaceholder: "you@company.com",
password: "\u0643\u0644\u0645\u0629 \u0627\u0644\u0645\u0631\u0648\u0631", password: "\u0643\u0644\u0645\u0629 \u0627\u0644\u0645\u0631\u0648\u0631",
passwordPlaceholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022", passwordPlaceholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",
confirmPassword:
"\u062a\u0623\u0643\u064a\u062f \u0643\u0644\u0645\u0629 \u0627\u0644\u0645\u0631\u0648\u0631",
passwordHint: passwordHint:
"\u0627\u0644\u062d\u062f \u0627\u0644\u0623\u062f\u0646\u0649 8 \u0623\u062d\u0631\u0641", "\u0627\u0644\u062d\u062f \u0627\u0644\u0623\u062f\u0646\u0649 8 \u0623\u062d\u0631\u0641",
languageLabel: languageLabel:
@@ -193,6 +221,8 @@ export default function SignUpForm({
"\u062a\u0633\u062c\u064a\u0644 \u0627\u0644\u062f\u062e\u0648\u0644", "\u062a\u0633\u062c\u064a\u0644 \u0627\u0644\u062f\u062e\u0648\u0644",
errorEmailTaken: errorEmailTaken:
"\u064a\u0648\u062c\u062f \u062d\u0633\u0627\u0628 \u0628\u0647\u0630\u0627 \u0627\u0644\u0628\u0631\u064a\u062f \u0627\u0644\u0625\u0644\u0643\u062a\u0631\u0648\u0646\u064a \u0628\u0627\u0644\u0641\u0639\u0644.", "\u064a\u0648\u062c\u062f \u062d\u0633\u0627\u0628 \u0628\u0647\u0630\u0627 \u0627\u0644\u0628\u0631\u064a\u062f \u0627\u0644\u0625\u0644\u0643\u062a\u0631\u0648\u0646\u064a \u0628\u0627\u0644\u0641\u0639\u0644.",
errorPasswordMismatch:
"\u0643\u0644\u0645\u062a\u0627 \u0627\u0644\u0645\u0631\u0648\u0631 \u063a\u064a\u0631 \u0645\u062a\u0637\u0627\u0628\u0642\u062a\u064a\u0646.",
errorGeneric: errorGeneric:
"\u062d\u062f\u062b \u062e\u0637\u0623 \u0645\u0627. \u064a\u0631\u062c\u0649 \u0627\u0644\u0645\u062d\u0627\u0648\u0644\u0629 \u0645\u0631\u0629 \u0623\u062e\u0631\u0649.", "\u062d\u062f\u062b \u062e\u0637\u0623 \u0645\u0627. \u064a\u0631\u062c\u0649 \u0627\u0644\u0645\u062d\u0627\u0648\u0644\u0629 \u0645\u0631\u0629 \u0623\u062e\u0631\u0649.",
successTitle: successTitle:
@@ -212,13 +242,25 @@ export default function SignUpForm({
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (password !== confirmPassword) {
setError(dict.errorPasswordMismatch);
return;
}
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
await apiFetch("/auth/account/start", { await apiFetch("/auth/account/start", {
method: "POST", method: "POST",
body: JSON.stringify({ email, password, preferredLanguage }), body: JSON.stringify({
firstName,
lastName,
companyName,
email,
password,
preferredLanguage,
}),
}); });
setLanguage(preferredLanguage); setLanguage(preferredLanguage);
@@ -277,7 +319,7 @@ export default function SignUpForm({
return ( return (
<PublicShell embedded={embedded} hideFooter hideHeaderActions> <PublicShell embedded={embedded} hideFooter hideHeaderActions>
<main className="flex flex-1 items-center justify-center px-4 py-12"> <main className="flex flex-1 items-center justify-center px-4 py-12">
<div className="w-full max-w-md"> <div className="w-full max-w-lg">
<div className="mb-8 text-center"> <div className="mb-8 text-center">
<div className="flex justify-center"> <div className="flex justify-center">
<a href={websiteUrl} target="_top"> <a href={websiteUrl} target="_top">
@@ -307,6 +349,55 @@ export default function SignUpForm({
) : null} ) : null}
<form onSubmit={handleSubmit} className="space-y-5"> <form onSubmit={handleSubmit} className="space-y-5">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">
{dict.firstName} <span className="text-red-500">*</span>
</label>
<input
type="text"
required
maxLength={80}
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
placeholder={dict.firstNamePlaceholder}
autoFocus
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-900 transition-colors placeholder:text-stone-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-blue-950/80 dark:text-stone-100 dark:placeholder:text-stone-500"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">
{dict.lastName} <span className="text-red-500">*</span>
</label>
<input
type="text"
required
maxLength={80}
value={lastName}
onChange={(e) => setLastName(e.target.value)}
placeholder={dict.lastNamePlaceholder}
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-900 transition-colors placeholder:text-stone-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-blue-950/80 dark:text-stone-100 dark:placeholder:text-stone-500"
/>
</div>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">
{dict.company} <span className="text-red-500">*</span>
</label>
<input
type="text"
required
minLength={2}
maxLength={120}
value={companyName}
onChange={(e) => setCompanyName(e.target.value)}
placeholder={dict.companyPlaceholder}
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-900 transition-colors placeholder:text-stone-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-blue-950/80 dark:text-stone-100 dark:placeholder:text-stone-500"
/>
</div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200"> <label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">
{dict.email} <span className="text-red-500">*</span> {dict.email} <span className="text-red-500">*</span>
@@ -317,7 +408,6 @@ export default function SignUpForm({
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
placeholder={dict.emailPlaceholder} placeholder={dict.emailPlaceholder}
autoFocus
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-900 transition-colors placeholder:text-stone-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-blue-950/80 dark:text-stone-100 dark:placeholder:text-stone-500" className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-900 transition-colors placeholder:text-stone-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-blue-950/80 dark:text-stone-100 dark:placeholder:text-stone-500"
/> />
</div> </div>
@@ -387,6 +477,21 @@ export default function SignUpForm({
</p> </p>
</div> </div>
<div>
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">
{dict.confirmPassword} <span className="text-red-500">*</span>
</label>
<input
type={showPassword ? "text" : "password"}
required
minLength={8}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder={dict.passwordPlaceholder}
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-900 transition-colors placeholder:text-stone-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-blue-950/80 dark:text-stone-100 dark:placeholder:text-stone-500"
/>
</div>
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}