@@ -7,6 +7,7 @@ import { type UpdatePassword, UsersService } from "@/client"
77import {
88 Form ,
99 FormControl ,
10+ FormDescription ,
1011 FormField ,
1112 FormItem ,
1213 FormLabel ,
@@ -17,32 +18,50 @@ import { PasswordInput } from "@/components/ui/password-input"
1718import useCustomToast from "@/hooks/useCustomToast"
1819import { handleError } from "@/utils"
1920
20- const formSchema = z
21+ const PASSWORD_MIN_LENGTH = 8
22+
23+ const passwordSchema = z
24+ . string ( )
25+ . min ( 1 , { message : "Password is required" } )
26+ . min ( PASSWORD_MIN_LENGTH , {
27+ message : `Password must be at least ${ PASSWORD_MIN_LENGTH } characters` ,
28+ } )
29+
30+ const changePasswordSchema = z
2131 . object ( {
22- current_password : z
23- . string ( )
24- . min ( 1 , { message : "Password is required" } )
25- . min ( 8 , { message : "Password must be at least 8 characters" } ) ,
26- new_password : z
27- . string ( )
28- . min ( 1 , { message : "Password is required" } )
29- . min ( 8 , { message : "Password must be at least 8 characters" } ) ,
32+ current_password : passwordSchema ,
33+ new_password : passwordSchema ,
3034 confirm_password : z
3135 . string ( )
32- . min ( 1 , { message : "Password confirmation is required " } ) ,
36+ . min ( 1 , { message : "Please confirm your new password " } ) ,
3337 } )
3438 . refine ( ( data ) => data . new_password === data . confirm_password , {
35- message : "The passwords don't match" ,
39+ message : "Passwords do not match" ,
3640 path : [ "confirm_password" ] ,
3741 } )
42+ . refine ( ( data ) => data . new_password !== data . current_password , {
43+ message : "New password cannot be the same as the current one" ,
44+ path : [ "new_password" ] ,
45+ } )
46+
47+ type ChangePasswordFormData = z . infer < typeof changePasswordSchema >
3848
39- type FormData = z . infer < typeof formSchema >
49+ export interface ChangePasswordProps {
50+ /** Called after password is updated successfully (e.g. to close a dialog) */
51+ onSuccess ?: ( ) => void
52+ /** When true, omits the heading and adjusts layout for use inside a dialog */
53+ embedded ?: boolean
54+ }
4055
41- const ChangePassword = ( ) => {
56+ export default function ChangePassword ( {
57+ onSuccess,
58+ embedded,
59+ } : ChangePasswordProps ) {
4260 const { showSuccessToast, showErrorToast } = useCustomToast ( )
43- const form = useForm < FormData > ( {
44- resolver : zodResolver ( formSchema ) ,
45- mode : "onSubmit" ,
61+
62+ const form = useForm < ChangePasswordFormData > ( {
63+ resolver : zodResolver ( changePasswordSchema ) ,
64+ mode : "onBlur" ,
4665 criteriaMode : "all" ,
4766 defaultValues : {
4867 current_password : "" ,
@@ -57,33 +76,42 @@ const ChangePassword = () => {
5776 onSuccess : ( ) => {
5877 showSuccessToast ( "Password updated successfully" )
5978 form . reset ( )
79+ onSuccess ?.( )
6080 } ,
6181 onError : handleError . bind ( showErrorToast ) ,
6282 } )
6383
64- const onSubmit = async ( data : FormData ) => {
84+ const onSubmit = ( data : ChangePasswordFormData ) => {
85+ if ( mutation . isPending ) return
6586 mutation . mutate ( data )
6687 }
6788
6889 return (
69- < div className = "max-w-md" >
70- < h3 className = "text-lg font-semibold py-4" > Change Password</ h3 >
90+ < div className = { embedded ? "pt-1" : "max-w-md" } >
91+ { ! embedded && (
92+ < h2 className = "text-lg font-semibold tracking-tight pb-4" >
93+ Change password
94+ </ h2 >
95+ ) }
7196 < Form { ...form } >
7297 < form
7398 onSubmit = { form . handleSubmit ( onSubmit ) }
74- className = "flex flex-col gap-4"
99+ noValidate
100+ className = "flex flex-col gap-6"
75101 >
76102 < FormField
77103 control = { form . control }
78104 name = "current_password"
79105 render = { ( { field, fieldState } ) => (
80106 < FormItem >
81- < FormLabel > Current Password </ FormLabel >
107+ < FormLabel > Current password </ FormLabel >
82108 < FormControl >
83109 < PasswordInput
84110 data-testid = "current-password-input"
85111 placeholder = "••••••••"
112+ autoComplete = "current-password"
86113 aria-invalid = { fieldState . invalid }
114+ disabled = { mutation . isPending }
87115 { ...field }
88116 />
89117 </ FormControl >
@@ -97,15 +125,20 @@ const ChangePassword = () => {
97125 name = "new_password"
98126 render = { ( { field, fieldState } ) => (
99127 < FormItem >
100- < FormLabel > New Password </ FormLabel >
128+ < FormLabel > New password </ FormLabel >
101129 < FormControl >
102130 < PasswordInput
103131 data-testid = "new-password-input"
104132 placeholder = "••••••••"
133+ autoComplete = "new-password"
105134 aria-invalid = { fieldState . invalid }
135+ disabled = { mutation . isPending }
106136 { ...field }
107137 />
108138 </ FormControl >
139+ < FormDescription >
140+ At least { PASSWORD_MIN_LENGTH } characters
141+ </ FormDescription >
109142 < FormMessage />
110143 </ FormItem >
111144 ) }
@@ -116,12 +149,14 @@ const ChangePassword = () => {
116149 name = "confirm_password"
117150 render = { ( { field, fieldState } ) => (
118151 < FormItem >
119- < FormLabel > Confirm Password </ FormLabel >
152+ < FormLabel > Confirm new password </ FormLabel >
120153 < FormControl >
121154 < PasswordInput
122155 data-testid = "confirm-password-input"
123156 placeholder = "••••••••"
157+ autoComplete = "new-password"
124158 aria-invalid = { fieldState . invalid }
159+ disabled = { mutation . isPending }
125160 { ...field }
126161 />
127162 </ FormControl >
@@ -133,14 +168,13 @@ const ChangePassword = () => {
133168 < LoadingButton
134169 type = "submit"
135170 loading = { mutation . isPending }
136- className = "self-start"
171+ disabled = { ! form . formState . isDirty || mutation . isPending }
172+ className = { embedded ? "w-full sm:w-auto" : "w-full sm:w-auto" }
137173 >
138- Update Password
174+ Update password
139175 </ LoadingButton >
140176 </ form >
141177 </ Form >
142178 </ div >
143179 )
144180}
145-
146- export default ChangePassword
0 commit comments