ResetPasswordForm.tsx 5.8 KB

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