11'use client' ;
22
33import { useTranslations } from 'next-intl' ;
4- import { useState } from 'react' ;
4+ import { useMemo , useState } from 'react' ;
55
66import { AuthErrorBanner } from '@/components/auth/AuthErrorBanner' ;
77import { AuthShell } from '@/components/auth/AuthShell' ;
88import { AuthSuccessBanner } from '@/components/auth/AuthSuccessBanner' ;
99import { PasswordField } from '@/components/auth/fields/PasswordField' ;
1010import { 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
1217type ResetPasswordFormProps = {
1318 token : string ;
1419} ;
1520
21+ function utf8ByteLength ( value : string ) : number {
22+ return new TextEncoder ( ) . encode ( value ) . length ;
23+ }
24+
1625export 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