From 880b7362b9158ac0d1eb5349c13c7a537fda80b4 Mon Sep 17 00:00:00 2001 From: kryvosheyin Date: Thu, 19 Feb 2026 01:54:45 +0000 Subject: [PATCH 1/5] Enhance signup and password reset flows --- .../api/auth/password-reset/confirm/route.ts | 26 ++- frontend/app/api/auth/signup/route.ts | 47 ++++- .../components/auth/ResetPasswordForm.tsx | 57 +++++- frontend/components/auth/SignupForm.tsx | 191 +++++++++++++++++- .../components/auth/fields/EmailField.tsx | 44 +++- frontend/components/auth/fields/NameField.tsx | 42 +++- .../components/auth/fields/PasswordField.tsx | 54 ++++- frontend/lib/auth/signup-constraints.ts | 11 + 8 files changed, 441 insertions(+), 31 deletions(-) create mode 100644 frontend/lib/auth/signup-constraints.ts diff --git a/frontend/app/api/auth/password-reset/confirm/route.ts b/frontend/app/api/auth/password-reset/confirm/route.ts index 11b5a872..4e6651b2 100644 --- a/frontend/app/api/auth/password-reset/confirm/route.ts +++ b/frontend/app/api/auth/password-reset/confirm/route.ts @@ -6,10 +6,27 @@ import { z } from 'zod'; import { db } from '@/db'; import { passwordResetTokens } from '@/db/schema/passwordResetTokens'; import { users } from '@/db/schema/users'; +import { + PASSWORD_MAX_LEN, + PASSWORD_MIN_LEN, + PASSWORD_POLICY_REGEX, +} from '@/lib/auth/signup-constraints'; const schema = z.object({ token: z.string().uuid(), - password: z.string().min(8), + password: z + .string() + .min( + PASSWORD_MIN_LEN, + `Password must be at least ${PASSWORD_MIN_LEN} characters` + ) + .max( + PASSWORD_MAX_LEN, + `Password must be at most ${PASSWORD_MAX_LEN} characters` + ) + .regex(/[A-Z]/, 'Password must contain at least one capital letter') + .regex(/[^A-Za-z0-9]/, 'Password must contain at least one special character') + .regex(PASSWORD_POLICY_REGEX, 'Password does not meet the required policy'), }); export async function POST(req: Request) { @@ -17,7 +34,10 @@ export async function POST(req: Request) { const parsed = schema.safeParse(body); if (!parsed.success) { - return NextResponse.json({ error: 'Invalid request' }, { status: 400 }); + return NextResponse.json( + { error: parsed.error.flatten().fieldErrors }, + { status: 400 } + ); } const { token, password } = parsed.data; @@ -60,4 +80,4 @@ export async function POST(req: Request) { await db.update(users).set({ passwordHash }).where(eq(users.id, userId)); return NextResponse.json({ success: true }); -} +} \ No newline at end of file diff --git a/frontend/app/api/auth/signup/route.ts b/frontend/app/api/auth/signup/route.ts index 95ef342d..bf877806 100644 --- a/frontend/app/api/auth/signup/route.ts +++ b/frontend/app/api/auth/signup/route.ts @@ -7,16 +7,51 @@ import { z } from 'zod'; import { db } from '@/db'; import { users } from '@/db/schema/users'; import { createEmailVerificationToken } from '@/lib/auth/email-verification'; +import { + EMAIL_MAX_LEN, + EMAIL_MIN_LEN, + NAME_MAX_LEN, + NAME_MIN_LEN, + PASSWORD_MAX_LEN, + PASSWORD_MIN_LEN, + PASSWORD_POLICY_REGEX, +} from '@/lib/auth/signup-constraints'; import { sendVerificationEmail } from '@/lib/email/sendVerificationEmail'; import { resolveBaseUrl } from '@/lib/http/getBaseUrl'; export const runtime = 'nodejs'; -const signupSchema = z.object({ - name: z.string().min(1, 'Name is required'), - email: z.string().email('Invalid email'), - password: z.string().min(8, 'Password must be at least 8 characters'), -}); +const signupSchema = z + .object({ + name: z + .string() + .trim() + .min(NAME_MIN_LEN, `Name must be at least ${NAME_MIN_LEN} characters`) + .max(NAME_MAX_LEN, `Name must be at most ${NAME_MAX_LEN} characters`), + email: z + .string() + .trim() + .min(EMAIL_MIN_LEN, `Email must be at least ${EMAIL_MIN_LEN} characters`) + .max(EMAIL_MAX_LEN, `Email must be at most ${EMAIL_MAX_LEN} characters`) + .email('Invalid email'), + password: z + .string() + .min(PASSWORD_MIN_LEN, `Password must be at least ${PASSWORD_MIN_LEN} characters`) + .max(PASSWORD_MAX_LEN, `Password must be at most ${PASSWORD_MAX_LEN} characters`) + .regex(/[A-Z]/, 'Password must contain at least one capital letter') + .regex(/[^A-Za-z0-9]/, 'Password must contain at least one special character') + .regex(PASSWORD_POLICY_REGEX, 'Password does not meet the required policy'), + confirmPassword: z.string(), + }) + .superRefine((val, ctx) => { + if (val.password !== val.confirmPassword) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['confirmPassword'], + message: 'Passwords do not match', + }); + } + }); export async function POST(req: Request) { try { @@ -88,4 +123,4 @@ export async function POST(req: Request) { { status: 500 } ); } -} +} \ No newline at end of file diff --git a/frontend/components/auth/ResetPasswordForm.tsx b/frontend/components/auth/ResetPasswordForm.tsx index 3517c3ed..58165d6d 100644 --- a/frontend/components/auth/ResetPasswordForm.tsx +++ b/frontend/components/auth/ResetPasswordForm.tsx @@ -1,13 +1,18 @@ 'use client'; import { useTranslations } from 'next-intl'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { AuthErrorBanner } from '@/components/auth/AuthErrorBanner'; import { AuthShell } from '@/components/auth/AuthShell'; import { AuthSuccessBanner } from '@/components/auth/AuthSuccessBanner'; import { PasswordField } from '@/components/auth/fields/PasswordField'; import { Button } from '@/components/ui/button'; +import { + PASSWORD_MAX_LEN, + PASSWORD_MIN_LEN, + PASSWORD_POLICY_REGEX, +} from '@/lib/auth/signup-constraints'; type ResetPasswordFormProps = { token: string; @@ -19,8 +24,31 @@ export function ResetPasswordForm({ token }: ResetPasswordFormProps) { const [error, setError] = useState(null); const [success, setSuccess] = useState(false); + const [passwordValue, setPasswordValue] = useState(''); + const [passwordTouched, setPasswordTouched] = useState(false); + + const passwordPolicyOk = useMemo(() => { + if (!passwordValue) return false; + if (passwordValue.length < PASSWORD_MIN_LEN) return false; + if (passwordValue.length > PASSWORD_MAX_LEN) return false; + return PASSWORD_POLICY_REGEX.test(passwordValue); + }, [passwordValue]); + + const passwordRequirementsText = + '8–128 characters, at least one capital letter, and at least one special character.'; + + const passwordErrorText = + passwordTouched && !passwordPolicyOk + ? `Password must meet requirements: ${passwordRequirementsText}` + : null; + + const submitDisabled = loading || !passwordPolicyOk; + async function onSubmit(e: React.FormEvent) { e.preventDefault(); + + if (submitDisabled) return; + setLoading(true); setError(null); @@ -38,8 +66,12 @@ export function ResetPasswordForm({ token }: ResetPasswordFormProps) { }), }); + const data = await res.json().catch(() => null); + if (!res.ok) { - setError(t('errors.resetFailed')); + const msg = + typeof data?.error === 'string' ? data.error : t('errors.resetFailed'); + setError(msg); return; } @@ -57,15 +89,30 @@ export function ResetPasswordForm({ token }: ResetPasswordFormProps) { ) : (
- +
+ setPasswordTouched(true)} + /> + {passwordErrorText && ( +

{passwordErrorText}

+ )} +
{error && } - )} ); -} +} \ No newline at end of file diff --git a/frontend/components/auth/SignupForm.tsx b/frontend/components/auth/SignupForm.tsx index aec0ed7d..bbaff096 100644 --- a/frontend/components/auth/SignupForm.tsx +++ b/frontend/components/auth/SignupForm.tsx @@ -1,7 +1,8 @@ 'use client'; import { useTranslations } from 'next-intl'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; +import { z } from 'zod'; import { AuthErrorBanner } from '@/components/auth/AuthErrorBanner'; import { AuthProvidersBlock } from '@/components/auth/AuthProvidersBlock'; @@ -12,6 +13,15 @@ import { NameField } from '@/components/auth/fields/NameField'; import { PasswordField } from '@/components/auth/fields/PasswordField'; import { Button } from '@/components/ui/button'; import { Link } from '@/i18n/routing'; +import { + EMAIL_MAX_LEN, + EMAIL_MIN_LEN, + NAME_MAX_LEN, + NAME_MIN_LEN, + PASSWORD_MAX_LEN, + PASSWORD_MIN_LEN, + PASSWORD_POLICY_REGEX, +} from '@/lib/auth/signup-constraints'; type SignupFormProps = { locale: string; @@ -20,13 +30,120 @@ type SignupFormProps = { export function SignupForm({ locale, returnTo }: SignupFormProps) { const t = useTranslations('auth.signup'); + const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [verificationRequired, setVerificationRequired] = useState(false); const [email, setEmail] = useState(''); + // Live values + const [nameValue, setNameValue] = useState(''); + const [emailValueLive, setEmailValueLive] = useState(''); + const [passwordValue, setPasswordValue] = useState(''); + const [confirmPasswordValue, setConfirmPasswordValue] = useState(''); + + // Touched flags (show messages only after blur) + const [nameTouched, setNameTouched] = useState(false); + const [emailTouched, setEmailTouched] = useState(false); + const [passwordTouched, setPasswordTouched] = useState(false); + const [confirmPasswordTouched, setConfirmPasswordTouched] = useState(false); + + const nameTrimmed = useMemo(() => nameValue.trim(), [nameValue]); + const emailTrimmed = useMemo(() => emailValueLive.trim(), [emailValueLive]); + + const nameLooksValid = useMemo(() => { + return nameTrimmed.length >= NAME_MIN_LEN && nameTrimmed.length <= NAME_MAX_LEN; + }, [nameTrimmed]); + + const emailFormatOk = useMemo(() => { + if (!emailTrimmed) return false; + return z.string().email().safeParse(emailTrimmed).success; + }, [emailTrimmed]); + + const emailLooksValid = useMemo(() => { + if (!emailTrimmed) return false; + if (emailTrimmed.length < EMAIL_MIN_LEN) return false; + if (emailTrimmed.length > EMAIL_MAX_LEN) return false; + return emailFormatOk; + }, [emailTrimmed, emailFormatOk]); + + const passwordPolicyOk = useMemo(() => { + if (!passwordValue) return false; + if (passwordValue.length < PASSWORD_MIN_LEN) return false; + if (passwordValue.length > PASSWORD_MAX_LEN) return false; + return PASSWORD_POLICY_REGEX.test(passwordValue); + }, [passwordValue]); + + const confirmPasswordPolicyOk = useMemo(() => { + if (!confirmPasswordValue) return false; + if (confirmPasswordValue.length < PASSWORD_MIN_LEN) return false; + if (confirmPasswordValue.length > PASSWORD_MAX_LEN) return false; + return PASSWORD_POLICY_REGEX.test(confirmPasswordValue); + }, [confirmPasswordValue]); + + const passwordsMatch = useMemo(() => { + if (!passwordValue || !confirmPasswordValue) return false; + return passwordValue === confirmPasswordValue; + }, [passwordValue, confirmPasswordValue]); + + const nameErrorText = + nameTouched && !nameLooksValid + ? `Name must be at least ${NAME_MIN_LEN} characters and at most ${NAME_MAX_LEN} characters` + : null; + + const emailErrorText = useMemo(() => { + if (!emailTouched) return null; + + if (!emailTrimmed) return null; + + if (emailTrimmed.length > EMAIL_MAX_LEN) { + return `Email must not exceed ${EMAIL_MAX_LEN} characters.`; + } + + if (!emailFormatOk) { + return 'Email format is invalid.'; + } + + return null; + }, [emailTouched, emailTrimmed, emailFormatOk]); + + const passwordRequirementsText = + '8–128 characters, at least one capital letter, and at least one special character.'; + + const passwordErrorText = + passwordTouched && !passwordPolicyOk + ? `Password must meet requirements: ${passwordRequirementsText}` + : null; + + const confirmPolicyErrorText = + confirmPasswordTouched && !confirmPasswordPolicyOk + ? `Repeat password must meet requirements: ${passwordRequirementsText}` + : null; + + const mismatchErrorText = + confirmPasswordTouched && + passwordTouched && + passwordValue.length > 0 && + confirmPasswordValue.length > 0 && + !passwordsMatch + ? 'Passwords do not match.' + : null; + + const confirmPasswordErrorText = + mismatchErrorText ?? confirmPolicyErrorText ?? null; + + const submitDisabled = + loading || + !nameLooksValid || + !emailLooksValid || + !passwordPolicyOk || + !confirmPasswordPolicyOk || + !passwordsMatch; + async function onSubmit(e: React.FormEvent) { e.preventDefault(); + if (submitDisabled) return; + setLoading(true); setError(null); @@ -42,13 +159,16 @@ export function SignupForm({ locale, returnTo }: SignupFormProps) { name: formData.get('name'), email: emailValue, password: formData.get('password'), + confirmPassword: formData.get('confirmPassword'), }), }); const data = await res.json().catch(() => null); if (!res.ok) { - setError(data?.error ?? t('errors.signupFailed')); + const msg = + typeof data?.error === 'string' ? data.error : t('errors.signupFailed'); + setError(msg); return; } @@ -95,7 +215,6 @@ export function SignupForm({ locale, returnTo }: SignupFormProps) {

{t('verificationSent')} {email}.

-

{t('checkInbox')}

} @@ -114,19 +233,75 @@ export function SignupForm({ locale, returnTo }: SignupFormProps) { /> ) : (
- +
+ setNameTouched(true)} + /> + {nameErrorText && ( +

{nameErrorText}

+ )} +
+ +
+ { + setEmailValueLive(value); + setEmail(value); + + }} + onBlur={() => setEmailTouched(true)} + /> + {emailErrorText && ( +

{emailErrorText}

+ )} +
- +
+ setPasswordTouched(true)} + /> + {passwordErrorText && ( +

{passwordErrorText}

+ )} +
- +
+ setConfirmPasswordTouched(true)} + /> + {confirmPasswordErrorText && ( +

{confirmPasswordErrorText}

+ )} +
{error && } - )} ); -} +} \ No newline at end of file diff --git a/frontend/components/auth/fields/EmailField.tsx b/frontend/components/auth/fields/EmailField.tsx index e1b3c705..a64a5d33 100644 --- a/frontend/components/auth/fields/EmailField.tsx +++ b/frontend/components/auth/fields/EmailField.tsx @@ -4,17 +4,40 @@ import { useTranslations } from 'next-intl'; type EmailFieldProps = { onChange?: (value: string) => void; + minLength?: number; + maxLength?: number; + onBlur?: React.FocusEventHandler; }; -export function EmailField({ onChange }: EmailFieldProps) { +export function EmailField({ + onChange, + minLength, + maxLength, + onBlur, +}: EmailFieldProps) { const t = useTranslations('auth.fields'); const handleInvalid = (e: React.InvalidEvent) => { const input = e.currentTarget; + if (input.validity.valueMissing) { input.setCustomValidity(t('validation.required')); - } else if (input.validity.typeMismatch) { + return; + } + + if (input.validity.tooShort && minLength) { + input.setCustomValidity(`Email must be at least ${minLength} characters.`); + return; + } + + if (input.validity.typeMismatch) { input.setCustomValidity(t('validation.invalidEmail')); + return; + } + + if (input.validity.tooLong && maxLength) { + input.setCustomValidity(`Email must be at most ${maxLength} characters.`); + return; } }; @@ -22,16 +45,31 @@ export function EmailField({ onChange }: EmailFieldProps) { e.currentTarget.setCustomValidity(''); }; + const handleBlur = (e: React.FocusEvent) => { + const input = e.currentTarget; + const trimmed = input.value.trim(); + + if (trimmed !== input.value) { + input.value = trimmed; + } + + onChange?.(trimmed); + onBlur?.(e); + }; + return ( onChange(e.currentTarget.value) : undefined} + onBlur={handleBlur} /> ); -} +} \ No newline at end of file diff --git a/frontend/components/auth/fields/NameField.tsx b/frontend/components/auth/fields/NameField.tsx index 0eca8d95..fb8da018 100644 --- a/frontend/components/auth/fields/NameField.tsx +++ b/frontend/components/auth/fields/NameField.tsx @@ -4,15 +4,37 @@ import { useTranslations } from 'next-intl'; type NameFieldProps = { name?: string; + minLength?: number; + maxLength?: number; + onChange?: (value: string) => void; + onBlur?: React.FocusEventHandler; }; -export function NameField({ name = 'name' }: NameFieldProps) { +export function NameField({ + name = 'name', + minLength, + maxLength, + onChange, + onBlur +}: NameFieldProps) { const t = useTranslations('auth.fields'); const handleInvalid = (e: React.InvalidEvent) => { const input = e.currentTarget; + if (input.validity.valueMissing) { input.setCustomValidity(t('validation.required')); + return; + } + + if (input.validity.tooShort && minLength) { + input.setCustomValidity(`Name must be at least ${minLength} characters.`); + return; + } + + if (input.validity.tooLong && maxLength) { + input.setCustomValidity(`Name must be at most ${maxLength} characters.`); + return; } }; @@ -20,15 +42,31 @@ export function NameField({ name = 'name' }: NameFieldProps) { e.currentTarget.setCustomValidity(''); }; + const handleBlur = (e: React.FocusEvent) => { + const input = e.currentTarget; + const trimmed = input.value.trim(); + + if (trimmed !== input.value) { + input.value = trimmed; + } + + onChange?.(trimmed); + onBlur?.(e); + }; + return ( onChange(e.currentTarget.value) : undefined} /> ); -} +} \ No newline at end of file diff --git a/frontend/components/auth/fields/PasswordField.tsx b/frontend/components/auth/fields/PasswordField.tsx index f42a8359..cb6b2cc8 100644 --- a/frontend/components/auth/fields/PasswordField.tsx +++ b/frontend/components/auth/fields/PasswordField.tsx @@ -5,40 +5,86 @@ import { useState } from 'react'; type PasswordFieldProps = { name?: string; + id?: string; minLength?: number; + maxLength?: number; + pattern?: string; + onChange?: (value: string) => void; + customError?: string | null; + + placeholder?: string; + autoComplete?: string; + ariaLabel?: string; + onBlur?: React.FocusEventHandler; }; export function PasswordField({ name = 'password', + id, minLength, + maxLength, + pattern, + onChange, + customError, + placeholder, + autoComplete = 'new-password', + ariaLabel, + onBlur, }: PasswordFieldProps) { const t = useTranslations('auth.fields'); const [visible, setVisible] = useState(false); const handleInvalid = (e: React.InvalidEvent) => { const input = e.currentTarget; + if (input.validity.valueMissing) { input.setCustomValidity(t('validation.required')); - } else if (input.validity.tooShort && minLength) { + return; + } + + if (input.validity.tooShort && minLength) { input.setCustomValidity(t('validation.passwordTooShort', { minLength })); + return; + } + + if (input.validity.tooLong && maxLength) { + input.setCustomValidity(`Password must be at most ${maxLength} characters.`); + return; + } + + if (input.validity.patternMismatch) { + input.setCustomValidity( + 'Password must include at least one capital letter and one special character.' + ); + return; } }; const handleInput = (e: React.FormEvent) => { - e.currentTarget.setCustomValidity(''); + const input = e.currentTarget; + input.setCustomValidity(customError ?? ''); }; + const resolvedPlaceholder = placeholder ?? t('password'); + return (
onChange(e.currentTarget.value) : undefined} />
); -} +} \ No newline at end of file diff --git a/frontend/lib/auth/signup-constraints.ts b/frontend/lib/auth/signup-constraints.ts new file mode 100644 index 00000000..4d3eb3a2 --- /dev/null +++ b/frontend/lib/auth/signup-constraints.ts @@ -0,0 +1,11 @@ +export const NAME_MIN_LEN = 3; +export const NAME_MAX_LEN = 64; + +export const EMAIL_MIN_LEN = 8; +export const EMAIL_MAX_LEN = 254; + +export const PASSWORD_MIN_LEN = 8; +export const PASSWORD_MAX_LEN = 128; + +export const PASSWORD_POLICY_REGEX = + /^(?=.*[A-Z])(?=.*[^A-Za-z0-9]).{8,128}$/; \ No newline at end of file From 1190ca436fad67cd67ef796e8397972f685a697c Mon Sep 17 00:00:00 2001 From: kryvosheyin Date: Thu, 19 Feb 2026 15:09:18 +0000 Subject: [PATCH 2/5] Address minor reviewer comments --- .../api/auth/password-reset/confirm/route.ts | 25 +++++++++++++++---- frontend/components/auth/SignupForm.tsx | 8 +++--- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/frontend/app/api/auth/password-reset/confirm/route.ts b/frontend/app/api/auth/password-reset/confirm/route.ts index 4e6651b2..cd60d10e 100644 --- a/frontend/app/api/auth/password-reset/confirm/route.ts +++ b/frontend/app/api/auth/password-reset/confirm/route.ts @@ -25,19 +25,34 @@ const schema = z.object({ `Password must be at most ${PASSWORD_MAX_LEN} characters` ) .regex(/[A-Z]/, 'Password must contain at least one capital letter') - .regex(/[^A-Za-z0-9]/, 'Password must contain at least one special character') + .regex( + /[^A-Za-z0-9]/, + 'Password must contain at least one special character' + ) .regex(PASSWORD_POLICY_REGEX, 'Password does not meet the required policy'), }); +function firstFieldErrorMessage( + fieldErrors: Record +): string | null { + for (const key of Object.keys(fieldErrors)) { + const msgs = fieldErrors[key]; + if (Array.isArray(msgs) && msgs.length > 0 && msgs[0]) { + return msgs[0]; + } + } + return null; +} + export async function POST(req: Request) { const body = await req.json().catch(() => null); const parsed = schema.safeParse(body); if (!parsed.success) { - return NextResponse.json( - { error: parsed.error.flatten().fieldErrors }, - { status: 400 } - ); + const flattened = parsed.error.flatten().fieldErrors; + const firstMsg = + firstFieldErrorMessage(flattened) ?? 'Invalid request'; + return NextResponse.json({ error: firstMsg }, { status: 400 }); } const { token, password } = parsed.data; diff --git a/frontend/components/auth/SignupForm.tsx b/frontend/components/auth/SignupForm.tsx index bbaff096..c5ee4a39 100644 --- a/frontend/components/auth/SignupForm.tsx +++ b/frontend/components/auth/SignupForm.tsx @@ -36,13 +36,11 @@ export function SignupForm({ locale, returnTo }: SignupFormProps) { const [verificationRequired, setVerificationRequired] = useState(false); const [email, setEmail] = useState(''); - // Live values const [nameValue, setNameValue] = useState(''); const [emailValueLive, setEmailValueLive] = useState(''); const [passwordValue, setPasswordValue] = useState(''); const [confirmPasswordValue, setConfirmPasswordValue] = useState(''); - // Touched flags (show messages only after blur) const [nameTouched, setNameTouched] = useState(false); const [emailTouched, setEmailTouched] = useState(false); const [passwordTouched, setPasswordTouched] = useState(false); @@ -94,7 +92,7 @@ export function SignupForm({ locale, returnTo }: SignupFormProps) { const emailErrorText = useMemo(() => { if (!emailTouched) return null; - if (!emailTrimmed) return null; + if (!emailTrimmed) return "Email is required"; if (emailTrimmed.length > EMAIL_MAX_LEN) { return `Email must not exceed ${EMAIL_MAX_LEN} characters.`; @@ -117,7 +115,7 @@ export function SignupForm({ locale, returnTo }: SignupFormProps) { const confirmPolicyErrorText = confirmPasswordTouched && !confirmPasswordPolicyOk - ? `Repeat password must meet requirements: ${passwordRequirementsText}` + ? `Password must meet requirements: ${passwordRequirementsText}` : null; const mismatchErrorText = @@ -282,7 +280,7 @@ export function SignupForm({ locale, returnTo }: SignupFormProps) { Date: Fri, 20 Feb 2026 09:51:16 +0000 Subject: [PATCH 3/5] Harden signup/reset flows: add trimmed name/email validation, password policy with bcrypt 72-byte limit, confirm-password match, and blur-gated inline errors --- .../api/auth/password-reset/confirm/route.ts | 24 ++---- frontend/app/api/auth/signup/route.ts | 31 ++++---- .../components/auth/ResetPasswordForm.tsx | 12 +-- frontend/components/auth/SignupForm.tsx | 69 +++++++++------- .../components/auth/fields/EmailField.tsx | 8 +- frontend/components/auth/fields/NameField.tsx | 2 +- .../components/auth/fields/PasswordField.tsx | 13 +--- frontend/lib/auth/password-bytes.ts | 3 + frontend/lib/auth/signup-constraints.ts | 6 +- frontend/messages/en.json | 78 +++++++++++++++---- frontend/messages/pl.json | 33 ++++++-- frontend/messages/uk.json | 75 ++++++++++++++---- 12 files changed, 229 insertions(+), 125 deletions(-) create mode 100644 frontend/lib/auth/password-bytes.ts diff --git a/frontend/app/api/auth/password-reset/confirm/route.ts b/frontend/app/api/auth/password-reset/confirm/route.ts index cd60d10e..0638da2a 100644 --- a/frontend/app/api/auth/password-reset/confirm/route.ts +++ b/frontend/app/api/auth/password-reset/confirm/route.ts @@ -6,30 +6,20 @@ import { z } from 'zod'; import { db } from '@/db'; import { passwordResetTokens } from '@/db/schema/passwordResetTokens'; import { users } from '@/db/schema/users'; -import { - PASSWORD_MAX_LEN, - PASSWORD_MIN_LEN, - PASSWORD_POLICY_REGEX, -} from '@/lib/auth/signup-constraints'; +import { PASSWORD_MAX_BYTES, PASSWORD_MIN_LEN, PASSWORD_POLICY_REGEX } from '@/lib/auth/signup-constraints'; const schema = z.object({ token: z.string().uuid(), password: z .string() - .min( - PASSWORD_MIN_LEN, - `Password must be at least ${PASSWORD_MIN_LEN} characters` - ) - .max( - PASSWORD_MAX_LEN, - `Password must be at most ${PASSWORD_MAX_LEN} characters` - ) + .min(PASSWORD_MIN_LEN, `Password must be at least ${PASSWORD_MIN_LEN} characters`) .regex(/[A-Z]/, 'Password must contain at least one capital letter') - .regex( - /[^A-Za-z0-9]/, - 'Password must contain at least one special character' + .regex(/[^A-Za-z0-9]/, 'Password must contain at least one special character') + .regex(PASSWORD_POLICY_REGEX, 'Password does not meet the required policy') + .refine( + (val) => Buffer.byteLength(val, 'utf8') <= PASSWORD_MAX_BYTES, + `Password must be at most ${PASSWORD_MAX_BYTES} bytes` ) - .regex(PASSWORD_POLICY_REGEX, 'Password does not meet the required policy'), }); function firstFieldErrorMessage( diff --git a/frontend/app/api/auth/signup/route.ts b/frontend/app/api/auth/signup/route.ts index bf877806..910b6986 100644 --- a/frontend/app/api/auth/signup/route.ts +++ b/frontend/app/api/auth/signup/route.ts @@ -12,7 +12,7 @@ import { EMAIL_MIN_LEN, NAME_MAX_LEN, NAME_MIN_LEN, - PASSWORD_MAX_LEN, + PASSWORD_MAX_BYTES, PASSWORD_MIN_LEN, PASSWORD_POLICY_REGEX, } from '@/lib/auth/signup-constraints'; @@ -21,6 +21,20 @@ import { resolveBaseUrl } from '@/lib/http/getBaseUrl'; export const runtime = 'nodejs'; +const passwordSchema = z + .string() + .min( + PASSWORD_MIN_LEN, + `Password must be at least ${PASSWORD_MIN_LEN} characters` + ) + .regex(/[A-Z]/, 'Password must contain at least one capital letter') + .regex(/[^A-Za-z0-9]/, 'Password must contain at least one special character') + .regex(PASSWORD_POLICY_REGEX, 'Password does not meet the required policy') + .refine( + val => Buffer.byteLength(val, 'utf8') <= PASSWORD_MAX_BYTES, + `Password must be at most ${PASSWORD_MAX_BYTES} bytes` + ); + const signupSchema = z .object({ name: z @@ -34,14 +48,8 @@ const signupSchema = z .min(EMAIL_MIN_LEN, `Email must be at least ${EMAIL_MIN_LEN} characters`) .max(EMAIL_MAX_LEN, `Email must be at most ${EMAIL_MAX_LEN} characters`) .email('Invalid email'), - password: z - .string() - .min(PASSWORD_MIN_LEN, `Password must be at least ${PASSWORD_MIN_LEN} characters`) - .max(PASSWORD_MAX_LEN, `Password must be at most ${PASSWORD_MAX_LEN} characters`) - .regex(/[A-Z]/, 'Password must contain at least one capital letter') - .regex(/[^A-Za-z0-9]/, 'Password must contain at least one special character') - .regex(PASSWORD_POLICY_REGEX, 'Password does not meet the required policy'), - confirmPassword: z.string(), + password: passwordSchema, + confirmPassword: passwordSchema, }) .superRefine((val, ctx) => { if (val.password !== val.confirmPassword) { @@ -109,10 +117,7 @@ export async function POST(req: Request) { }); return NextResponse.json( - { - success: true, - verificationRequired: true, - }, + { success: true, verificationRequired: true }, { status: 201 } ); } catch (error) { diff --git a/frontend/components/auth/ResetPasswordForm.tsx b/frontend/components/auth/ResetPasswordForm.tsx index 58165d6d..43b48774 100644 --- a/frontend/components/auth/ResetPasswordForm.tsx +++ b/frontend/components/auth/ResetPasswordForm.tsx @@ -9,7 +9,7 @@ import { AuthSuccessBanner } from '@/components/auth/AuthSuccessBanner'; import { PasswordField } from '@/components/auth/fields/PasswordField'; import { Button } from '@/components/ui/button'; import { - PASSWORD_MAX_LEN, + PASSWORD_MAX_BYTES, PASSWORD_MIN_LEN, PASSWORD_POLICY_REGEX, } from '@/lib/auth/signup-constraints'; @@ -20,6 +20,7 @@ type ResetPasswordFormProps = { export function ResetPasswordForm({ token }: ResetPasswordFormProps) { const t = useTranslations('auth.resetPassword'); + const tf = useTranslations('auth.fields'); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(false); @@ -30,16 +31,16 @@ export function ResetPasswordForm({ token }: ResetPasswordFormProps) { const passwordPolicyOk = useMemo(() => { if (!passwordValue) return false; if (passwordValue.length < PASSWORD_MIN_LEN) return false; - if (passwordValue.length > PASSWORD_MAX_LEN) return false; + if (passwordValue.length > PASSWORD_MAX_BYTES) return false; return PASSWORD_POLICY_REGEX.test(passwordValue); }, [passwordValue]); const passwordRequirementsText = - '8–128 characters, at least one capital letter, and at least one special character.'; + tf('validation.passwordRequirements', { PASSWORD_MIN_LEN, PASSWORD_MAX_BYTES }); const passwordErrorText = passwordTouched && !passwordPolicyOk - ? `Password must meet requirements: ${passwordRequirementsText}` + ? tf('validation.invalidPassword', { passwordRequirementsText }) : null; const submitDisabled = loading || !passwordPolicyOk; @@ -93,10 +94,9 @@ export function ResetPasswordForm({ token }: ResetPasswordFormProps) { setPasswordTouched(true)} diff --git a/frontend/components/auth/SignupForm.tsx b/frontend/components/auth/SignupForm.tsx index c5ee4a39..339406e6 100644 --- a/frontend/components/auth/SignupForm.tsx +++ b/frontend/components/auth/SignupForm.tsx @@ -18,7 +18,7 @@ import { EMAIL_MIN_LEN, NAME_MAX_LEN, NAME_MIN_LEN, - PASSWORD_MAX_LEN, + PASSWORD_MAX_BYTES, PASSWORD_MIN_LEN, PASSWORD_POLICY_REGEX, } from '@/lib/auth/signup-constraints'; @@ -28,8 +28,13 @@ type SignupFormProps = { returnTo: string; }; +function utf8ByteLength(value: string): number { + return new TextEncoder().encode(value).length; +} + export function SignupForm({ locale, returnTo }: SignupFormProps) { const t = useTranslations('auth.signup'); + const tf = useTranslations('auth.fields'); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -68,15 +73,17 @@ export function SignupForm({ locale, returnTo }: SignupFormProps) { const passwordPolicyOk = useMemo(() => { if (!passwordValue) return false; if (passwordValue.length < PASSWORD_MIN_LEN) return false; - if (passwordValue.length > PASSWORD_MAX_LEN) return false; - return PASSWORD_POLICY_REGEX.test(passwordValue); + if (!PASSWORD_POLICY_REGEX.test(passwordValue)) return false; + if (utf8ByteLength(passwordValue) > PASSWORD_MAX_BYTES) return false; + return true; }, [passwordValue]); const confirmPasswordPolicyOk = useMemo(() => { if (!confirmPasswordValue) return false; if (confirmPasswordValue.length < PASSWORD_MIN_LEN) return false; - if (confirmPasswordValue.length > PASSWORD_MAX_LEN) return false; - return PASSWORD_POLICY_REGEX.test(confirmPasswordValue); + if (!PASSWORD_POLICY_REGEX.test(confirmPasswordValue)) return false; + if (utf8ByteLength(confirmPasswordValue) > PASSWORD_MAX_BYTES) return false; + return true; }, [confirmPasswordValue]); const passwordsMatch = useMemo(() => { @@ -86,36 +93,37 @@ export function SignupForm({ locale, returnTo }: SignupFormProps) { const nameErrorText = nameTouched && !nameLooksValid - ? `Name must be at least ${NAME_MIN_LEN} characters and at most ${NAME_MAX_LEN} characters` + ? tf('validation.invalidName', { NAME_MIN_LEN, NAME_MAX_LEN }) : null; const emailErrorText = useMemo(() => { if (!emailTouched) return null; - - if (!emailTrimmed) return "Email is required"; + if (!emailTrimmed) return null; if (emailTrimmed.length > EMAIL_MAX_LEN) { - return `Email must not exceed ${EMAIL_MAX_LEN} characters.`; + return tf('validation.emailTooLong', { EMAIL_MAX_LEN }); } if (!emailFormatOk) { - return 'Email format is invalid.'; + return tf('validation.invalidEmail'); } return null; - }, [emailTouched, emailTrimmed, emailFormatOk]); + }, [emailTouched, emailTrimmed, emailFormatOk, tf]); - const passwordRequirementsText = - '8–128 characters, at least one capital letter, and at least one special character.'; + const passwordRequirementsText = tf('validation.passwordRequirements', { + PASSWORD_MIN_LEN, + PASSWORD_MAX_BYTES, + }); const passwordErrorText = passwordTouched && !passwordPolicyOk - ? `Password must meet requirements: ${passwordRequirementsText}` + ? tf('validation.invalidPassword', { passwordRequirementsText }) : null; const confirmPolicyErrorText = confirmPasswordTouched && !confirmPasswordPolicyOk - ? `Password must meet requirements: ${passwordRequirementsText}` + ? tf('validation.invalidPassword', { passwordRequirementsText }) : null; const mismatchErrorText = @@ -124,11 +132,10 @@ export function SignupForm({ locale, returnTo }: SignupFormProps) { passwordValue.length > 0 && confirmPasswordValue.length > 0 && !passwordsMatch - ? 'Passwords do not match.' + ? tf('validation.passwordsDontMatch') : null; - const confirmPasswordErrorText = - mismatchErrorText ?? confirmPolicyErrorText ?? null; + const confirmPasswordErrorText = mismatchErrorText ?? confirmPolicyErrorText ?? null; const submitDisabled = loading || @@ -238,9 +245,7 @@ export function SignupForm({ locale, returnTo }: SignupFormProps) { onChange={setNameValue} onBlur={() => setNameTouched(true)} /> - {nameErrorText && ( -

{nameErrorText}

- )} + {nameErrorText &&

{nameErrorText}

}
@@ -250,23 +255,19 @@ export function SignupForm({ locale, returnTo }: SignupFormProps) { onChange={value => { setEmailValueLive(value); setEmail(value); - }} onBlur={() => setEmailTouched(true)} /> - {emailErrorText && ( -

{emailErrorText}

- )} + {emailErrorText &&

{emailErrorText}

}
setPasswordTouched(true)} @@ -274,16 +275,20 @@ export function SignupForm({ locale, returnTo }: SignupFormProps) { {passwordErrorText && (

{passwordErrorText}

)} + {passwordTouched && utf8ByteLength(passwordValue) > PASSWORD_MAX_BYTES && ( +

+ {tf('validation.passwordTooLongBytes', { PASSWORD_MAX_BYTES })} +

+ )}
setConfirmPasswordTouched(true)} @@ -291,6 +296,12 @@ export function SignupForm({ locale, returnTo }: SignupFormProps) { {confirmPasswordErrorText && (

{confirmPasswordErrorText}

)} + {confirmPasswordTouched && + utf8ByteLength(confirmPasswordValue) > PASSWORD_MAX_BYTES && ( +

+ {tf('validation.passwordTooLongBytes', { PASSWORD_MAX_BYTES })} +

+ )}
{error && } diff --git a/frontend/components/auth/fields/EmailField.tsx b/frontend/components/auth/fields/EmailField.tsx index a64a5d33..7ea361c3 100644 --- a/frontend/components/auth/fields/EmailField.tsx +++ b/frontend/components/auth/fields/EmailField.tsx @@ -45,13 +45,11 @@ export function EmailField({ e.currentTarget.setCustomValidity(''); }; - const handleBlur = (e: React.FocusEvent) => { + const handleBlur: React.FocusEventHandler = (e) => { const input = e.currentTarget; const trimmed = input.value.trim(); - if (trimmed !== input.value) { - input.value = trimmed; - } + input.value = trimmed; onChange?.(trimmed); onBlur?.(e); @@ -68,7 +66,7 @@ export function EmailField({ className="w-full rounded border px-3 py-2" onInvalid={handleInvalid} onInput={handleInput} - onChange={onChange ? e => onChange(e.currentTarget.value) : undefined} + onChange={e => onChange?.(e.currentTarget.value)} onBlur={handleBlur} /> ); diff --git a/frontend/components/auth/fields/NameField.tsx b/frontend/components/auth/fields/NameField.tsx index fb8da018..a84b8056 100644 --- a/frontend/components/auth/fields/NameField.tsx +++ b/frontend/components/auth/fields/NameField.tsx @@ -15,7 +15,7 @@ export function NameField({ minLength, maxLength, onChange, - onBlur + onBlur, }: NameFieldProps) { const t = useTranslations('auth.fields'); diff --git a/frontend/components/auth/fields/PasswordField.tsx b/frontend/components/auth/fields/PasswordField.tsx index cb6b2cc8..75e3e5ec 100644 --- a/frontend/components/auth/fields/PasswordField.tsx +++ b/frontend/components/auth/fields/PasswordField.tsx @@ -7,10 +7,8 @@ type PasswordFieldProps = { name?: string; id?: string; minLength?: number; - maxLength?: number; pattern?: string; onChange?: (value: string) => void; - customError?: string | null; placeholder?: string; autoComplete?: string; @@ -22,10 +20,8 @@ export function PasswordField({ name = 'password', id, minLength, - maxLength, pattern, onChange, - customError, placeholder, autoComplete = 'new-password', ariaLabel, @@ -47,11 +43,6 @@ export function PasswordField({ return; } - if (input.validity.tooLong && maxLength) { - input.setCustomValidity(`Password must be at most ${maxLength} characters.`); - return; - } - if (input.validity.patternMismatch) { input.setCustomValidity( 'Password must include at least one capital letter and one special character.' @@ -61,8 +52,7 @@ export function PasswordField({ }; const handleInput = (e: React.FormEvent) => { - const input = e.currentTarget; - input.setCustomValidity(customError ?? ''); + e.currentTarget.setCustomValidity(''); }; const resolvedPlaceholder = placeholder ?? t('password'); @@ -78,7 +68,6 @@ export function PasswordField({ autoComplete={autoComplete} required minLength={minLength} - maxLength={maxLength} pattern={pattern} className="w-full rounded border px-3 py-2 pr-10" onInvalid={handleInvalid} diff --git a/frontend/lib/auth/password-bytes.ts b/frontend/lib/auth/password-bytes.ts new file mode 100644 index 00000000..c8da1f38 --- /dev/null +++ b/frontend/lib/auth/password-bytes.ts @@ -0,0 +1,3 @@ +export function utf8ByteLength(value: string): number { + return new TextEncoder().encode(value).length; +} \ No newline at end of file diff --git a/frontend/lib/auth/signup-constraints.ts b/frontend/lib/auth/signup-constraints.ts index 4d3eb3a2..f97200d7 100644 --- a/frontend/lib/auth/signup-constraints.ts +++ b/frontend/lib/auth/signup-constraints.ts @@ -5,7 +5,7 @@ export const EMAIL_MIN_LEN = 8; export const EMAIL_MAX_LEN = 254; export const PASSWORD_MIN_LEN = 8; -export const PASSWORD_MAX_LEN = 128; -export const PASSWORD_POLICY_REGEX = - /^(?=.*[A-Z])(?=.*[^A-Za-z0-9]).{8,128}$/; \ No newline at end of file +export const PASSWORD_MAX_BYTES = 72; + +export const PASSWORD_POLICY_REGEX = /^(?=.*[A-Z])(?=.*[^A-Za-z0-9]).{8,}$/; \ No newline at end of file diff --git a/frontend/messages/en.json b/frontend/messages/en.json index cbefab30..a7d5a944 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -823,24 +823,42 @@ "label": "Auto Sync", "desc": "Saves progress post-login" }, - "tracking": { "label": "Tracking", "desc": "Best scores & attempts" } + "tracking": { + "label": "Tracking", + "desc": "Best scores & attempts" + } }, "leaderboard": { "podium": { "label": "The Podium", "desc": "Top 3 exclusive spotlight" }, - "globalRank": { "label": "Global Rank", "desc": "Compete worldwide" }, + "globalRank": { + "label": "Global Rank", + "desc": "Compete worldwide" + }, "xpSystem": { "label": "XP System", "desc": "Points for every answer" }, - "liveFeed": { "label": "Live Feed", "desc": "Real-time rank updates" } + "liveFeed": { + "label": "Live Feed", + "desc": "Real-time rank updates" + } }, "profile": { - "statsHub": { "label": "Stats Hub", "desc": "Visualize your growth" }, - "history": { "label": "History", "desc": "Track learning streaks" }, - "identity": { "label": "Identity", "desc": "Manage role & profile" }, + "statsHub": { + "label": "Stats Hub", + "desc": "Visualize your growth" + }, + "history": { + "label": "History", + "desc": "Track learning streaks" + }, + "identity": { + "label": "Identity", + "desc": "Manage role & profile" + }, "reminders": { "label": "Reminders", "desc": "Finish incomplete quizzes" @@ -851,15 +869,36 @@ "label": "Tech Trends", "desc": "Stay ahead of the curve" }, - "tutorials": { "label": "Tutorials", "desc": "Step-by-step guides" }, - "deepDives": { "label": "Deep Dives", "desc": "In-depth analysis" }, - "community": { "label": "Community", "desc": "Written by developers" } + "tutorials": { + "label": "Tutorials", + "desc": "Step-by-step guides" + }, + "deepDives": { + "label": "Deep Dives", + "desc": "In-depth analysis" + }, + "community": { + "label": "Community", + "desc": "Written by developers" + } }, "shop": { - "newDrops": { "label": "New Drops", "desc": "Regular fresh content" }, - "curated": { "label": "Curated", "desc": "Dev-focused collections" }, - "checkout": { "label": "Checkout", "desc": "Seamless Stripe flow" }, - "premium": { "label": "Premium", "desc": "High-quality material" } + "newDrops": { + "label": "New Drops", + "desc": "Regular fresh content" + }, + "curated": { + "label": "Curated", + "desc": "Dev-focused collections" + }, + "checkout": { + "label": "Checkout", + "desc": "Seamless Stripe flow" + }, + "premium": { + "label": "Premium", + "desc": "High-quality material" + } } } }, @@ -1017,15 +1056,22 @@ "fields": { "email": "Email", "password": "Password", + "confirmPassword": "Confirm password", + "setNewPassword": "New password", "name": "Name", "showPassword": "Show password", "hidePassword": "Hide password", "show": "Show", "hide": "Hide", "validation": { - "required": "Please fill out this field", + "emailRequired": "Email is required", + "invalidName": "Name must be at least {NAME_MIN_LEN} characters and at most {NAME_MAX_LEN} characters", + "emailTooShort": "Email must be at least {EMAIL_MIN_LEN} characters", + "emailTooLong": "Email must not exceed {EMAIL_MAX_LEN} characters", "invalidEmail": "Please enter a valid email address", - "passwordTooShort": "Password must be at least {minLength} characters" + "passwordRequirements": "{PASSWORD_MIN_LEN}-{PASSWORD_MAX_BYTES} characters, at least one capital letter, and at least one special character", + "invalidPassword": "Password must meet requirements: {passwordRequirementsText}", + "passwordsDontMatch": "Passwords don't match" } }, "divider": "or" @@ -1296,4 +1342,4 @@ } } } -} +} \ No newline at end of file diff --git a/frontend/messages/pl.json b/frontend/messages/pl.json index d2245ee4..96fc1a5d 100644 --- a/frontend/messages/pl.json +++ b/frontend/messages/pl.json @@ -793,7 +793,10 @@ }, "bubbles": { "qa": { - "languages": { "label": "3 języki", "desc": "EN, UK i PL" }, + "languages": { + "label": "3 języki", + "desc": "EN, UK i PL" + }, "aiHelper": { "label": "Pomocnik AI", "desc": "Zaznacz tekst do wyjaśnienia" @@ -848,7 +851,10 @@ "label": "Centrum statystyk", "desc": "Wizualizuj swój rozwój" }, - "history": { "label": "Historia", "desc": "Śledź serie nauki" }, + "history": { + "label": "Historia", + "desc": "Śledź serie nauki" + }, "identity": { "label": "Tożsamość", "desc": "Zarządzaj rolą i profilem" @@ -877,7 +883,10 @@ } }, "shop": { - "newDrops": { "label": "Nowości", "desc": "Regularne aktualizacje" }, + "newDrops": { + "label": "Nowości", + "desc": "Regularne aktualizacje" + }, "curated": { "label": "Selekcja", "desc": "Kolekcje dla developerów" @@ -886,7 +895,10 @@ "label": "Płatność", "desc": "Płynny proces przez Stripe" }, - "premium": { "label": "Premium", "desc": "Wysoka jakość materiałów" } + "premium": { + "label": "Premium", + "desc": "Wysoka jakość materiałów" + } } } }, @@ -1044,15 +1056,22 @@ "fields": { "email": "Email", "password": "Hasło", + "confirmPassword": "Potwierdź hasło", + "setNewPassword": "Nowe hasło", "name": "Imię", "showPassword": "Pokaż hasło", "hidePassword": "Ukryj hasło", "show": "Pokaż", "hide": "Ukryj", "validation": { - "required": "Proszę wypełnić to pole", + "emailRequired": "Proszę wypełnić to pole", + "invalidName": "Imię i nazwisko musi mieć co najmniej {NAME_MIN_LEN} znaków i nie więcej niż {NAME_MAX_LEN} znaków", + "emailTooShort": "Adres e-mail musi mieć co najmniej {EMAIL_MIN_LEN} znaków", + "emailTooLong": "Adres e-mail nie może mieć więcej niż {EMAIL_MAX_LEN} znaków", "invalidEmail": "Proszę podać prawidłowy adres email", - "passwordTooShort": "Hasło musi mieć minimum {minLength} znaków" + "passwordRequirements": "{PASSWORD_MIN_LEN}–{PASSWORD_MAX_BYTES} znaków, co najmniej jedną wielką literę i co najmniej jeden znak specjalny", + "invalidPassword": "Hasło musi spełniać wymagania: {passwordRequirementsText}", + "passwordsDontMatch": "Hasła nie zgadzają się" } }, "divider": "lub" @@ -1323,4 +1342,4 @@ } } } -} +} \ No newline at end of file diff --git a/frontend/messages/uk.json b/frontend/messages/uk.json index be88161a..dba35074 100644 --- a/frontend/messages/uk.json +++ b/frontend/messages/uk.json @@ -793,7 +793,10 @@ }, "bubbles": { "qa": { - "languages": { "label": "3 мови", "desc": "EN, UK та PL" }, + "languages": { + "label": "3 мови", + "desc": "EN, UK та PL" + }, "aiHelper": { "label": "AI-помічник", "desc": "Виділи текст для пояснення" @@ -812,7 +815,10 @@ "label": "Розумний таймер", "desc": "Змагайся з часом" }, - "antiCheat": { "label": "Античіт", "desc": "Детекція втрати фокусу" }, + "antiCheat": { + "label": "Античіт", + "desc": "Детекція втрати фокусу" + }, "autoSync": { "label": "Синхронізація", "desc": "Зберігає прогрес після входу" @@ -823,7 +829,10 @@ } }, "leaderboard": { - "podium": { "label": "Подіум", "desc": "Топ-3 в центрі уваги" }, + "podium": { + "label": "Подіум", + "desc": "Топ-3 в центрі уваги" + }, "globalRank": { "label": "Глобальний рейтинг", "desc": "Змагайся зі світом" @@ -842,27 +851,54 @@ "label": "Центр статистики", "desc": "Візуалізуй своє зростання" }, - "history": { "label": "Історія", "desc": "Відстежуй серії навчання" }, - "identity": { "label": "Профіль", "desc": "Керуй роллю та даними" }, + "history": { + "label": "Історія", + "desc": "Відстежуй серії навчання" + }, + "identity": { + "label": "Профіль", + "desc": "Керуй роллю та даними" + }, "reminders": { "label": "Нагадування", "desc": "Заверши незакінчені квізи" } }, "blog": { - "techTrends": { "label": "Tech-тренди", "desc": "Будь попереду" }, - "tutorials": { "label": "Туторіали", "desc": "Покрокові гайди" }, + "techTrends": { + "label": "Tech-тренди", + "desc": "Будь попереду" + }, + "tutorials": { + "label": "Туторіали", + "desc": "Покрокові гайди" + }, "deepDives": { "label": "Глибокий аналіз", "desc": "Детальний розбір" }, - "community": { "label": "Спільнота", "desc": "Написано розробниками" } + "community": { + "label": "Спільнота", + "desc": "Написано розробниками" + } }, "shop": { - "newDrops": { "label": "Новинки", "desc": "Регулярні оновлення" }, - "curated": { "label": "Добірка", "desc": "Колекції для девелоперів" }, - "checkout": { "label": "Оплата", "desc": "Зручний Stripe checkout" }, - "premium": { "label": "Преміум", "desc": "Висока якість матеріалів" } + "newDrops": { + "label": "Новинки", + "desc": "Регулярні оновлення" + }, + "curated": { + "label": "Добірка", + "desc": "Колекції для девелоперів" + }, + "checkout": { + "label": "Оплата", + "desc": "Зручний Stripe checkout" + }, + "premium": { + "label": "Преміум", + "desc": "Висока якість матеріалів" + } } } }, @@ -1020,15 +1056,22 @@ "fields": { "email": "Email", "password": "Пароль", + "confirmPassword": "Повторіть пароль", + "setNewPassword": "Новий пароль", "name": "Ім'я", "showPassword": "Показати пароль", "hidePassword": "Сховати пароль", "show": "Показати", "hide": "Сховати", "validation": { - "required": "Будь ласка, заповніть це поле", - "invalidEmail": "Будь ласка, введіть коректну email адресу", - "passwordTooShort": "Пароль має містити мінімум {minLength} символів" + "emailRequired": "Будь ласка, введіть email", + "invalidName": "Ім'я має містити не менше {NAME_MIN_LEN} символів та не більше {NAME_MAX_LEN} символів", + "emailTooShort": "Електронна адреса має містити щонайменше {EMAIL_MIN_LEN} символів", + "emailTooLong": "Електронна адреса не повинна перевищувати {EMAIL_MAX_LEN} символів", + "invalidEmail": "Будь ласка, введіть дійсну адресу електронної пошти", + "passwordRequirements": "{PASSWORD_MIN_LEN}-{PASSWORD_MAX_BYTES} символи, принаймні одна велика літера та принаймні один спеціальний символ", + "invalidPassword": "Пароль має відповідати вимогам: {passwordRequirementsText}", + "passwordsDontMatch": "Паролі не співпадають" } }, "divider": "або" @@ -1299,4 +1342,4 @@ } } } -} +} \ No newline at end of file From e3d3bfd50b2fd0ce2987f27ecdbfccbba7f20696 Mon Sep 17 00:00:00 2001 From: kryvosheyin Date: Fri, 20 Feb 2026 17:15:47 +0000 Subject: [PATCH 4/5] Remove irrelevant validation on confirm password --- frontend/app/api/auth/signup/route.ts | 3 +- frontend/components/auth/SignupForm.tsx | 46 +++++++++---------------- 2 files changed, 19 insertions(+), 30 deletions(-) diff --git a/frontend/app/api/auth/signup/route.ts b/frontend/app/api/auth/signup/route.ts index 910b6986..1d0c43b1 100644 --- a/frontend/app/api/auth/signup/route.ts +++ b/frontend/app/api/auth/signup/route.ts @@ -49,7 +49,8 @@ const signupSchema = z .max(EMAIL_MAX_LEN, `Email must be at most ${EMAIL_MAX_LEN} characters`) .email('Invalid email'), password: passwordSchema, - confirmPassword: passwordSchema, + + confirmPassword: z.string(), }) .superRefine((val, ctx) => { if (val.password !== val.confirmPassword) { diff --git a/frontend/components/auth/SignupForm.tsx b/frontend/components/auth/SignupForm.tsx index 339406e6..752e2d4d 100644 --- a/frontend/components/auth/SignupForm.tsx +++ b/frontend/components/auth/SignupForm.tsx @@ -55,7 +55,9 @@ export function SignupForm({ locale, returnTo }: SignupFormProps) { const emailTrimmed = useMemo(() => emailValueLive.trim(), [emailValueLive]); const nameLooksValid = useMemo(() => { - return nameTrimmed.length >= NAME_MIN_LEN && nameTrimmed.length <= NAME_MAX_LEN; + return ( + nameTrimmed.length >= NAME_MIN_LEN && nameTrimmed.length <= NAME_MAX_LEN + ); }, [nameTrimmed]); const emailFormatOk = useMemo(() => { @@ -78,14 +80,6 @@ export function SignupForm({ locale, returnTo }: SignupFormProps) { return true; }, [passwordValue]); - const confirmPasswordPolicyOk = useMemo(() => { - if (!confirmPasswordValue) return false; - if (confirmPasswordValue.length < PASSWORD_MIN_LEN) return false; - if (!PASSWORD_POLICY_REGEX.test(confirmPasswordValue)) return false; - if (utf8ByteLength(confirmPasswordValue) > PASSWORD_MAX_BYTES) return false; - return true; - }, [confirmPasswordValue]); - const passwordsMatch = useMemo(() => { if (!passwordValue || !confirmPasswordValue) return false; return passwordValue === confirmPasswordValue; @@ -121,10 +115,8 @@ export function SignupForm({ locale, returnTo }: SignupFormProps) { ? tf('validation.invalidPassword', { passwordRequirementsText }) : null; - const confirmPolicyErrorText = - confirmPasswordTouched && !confirmPasswordPolicyOk - ? tf('validation.invalidPassword', { passwordRequirementsText }) - : null; + const passwordBytesTooLong = + passwordTouched && utf8ByteLength(passwordValue) > PASSWORD_MAX_BYTES; const mismatchErrorText = confirmPasswordTouched && @@ -135,14 +127,11 @@ export function SignupForm({ locale, returnTo }: SignupFormProps) { ? tf('validation.passwordsDontMatch') : null; - const confirmPasswordErrorText = mismatchErrorText ?? confirmPolicyErrorText ?? null; - const submitDisabled = loading || !nameLooksValid || !emailLooksValid || !passwordPolicyOk || - !confirmPasswordPolicyOk || !passwordsMatch; async function onSubmit(e: React.FormEvent) { @@ -172,7 +161,9 @@ export function SignupForm({ locale, returnTo }: SignupFormProps) { if (!res.ok) { const msg = - typeof data?.error === 'string' ? data.error : t('errors.signupFailed'); + typeof data?.error === 'string' + ? data.error + : t('errors.signupFailed'); setError(msg); return; } @@ -245,7 +236,9 @@ export function SignupForm({ locale, returnTo }: SignupFormProps) { onChange={setNameValue} onBlur={() => setNameTouched(true)} /> - {nameErrorText &&

{nameErrorText}

} + {nameErrorText && ( +

{nameErrorText}

+ )}
@@ -258,7 +251,9 @@ export function SignupForm({ locale, returnTo }: SignupFormProps) { }} onBlur={() => setEmailTouched(true)} /> - {emailErrorText &&

{emailErrorText}

} + {emailErrorText && ( +

{emailErrorText}

+ )}
@@ -275,7 +270,7 @@ export function SignupForm({ locale, returnTo }: SignupFormProps) { {passwordErrorText && (

{passwordErrorText}

)} - {passwordTouched && utf8ByteLength(passwordValue) > PASSWORD_MAX_BYTES && ( + {passwordBytesTooLong && (

{tf('validation.passwordTooLongBytes', { PASSWORD_MAX_BYTES })}

@@ -289,19 +284,12 @@ export function SignupForm({ locale, returnTo }: SignupFormProps) { placeholder={tf('confirmPassword')} autoComplete="new-password" minLength={PASSWORD_MIN_LEN} - pattern={PASSWORD_POLICY_REGEX.source} onChange={setConfirmPasswordValue} onBlur={() => setConfirmPasswordTouched(true)} /> - {confirmPasswordErrorText && ( -

{confirmPasswordErrorText}

+ {mismatchErrorText && ( +

{mismatchErrorText}

)} - {confirmPasswordTouched && - utf8ByteLength(confirmPasswordValue) > PASSWORD_MAX_BYTES && ( -

- {tf('validation.passwordTooLongBytes', { PASSWORD_MAX_BYTES })} -

- )}
{error && } From 63e595570073d64b1ea3509de28164074881331c Mon Sep 17 00:00:00 2001 From: kryvosheyin Date: Fri, 20 Feb 2026 17:23:01 +0000 Subject: [PATCH 5/5] Align password security check on reset-password to match the registration --- .../api/auth/password-reset/confirm/route.ts | 23 ++++++---- .../components/auth/ResetPasswordForm.tsx | 42 ++++++++++++++----- 2 files changed, 48 insertions(+), 17 deletions(-) diff --git a/frontend/app/api/auth/password-reset/confirm/route.ts b/frontend/app/api/auth/password-reset/confirm/route.ts index 0638da2a..6b3197da 100644 --- a/frontend/app/api/auth/password-reset/confirm/route.ts +++ b/frontend/app/api/auth/password-reset/confirm/route.ts @@ -6,20 +6,30 @@ import { z } from 'zod'; import { db } from '@/db'; import { passwordResetTokens } from '@/db/schema/passwordResetTokens'; import { users } from '@/db/schema/users'; -import { PASSWORD_MAX_BYTES, PASSWORD_MIN_LEN, PASSWORD_POLICY_REGEX } from '@/lib/auth/signup-constraints'; +import { + PASSWORD_MAX_BYTES, + PASSWORD_MIN_LEN, + PASSWORD_POLICY_REGEX, +} from '@/lib/auth/signup-constraints'; const schema = z.object({ token: z.string().uuid(), password: z .string() - .min(PASSWORD_MIN_LEN, `Password must be at least ${PASSWORD_MIN_LEN} characters`) + .min( + PASSWORD_MIN_LEN, + `Password must be at least ${PASSWORD_MIN_LEN} characters` + ) .regex(/[A-Z]/, 'Password must contain at least one capital letter') - .regex(/[^A-Za-z0-9]/, 'Password must contain at least one special character') + .regex( + /[^A-Za-z0-9]/, + 'Password must contain at least one special character' + ) .regex(PASSWORD_POLICY_REGEX, 'Password does not meet the required policy') .refine( - (val) => Buffer.byteLength(val, 'utf8') <= PASSWORD_MAX_BYTES, + val => Buffer.byteLength(val, 'utf8') <= PASSWORD_MAX_BYTES, `Password must be at most ${PASSWORD_MAX_BYTES} bytes` - ) + ), }); function firstFieldErrorMessage( @@ -40,8 +50,7 @@ export async function POST(req: Request) { if (!parsed.success) { const flattened = parsed.error.flatten().fieldErrors; - const firstMsg = - firstFieldErrorMessage(flattened) ?? 'Invalid request'; + const firstMsg = firstFieldErrorMessage(flattened) ?? 'Invalid request'; return NextResponse.json({ error: firstMsg }, { status: 400 }); } diff --git a/frontend/components/auth/ResetPasswordForm.tsx b/frontend/components/auth/ResetPasswordForm.tsx index 43b48774..c63c00b3 100644 --- a/frontend/components/auth/ResetPasswordForm.tsx +++ b/frontend/components/auth/ResetPasswordForm.tsx @@ -18,9 +18,14 @@ type ResetPasswordFormProps = { token: string; }; +function utf8ByteLength(value: string): number { + return new TextEncoder().encode(value).length; +} + export function ResetPasswordForm({ token }: ResetPasswordFormProps) { const t = useTranslations('auth.resetPassword'); const tf = useTranslations('auth.fields'); + const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(false); @@ -31,23 +36,29 @@ export function ResetPasswordForm({ token }: ResetPasswordFormProps) { const passwordPolicyOk = useMemo(() => { if (!passwordValue) return false; if (passwordValue.length < PASSWORD_MIN_LEN) return false; - if (passwordValue.length > PASSWORD_MAX_BYTES) return false; - return PASSWORD_POLICY_REGEX.test(passwordValue); + if (!PASSWORD_POLICY_REGEX.test(passwordValue)) return false; + if (utf8ByteLength(passwordValue) > PASSWORD_MAX_BYTES) return false; + return true; }, [passwordValue]); - const passwordRequirementsText = - tf('validation.passwordRequirements', { PASSWORD_MIN_LEN, PASSWORD_MAX_BYTES }); + const passwordRequirementsText = tf('validation.passwordRequirements', { + PASSWORD_MIN_LEN, + PASSWORD_MAX_BYTES, + }); const passwordErrorText = passwordTouched && !passwordPolicyOk ? tf('validation.invalidPassword', { passwordRequirementsText }) : null; + const passwordBytesTooLong = + passwordTouched && + utf8ByteLength(passwordValue) > PASSWORD_MAX_BYTES; + const submitDisabled = loading || !passwordPolicyOk; async function onSubmit(e: React.FormEvent) { e.preventDefault(); - if (submitDisabled) return; setLoading(true); @@ -58,9 +69,7 @@ export function ResetPasswordForm({ token }: ResetPasswordFormProps) { try { const res = await fetch('/api/auth/password-reset/confirm', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, password: formData.get('password'), @@ -71,7 +80,9 @@ export function ResetPasswordForm({ token }: ResetPasswordFormProps) { if (!res.ok) { const msg = - typeof data?.error === 'string' ? data.error : t('errors.resetFailed'); + typeof data?.error === 'string' + ? data.error + : t('errors.resetFailed'); setError(msg); return; } @@ -101,8 +112,19 @@ export function ResetPasswordForm({ token }: ResetPasswordFormProps) { onChange={setPasswordValue} onBlur={() => setPasswordTouched(true)} /> + {passwordErrorText && ( -

{passwordErrorText}

+

+ {passwordErrorText} +

+ )} + + {passwordBytesTooLong && ( +

+ {tf('validation.passwordTooLongBytes', { + PASSWORD_MAX_BYTES, + })} +

)}