Skip to content

Commit c8adbe1

Browse files
Merge pull request #344 from DevLoversTeam/feat/auth-enhance-registration
Enhance signup and password reset flows
2 parents b7a52a6 + 63e5955 commit c8adbe1

12 files changed

Lines changed: 624 additions & 77 deletions

File tree

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

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,52 @@ 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_BYTES,
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+
.regex(/[A-Z]/, 'Password must contain at least one capital letter')
24+
.regex(
25+
/[^A-Za-z0-9]/,
26+
'Password must contain at least one special character'
27+
)
28+
.regex(PASSWORD_POLICY_REGEX, 'Password does not meet the required policy')
29+
.refine(
30+
val => Buffer.byteLength(val, 'utf8') <= PASSWORD_MAX_BYTES,
31+
`Password must be at most ${PASSWORD_MAX_BYTES} bytes`
32+
),
1333
});
1434

35+
function firstFieldErrorMessage(
36+
fieldErrors: Record<string, string[] | undefined>
37+
): string | null {
38+
for (const key of Object.keys(fieldErrors)) {
39+
const msgs = fieldErrors[key];
40+
if (Array.isArray(msgs) && msgs.length > 0 && msgs[0]) {
41+
return msgs[0];
42+
}
43+
}
44+
return null;
45+
}
46+
1547
export async function POST(req: Request) {
1648
const body = await req.json().catch(() => null);
1749
const parsed = schema.safeParse(body);
1850

1951
if (!parsed.success) {
20-
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
52+
const flattened = parsed.error.flatten().fieldErrors;
53+
const firstMsg = firstFieldErrorMessage(flattened) ?? 'Invalid request';
54+
return NextResponse.json({ error: firstMsg }, { status: 400 });
2155
}
2256

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

6296
return NextResponse.json({ success: true });
63-
}
97+
}

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

Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,60 @@ 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_BYTES,
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 passwordSchema = z
25+
.string()
26+
.min(
27+
PASSWORD_MIN_LEN,
28+
`Password must be at least ${PASSWORD_MIN_LEN} characters`
29+
)
30+
.regex(/[A-Z]/, 'Password must contain at least one capital letter')
31+
.regex(/[^A-Za-z0-9]/, 'Password must contain at least one special character')
32+
.regex(PASSWORD_POLICY_REGEX, 'Password does not meet the required policy')
33+
.refine(
34+
val => Buffer.byteLength(val, 'utf8') <= PASSWORD_MAX_BYTES,
35+
`Password must be at most ${PASSWORD_MAX_BYTES} bytes`
36+
);
37+
38+
const signupSchema = z
39+
.object({
40+
name: z
41+
.string()
42+
.trim()
43+
.min(NAME_MIN_LEN, `Name must be at least ${NAME_MIN_LEN} characters`)
44+
.max(NAME_MAX_LEN, `Name must be at most ${NAME_MAX_LEN} characters`),
45+
email: z
46+
.string()
47+
.trim()
48+
.min(EMAIL_MIN_LEN, `Email must be at least ${EMAIL_MIN_LEN} characters`)
49+
.max(EMAIL_MAX_LEN, `Email must be at most ${EMAIL_MAX_LEN} characters`)
50+
.email('Invalid email'),
51+
password: passwordSchema,
52+
53+
confirmPassword: z.string(),
54+
})
55+
.superRefine((val, ctx) => {
56+
if (val.password !== val.confirmPassword) {
57+
ctx.addIssue({
58+
code: z.ZodIssueCode.custom,
59+
path: ['confirmPassword'],
60+
message: 'Passwords do not match',
61+
});
62+
}
63+
});
2064

2165
export async function POST(req: Request) {
2266
try {
@@ -74,10 +118,7 @@ export async function POST(req: Request) {
74118
});
75119

76120
return NextResponse.json(
77-
{
78-
success: true,
79-
verificationRequired: true,
80-
},
121+
{ success: true, verificationRequired: true },
81122
{ status: 201 }
82123
);
83124
} catch (error) {
@@ -88,4 +129,4 @@ export async function POST(req: Request) {
88129
{ status: 500 }
89130
);
90131
}
91-
}
132+
}

frontend/components/auth/ResetPasswordForm.tsx

Lines changed: 77 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,66 @@
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_BYTES,
13+
PASSWORD_MIN_LEN,
14+
PASSWORD_POLICY_REGEX,
15+
} from '@/lib/auth/signup-constraints';
1116

1217
type ResetPasswordFormProps = {
1318
token: string;
1419
};
1520

21+
function utf8ByteLength(value: string): number {
22+
return new TextEncoder().encode(value).length;
23+
}
24+
1625
export function ResetPasswordForm({ token }: ResetPasswordFormProps) {
1726
const t = useTranslations('auth.resetPassword');
27+
const tf = useTranslations('auth.fields');
28+
1829
const [loading, setLoading] = useState(false);
1930
const [error, setError] = useState<string | null>(null);
2031
const [success, setSuccess] = useState(false);
2132

33+
const [passwordValue, setPasswordValue] = useState('');
34+
const [passwordTouched, setPasswordTouched] = useState(false);
35+
36+
const passwordPolicyOk = useMemo(() => {
37+
if (!passwordValue) return false;
38+
if (passwordValue.length < PASSWORD_MIN_LEN) return false;
39+
if (!PASSWORD_POLICY_REGEX.test(passwordValue)) return false;
40+
if (utf8ByteLength(passwordValue) > PASSWORD_MAX_BYTES) return false;
41+
return true;
42+
}, [passwordValue]);
43+
44+
const passwordRequirementsText = tf('validation.passwordRequirements', {
45+
PASSWORD_MIN_LEN,
46+
PASSWORD_MAX_BYTES,
47+
});
48+
49+
const passwordErrorText =
50+
passwordTouched && !passwordPolicyOk
51+
? tf('validation.invalidPassword', { passwordRequirementsText })
52+
: null;
53+
54+
const passwordBytesTooLong =
55+
passwordTouched &&
56+
utf8ByteLength(passwordValue) > PASSWORD_MAX_BYTES;
57+
58+
const submitDisabled = loading || !passwordPolicyOk;
59+
2260
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
2361
e.preventDefault();
62+
if (submitDisabled) return;
63+
2464
setLoading(true);
2565
setError(null);
2666

@@ -29,17 +69,21 @@ export function ResetPasswordForm({ token }: ResetPasswordFormProps) {
2969
try {
3070
const res = await fetch('/api/auth/password-reset/confirm', {
3171
method: 'POST',
32-
headers: {
33-
'Content-Type': 'application/json',
34-
},
72+
headers: { 'Content-Type': 'application/json' },
3573
body: JSON.stringify({
3674
token,
3775
password: formData.get('password'),
3876
}),
3977
});
4078

79+
const data = await res.json().catch(() => null);
80+
4181
if (!res.ok) {
42-
setError(t('errors.resetFailed'));
82+
const msg =
83+
typeof data?.error === 'string'
84+
? data.error
85+
: t('errors.resetFailed');
86+
setError(msg);
4387
return;
4488
}
4589

@@ -57,15 +101,40 @@ export function ResetPasswordForm({ token }: ResetPasswordFormProps) {
57101
<AuthSuccessBanner message={t('success')} />
58102
) : (
59103
<form onSubmit={onSubmit} className="space-y-4">
60-
<PasswordField minLength={8} />
104+
<div className="space-y-1">
105+
<PasswordField
106+
id="password"
107+
name="password"
108+
placeholder={tf('setNewPassword')}
109+
autoComplete="new-password"
110+
minLength={PASSWORD_MIN_LEN}
111+
pattern={PASSWORD_POLICY_REGEX.source}
112+
onChange={setPasswordValue}
113+
onBlur={() => setPasswordTouched(true)}
114+
/>
115+
116+
{passwordErrorText && (
117+
<p className="text-sm text-red-600">
118+
{passwordErrorText}
119+
</p>
120+
)}
121+
122+
{passwordBytesTooLong && (
123+
<p className="text-sm text-red-600">
124+
{tf('validation.passwordTooLongBytes', {
125+
PASSWORD_MAX_BYTES,
126+
})}
127+
</p>
128+
)}
129+
</div>
61130

62131
{error && <AuthErrorBanner message={error} />}
63132

64-
<Button type="submit" disabled={loading} className="w-full">
133+
<Button type="submit" disabled={submitDisabled} className="w-full">
65134
{loading ? t('submitting') : t('submit')}
66135
</Button>
67136
</form>
68137
)}
69138
</AuthShell>
70139
);
71-
}
140+
}

0 commit comments

Comments
 (0)