ResetPasswordForm.tsx166 lines · main
1import { zodResolver } from '@hookform/resolvers/zod'
2import { useParams } from 'common'
3import { Eye, EyeOff } from 'lucide-react'
4import { useRouter } from 'next/router'
5import { useState } from 'react'
6import { useForm } from 'react-hook-form'
7import { toast } from 'sonner'
8import { Button, cn, Form, FormControl, FormField, Separator } from 'ui'
9import { Input } from 'ui-patterns/DataInputs/Input'
10import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
11import { z } from 'zod'
12
13import PasswordConditionsHelper from './PasswordConditionsHelper'
14import { captureCriticalError } from '@/lib/error-reporting'
15import { auth, getReturnToPath } from '@/lib/gotrue'
16
17const passwordValidation = z
18 .string()
19 .min(1, 'Password is required')
20 .max(72, 'Password cannot exceed 72 characters')
21 .refine((password) => {
22 const hasUppercase = /[A-Z]/.test(password)
23 const hasLowercase = /[a-z]/.test(password)
24 const hasNumber = /[0-9]/.test(password)
25 const hasSpecialChar = /[!@#$%^&*()_+\-=\[\]{};`':"\\|,.<>\/?]/.test(password)
26 const isLongEnough = password.length >= 8
27
28 return hasUppercase && hasLowercase && hasNumber && hasSpecialChar && isLongEnough
29 }, 'Password must contain at least 8 characters, including uppercase, lowercase, number, and special character')
30
31const passwordSchema = z.object({
32 currentPassword: z.string().min(1, 'Current password is required'),
33 password: passwordValidation,
34})
35
36const recoveryPasswordSchema = z.object({
37 currentPassword: z.string().optional(),
38 password: passwordValidation,
39})
40
41type FormData = z.infer<typeof passwordSchema>
42
43export const ResetPasswordForm = () => {
44 const router = useRouter()
45 const { type } = useParams()
46 const requireCurrentPassword = type === 'change'
47
48 const [showConditions, setShowConditions] = useState(false)
49 const [passwordHidden, setPasswordHidden] = useState(true)
50 const [currentPasswordHidden, setCurrentPasswordHidden] = useState(true)
51
52 const form = useForm<FormData>({
53 resolver: zodResolver(requireCurrentPassword ? passwordSchema : recoveryPasswordSchema as any),
54 defaultValues: { password: '', currentPassword: '' },
55 mode: 'onChange',
56 })
57
58 const onResetPassword = async (data: FormData) => {
59 const toastId = toast.loading('Saving password...')
60 const { error } = await auth.updateUser({
61 password: data.password,
62 ...(requireCurrentPassword ? { current_password: data.currentPassword } : {}),
63 })
64
65 if (!error) {
66 toast.success('Password saved successfully!', { id: toastId })
67
68 // logout all other sessions after changing password
69 await auth.signOut({ scope: 'others' })
70 await router.push(getReturnToPath('/organizations'))
71 } else {
72 toast.error(`Failed to save password: ${error.message}`, { id: toastId })
73 captureCriticalError(error, 'reset password')
74 }
75 }
76
77 return (
78 <Form {...form}>
79 <form onSubmit={form.handleSubmit(onResetPassword)} className="space-y-4 pt-4">
80 {requireCurrentPassword && (
81 <FormField
82 control={form.control}
83 name="currentPassword"
84 render={({ field }) => (
85 <FormItemLayout label="Current password">
86 <FormControl>
87 <Input
88 id="currentPassword"
89 type={currentPasswordHidden ? 'password' : 'text'}
90 placeholder="&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;"
91 disabled={form.formState.isSubmitting}
92 actions={
93 <Button
94 icon={currentPasswordHidden ? <Eye /> : <EyeOff />}
95 type="default"
96 className="w-7"
97 onClick={() => setCurrentPasswordHidden((prev) => !prev)}
98 />
99 }
100 {...field}
101 onBlur={() => {
102 field.onBlur()
103 setCurrentPasswordHidden(true)
104 }}
105 />
106 </FormControl>
107 </FormItemLayout>
108 )}
109 />
110 )}
111 <FormField
112 control={form.control}
113 name="password"
114 render={({ field }) => (
115 <FormItemLayout label="Password">
116 <FormControl>
117 <Input
118 id="password"
119 type={passwordHidden ? 'password' : 'text'}
120 placeholder="&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;"
121 disabled={form.formState.isSubmitting}
122 onFocus={() => setShowConditions(true)}
123 autoComplete="new-password"
124 actions={
125 <Button
126 icon={passwordHidden ? <Eye /> : <EyeOff />}
127 type="default"
128 className="w-7"
129 onClick={() => setPasswordHidden((prev) => !prev)}
130 />
131 }
132 {...field}
133 onBlur={() => {
134 field.onBlur()
135 setPasswordHidden(true)
136 }}
137 />
138 </FormControl>
139 </FormItemLayout>
140 )}
141 />
142
143 <div
144 className={cn(
145 showConditions ? 'max-h-[500px]' : 'max-h-0',
146 'transition-all duration-400 overflow-y-hidden'
147 )}
148 >
149 <PasswordConditionsHelper password={form.watch('password')} />
150 </div>
151
152 <Separator className="bg-border" />
153
154 <Button
155 block
156 htmlType="submit"
157 size="medium"
158 disabled={form.formState.isSubmitting}
159 loading={form.formState.isSubmitting}
160 >
161 Save new password
162 </Button>
163 </form>
164 </Form>
165 )
166}