Skip to content

Commit 880b736

Browse files
committed
Enhance signup and password reset flows
1 parent b7a52a6 commit 880b736

8 files changed

Lines changed: 441 additions & 31 deletions

File tree

frontend/app/api/auth/password-reset/confirm/route.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,38 @@ import { z } from 'zod';
66
import { db } from '@/db';
77
import { passwordResetTokens } from '@/db/schema/passwordResetTokens';
88
import { users } from '@/db/schema/users';
9+
import {
10+
PASSWORD_MAX_LEN,
11+
PASSWORD_MIN_LEN,
12+
PASSWORD_POLICY_REGEX,
13+
} from '@/lib/auth/signup-constraints';
914

1015
const schema = z.object({
1116
token: z.string().uuid(),
12-
password: z.string().min(8),
17+
password: z
18+
.string()
19+
.min(
20+
PASSWORD_MIN_LEN,
21+
`Password must be at least ${PASSWORD_MIN_LEN} characters`
22+
)
23+
.max(
24+
PASSWORD_MAX_LEN,
25+
`Password must be at most ${PASSWORD_MAX_LEN} characters`
26+
)
27+
.regex(/[A-Z]/, 'Password must contain at least one capital letter')
28+
.regex(/[^A-Za-z0-9]/, 'Password must contain at least one special character')
29+
.regex(PASSWORD_POLICY_REGEX, 'Password does not meet the required policy'),
1330
});
1431

1532
export async function POST(req: Request) {
1633
const body = await req.json().catch(() => null);
1734
const parsed = schema.safeParse(body);
1835

1936
if (!parsed.success) {
20-
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
37+
return NextResponse.json(
38+
{ error: parsed.error.flatten().fieldErrors },
39+
{ status: 400 }
40+
);
2141
}
2242

2343
const { token, password } = parsed.data;
@@ -60,4 +80,4 @@ export async function POST(req: Request) {
6080
await db.update(users).set({ passwordHash }).where(eq(users.id, userId));
6181

6282
return NextResponse.json({ success: true });
63-
}
83+
}

frontend/app/api/auth/signup/route.ts

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,51 @@ import { z } from 'zod';
77
import { db } from '@/db';
88
import { users } from '@/db/schema/users';
99
import { createEmailVerificationToken } from '@/lib/auth/email-verification';
10+
import {
11+
EMAIL_MAX_LEN,
12+
EMAIL_MIN_LEN,
13+
NAME_MAX_LEN,
14+
NAME_MIN_LEN,
15+
PASSWORD_MAX_LEN,
16+
PASSWORD_MIN_LEN,
17+
PASSWORD_POLICY_REGEX,
18+
} from '@/lib/auth/signup-constraints';
1019
import { sendVerificationEmail } from '@/lib/email/sendVerificationEmail';
1120
import { resolveBaseUrl } from '@/lib/http/getBaseUrl';
1221

1322
export const runtime = 'nodejs';
1423

15-
const signupSchema = z.object({
16-
name: z.string().min(1, 'Name is required'),
17-
email: z.string().email('Invalid email'),
18-
password: z.string().min(8, 'Password must be at least 8 characters'),
19-
});
24+
const signupSchema = z
25+
.object({
26+
name: z
27+
.string()
28+
.trim()
29+
.min(NAME_MIN_LEN, `Name must be at least ${NAME_MIN_LEN} characters`)
30+
.max(NAME_MAX_LEN, `Name must be at most ${NAME_MAX_LEN} characters`),
31+
email: z
32+
.string()
33+
.trim()
34+
.min(EMAIL_MIN_LEN, `Email must be at least ${EMAIL_MIN_LEN} characters`)
35+
.max(EMAIL_MAX_LEN, `Email must be at most ${EMAIL_MAX_LEN} characters`)
36+
.email('Invalid email'),
37+
password: z
38+
.string()
39+
.min(PASSWORD_MIN_LEN, `Password must be at least ${PASSWORD_MIN_LEN} characters`)
40+
.max(PASSWORD_MAX_LEN, `Password must be at most ${PASSWORD_MAX_LEN} characters`)
41+
.regex(/[A-Z]/, 'Password must contain at least one capital letter')
42+
.regex(/[^A-Za-z0-9]/, 'Password must contain at least one special character')
43+
.regex(PASSWORD_POLICY_REGEX, 'Password does not meet the required policy'),
44+
confirmPassword: z.string(),
45+
})
46+
.superRefine((val, ctx) => {
47+
if (val.password !== val.confirmPassword) {
48+
ctx.addIssue({
49+
code: z.ZodIssueCode.custom,
50+
path: ['confirmPassword'],
51+
message: 'Passwords do not match',
52+
});
53+
}
54+
});
2055

2156
export async function POST(req: Request) {
2257
try {
@@ -88,4 +123,4 @@ export async function POST(req: Request) {
88123
{ status: 500 }
89124
);
90125
}
91-
}
126+
}

frontend/components/auth/ResetPasswordForm.tsx

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
'use client';
22

33
import { useTranslations } from 'next-intl';
4-
import { useState } from 'react';
4+
import { useMemo, useState } from 'react';
55

66
import { AuthErrorBanner } from '@/components/auth/AuthErrorBanner';
77
import { AuthShell } from '@/components/auth/AuthShell';
88
import { AuthSuccessBanner } from '@/components/auth/AuthSuccessBanner';
99
import { PasswordField } from '@/components/auth/fields/PasswordField';
1010
import { Button } from '@/components/ui/button';
11+
import {
12+
PASSWORD_MAX_LEN,
13+
PASSWORD_MIN_LEN,
14+
PASSWORD_POLICY_REGEX,
15+
} from '@/lib/auth/signup-constraints';
1116

1217
type ResetPasswordFormProps = {
1318
token: string;
@@ -19,8 +24,31 @@ export function ResetPasswordForm({ token }: ResetPasswordFormProps) {
1924
const [error, setError] = useState<string | null>(null);
2025
const [success, setSuccess] = useState(false);
2126

27+
const [passwordValue, setPasswordValue] = useState('');
28+
const [passwordTouched, setPasswordTouched] = useState(false);
29+
30+
const passwordPolicyOk = useMemo(() => {
31+
if (!passwordValue) return false;
32+
if (passwordValue.length < PASSWORD_MIN_LEN) return false;
33+
if (passwordValue.length > PASSWORD_MAX_LEN) return false;
34+
return PASSWORD_POLICY_REGEX.test(passwordValue);
35+
}, [passwordValue]);
36+
37+
const passwordRequirementsText =
38+
'8–128 characters, at least one capital letter, and at least one special character.';
39+
40+
const passwordErrorText =
41+
passwordTouched && !passwordPolicyOk
42+
? `Password must meet requirements: ${passwordRequirementsText}`
43+
: null;
44+
45+
const submitDisabled = loading || !passwordPolicyOk;
46+
2247
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
2348
e.preventDefault();
49+
50+
if (submitDisabled) return;
51+
2452
setLoading(true);
2553
setError(null);
2654

@@ -38,8 +66,12 @@ export function ResetPasswordForm({ token }: ResetPasswordFormProps) {
3866
}),
3967
});
4068

69+
const data = await res.json().catch(() => null);
70+
4171
if (!res.ok) {
42-
setError(t('errors.resetFailed'));
72+
const msg =
73+
typeof data?.error === 'string' ? data.error : t('errors.resetFailed');
74+
setError(msg);
4375
return;
4476
}
4577

@@ -57,15 +89,30 @@ export function ResetPasswordForm({ token }: ResetPasswordFormProps) {
5789
<AuthSuccessBanner message={t('success')} />
5890
) : (
5991
<form onSubmit={onSubmit} className="space-y-4">
60-
<PasswordField minLength={8} />
92+
<div className="space-y-1">
93+
<PasswordField
94+
id="password"
95+
name="password"
96+
placeholder="New password"
97+
autoComplete="new-password"
98+
minLength={PASSWORD_MIN_LEN}
99+
maxLength={PASSWORD_MAX_LEN}
100+
pattern={PASSWORD_POLICY_REGEX.source}
101+
onChange={setPasswordValue}
102+
onBlur={() => setPasswordTouched(true)}
103+
/>
104+
{passwordErrorText && (
105+
<p className="text-sm text-red-600">{passwordErrorText}</p>
106+
)}
107+
</div>
61108

62109
{error && <AuthErrorBanner message={error} />}
63110

64-
<Button type="submit" disabled={loading} className="w-full">
111+
<Button type="submit" disabled={submitDisabled} className="w-full">
65112
{loading ? t('submitting') : t('submit')}
66113
</Button>
67114
</form>
68115
)}
69116
</AuthShell>
70117
);
71-
}
118+
}

0 commit comments

Comments
 (0)