diff --git a/frontend/app/api/auth/password-reset/confirm/route.ts b/frontend/app/api/auth/password-reset/confirm/route.ts index 11b5a872..6b3197da 100644 --- a/frontend/app/api/auth/password-reset/confirm/route.ts +++ b/frontend/app/api/auth/password-reset/confirm/route.ts @@ -6,18 +6,52 @@ 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'; 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` + ) + .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` + ), }); +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: 'Invalid request' }, { 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; @@ -60,4 +94,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..1d0c43b1 100644 --- a/frontend/app/api/auth/signup/route.ts +++ b/frontend/app/api/auth/signup/route.ts @@ -7,16 +7,60 @@ 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_BYTES, + 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 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 + .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: passwordSchema, + + 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 { @@ -74,10 +118,7 @@ export async function POST(req: Request) { }); return NextResponse.json( - { - success: true, - verificationRequired: true, - }, + { success: true, verificationRequired: true }, { status: 201 } ); } catch (error) { @@ -88,4 +129,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..c63c00b3 100644 --- a/frontend/components/auth/ResetPasswordForm.tsx +++ b/frontend/components/auth/ResetPasswordForm.tsx @@ -1,26 +1,66 @@ '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_BYTES, + PASSWORD_MIN_LEN, + PASSWORD_POLICY_REGEX, +} from '@/lib/auth/signup-constraints'; 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); + 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 (!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 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); setError(null); @@ -29,17 +69,21 @@ 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'), }), }); + 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 +101,40 @@ export function ResetPasswordForm({ token }: ResetPasswordFormProps) { ) : (
- +
+ setPasswordTouched(true)} + /> + + {passwordErrorText && ( +

+ {passwordErrorText} +

+ )} + + {passwordBytesTooLong && ( +

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

+ )} +
{error && } - )} ); -} +} \ No newline at end of file diff --git a/frontend/components/auth/SignupForm.tsx b/frontend/components/auth/SignupForm.tsx index aec0ed7d..752e2d4d 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,21 +13,131 @@ 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_BYTES, + PASSWORD_MIN_LEN, + PASSWORD_POLICY_REGEX, +} from '@/lib/auth/signup-constraints'; type SignupFormProps = { locale: string; 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); const [verificationRequired, setVerificationRequired] = useState(false); const [email, setEmail] = useState(''); + const [nameValue, setNameValue] = useState(''); + const [emailValueLive, setEmailValueLive] = useState(''); + const [passwordValue, setPasswordValue] = useState(''); + const [confirmPasswordValue, setConfirmPasswordValue] = useState(''); + + 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 (!PASSWORD_POLICY_REGEX.test(passwordValue)) return false; + if (utf8ByteLength(passwordValue) > PASSWORD_MAX_BYTES) return false; + return true; + }, [passwordValue]); + + const passwordsMatch = useMemo(() => { + if (!passwordValue || !confirmPasswordValue) return false; + return passwordValue === confirmPasswordValue; + }, [passwordValue, confirmPasswordValue]); + + const nameErrorText = + nameTouched && !nameLooksValid + ? tf('validation.invalidName', { NAME_MIN_LEN, NAME_MAX_LEN }) + : null; + + const emailErrorText = useMemo(() => { + if (!emailTouched) return null; + if (!emailTrimmed) return null; + + if (emailTrimmed.length > EMAIL_MAX_LEN) { + return tf('validation.emailTooLong', { EMAIL_MAX_LEN }); + } + + if (!emailFormatOk) { + return tf('validation.invalidEmail'); + } + + return null; + }, [emailTouched, emailTrimmed, emailFormatOk, tf]); + + 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 mismatchErrorText = + confirmPasswordTouched && + passwordTouched && + passwordValue.length > 0 && + confirmPasswordValue.length > 0 && + !passwordsMatch + ? tf('validation.passwordsDontMatch') + : null; + + const submitDisabled = + loading || + !nameLooksValid || + !emailLooksValid || + !passwordPolicyOk || + !passwordsMatch; + async function onSubmit(e: React.FormEvent) { e.preventDefault(); + if (submitDisabled) return; + setLoading(true); setError(null); @@ -42,13 +153,18 @@ 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 +211,6 @@ export function SignupForm({ locale, returnTo }: SignupFormProps) {

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

-

{t('checkInbox')}

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

{nameErrorText}

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

{emailErrorText}

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

{passwordErrorText}

+ )} + {passwordBytesTooLong && ( +

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

+ )} +
+ +
+ setConfirmPasswordTouched(true)} + /> + {mismatchErrorText && ( +

{mismatchErrorText}

+ )} +
{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..7ea361c3 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,29 @@ export function EmailField({ onChange }: EmailFieldProps) { e.currentTarget.setCustomValidity(''); }; + const handleBlur: React.FocusEventHandler = (e) => { + const input = e.currentTarget; + const trimmed = input.value.trim(); + + input.value = trimmed; + + onChange?.(trimmed); + onBlur?.(e); + }; + return ( onChange(e.currentTarget.value) : undefined} + onChange={e => onChange?.(e.currentTarget.value)} + 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..a84b8056 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..75e3e5ec 100644 --- a/frontend/components/auth/fields/PasswordField.tsx +++ b/frontend/components/auth/fields/PasswordField.tsx @@ -5,22 +5,49 @@ import { useState } from 'react'; type PasswordFieldProps = { name?: string; + id?: string; minLength?: number; + pattern?: string; + onChange?: (value: string) => void; + + placeholder?: string; + autoComplete?: string; + ariaLabel?: string; + onBlur?: React.FocusEventHandler; }; export function PasswordField({ name = 'password', + id, minLength, + pattern, + onChange, + 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.patternMismatch) { + input.setCustomValidity( + 'Password must include at least one capital letter and one special character.' + ); + return; } }; @@ -28,17 +55,25 @@ export function PasswordField({ e.currentTarget.setCustomValidity(''); }; + const resolvedPlaceholder = placeholder ?? t('password'); + return (
onChange(e.currentTarget.value) : undefined} />
); -} +} \ No newline at end of file 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 new file mode 100644 index 00000000..f97200d7 --- /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_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