| 1 | "use client"; |
| 2 | |
| 3 | import { |
| 4 | getAuthLinkURL, |
| 5 | isPasswordCompromisedError, |
| 6 | validateMatchingValue, |
| 7 | validateStringLength, |
| 8 | } from "@better-auth-ui/core"; |
| 9 | import { useAuth, useResetPassword |
| 10 | import { Eye, EyeOff |
| 11 | import { useEffect, useState |
| 12 | import { toast } |
| 13 | |
| 14 | import { Card, CardContent |
| 15 | import { |
| 16 | Field, |
| 17 | FieldDescription, |
| 18 | FieldError, |
| 19 | FieldGroup, |
| 20 | FieldLabel, |
| 21 | } from "@/components/ui/field"; |
| 22 | import { |
| 23 | InputGroup, |
| 24 | InputGroupAddon, |
| 25 | InputGroupButton, |
| 26 | InputGroupInput, |
| 27 | } from "@/components/ui/input-group"; |
| 28 | import { cn } |
| 29 | import { isAuthFormFieldInvalid, useAuthForm |
| 30 | import { PasswordStrengthMeter } |
| 31 | |
| 32 | export type ResetPasswordProps |
| 33 | className?: string |
| 34 | }; |
| 35 | |
| 36 | /** Render a password reset form that validates the reset token from the URL, accepts a new password (and optional confirmation), and submits it to the auth client. */ |
| 37 | export function ResetPassword({ |
| 38 | const { |
| 39 | authClient, |
| 40 | basePaths, |
| 41 | emailAndPassword, |
| 42 | localization, |
| 43 | navigate, |
| 44 | redirectTo, |
| 45 | viewPaths, |
| 46 | Link, |
| 47 | } = useAuth(); |
| 48 | const signInURL = |
| 49 | `${basePaths.auth} |
| 50 | redirectTo, |
| 51 | ); |
| 52 | |
| 53 | const { mutateAsync: |
| 54 | authClient, |
| 55 | { |
| 56 | onError: (error) |
| 57 | // The haveIBeenPwned plugin rejects on the password itself, so it belongs against the field rather than in a toast. |
| 58 | if (isPasswordCompromisedError(error |
| 59 | setIsCompromised(true); |
| 60 | } |
| 61 | }, |
| 62 | onSuccess: () => |
| 63 | toast.success(localization |
| 64 | navigate({ to: |
| 65 | }, |
| 66 | }, |
| 67 | ); |
| 68 | |
| 69 | const [isPasswordVisible, |
| 70 | const [isConfirmPasswordVisible, |
| 71 | useState(false); |
| 72 | const [isCompromised, |
| 73 | |
| 74 | useEffect(() => |
| 75 | const searchParams = |
| 76 | const token = |
| 77 | |
| 78 | if (!token) |
| 79 | toast.error(localization |
| 80 | navigate({ to: |
| 81 | } |
| 82 | }, [localization.auth |
| 83 | |
| 84 | const form = |
| 85 | defaultValues: { confirmPassword: "" |
| 86 | onSubmit: async ( |
| 87 | const searchParams = |
| 88 | const token = |
| 89 | |
| 90 | if (!token) |
| 91 | toast.error(localization |
| 92 | navigate({ to: |
| 93 | return; |
| 94 | } |
| 95 | |
| 96 | try { |
| 97 | await resetPassword({ |
| 98 | } catch { |
| 99 | // The mutation reports the error through its configured handler. |
| 100 | } |
| 101 | }, |
| 102 | }); |
| 103 | |
| 104 | return ( |
| 105 | <Card className={ |
| 106 | <CardHeader> |
| 107 | <CardTitle className=" |
| 108 | {localization.auth.resetPassword |
| 109 | </CardTitle> |
| 110 | </CardHeader> |
| 111 | |
| 112 | <CardContent> |
| 113 | <form.AppForm> |
| 114 | <form.AuthFormRoot> |
| 115 | <FieldGroup> |
| 116 | <form.AppField |
| 117 | name="password" |
| 118 | validators={{ |
| 119 | onChange: ({ value |
| 120 | validateStringLength(value, { |
| 121 | maxLength: emailAndPassword?.maxPasswordLength |
| 122 | maxLengthMessage: localization.auth |
| 123 | "{{max}}", |
| 124 | String(emailAndPassword?.maxPasswordLength |
| 125 | ), |
| 126 | minLength: emailAndPassword?.minPasswordLength |
| 127 | minLengthMessage: localization.auth |
| 128 | "{{min}}", |
| 129 | String(emailAndPassword?.minPasswordLength |
| 130 | ), |
| 131 | requiredMessage: localization.auth |
| 132 | }), |
| 133 | }} |
| 134 | > |
| 135 | {(field) => |
| 136 | const isInvalid = |
| 137 | isAuthFormFieldInvalid(field.state |
| 138 | |
| 139 | return ( |
| 140 | <Field data-invalid={ |
| 141 | <FieldLabel htmlFor=" |
| 142 | {localization.auth.password |
| 143 | </FieldLabel> |
| 144 | |
| 145 | <InputGroup> |
| 146 | <InputGroupInput |
| 147 | id="password" |
| 148 | type={isPasswordVisible ? |
| 149 | autoComplete="new-password" |
| 150 | placeholder={localization.auth |
| 151 | required |
| 152 | minLength={emailAndPassword?.minPasswordLength |
| 153 | maxLength={emailAndPassword?.maxPasswordLength |
| 154 | disabled={isPending} |
| 155 | name={field.name |
| 156 | onBlur={field.handleBlur |
| 157 | onChange={(e) |
| 158 | field.handleChange(e |
| 159 | setIsCompromised(false); |
| 160 | }} |
| 161 | aria-invalid={isInvalid} |
| 162 | value={field.state |
| 163 | /> |
| 164 | |
| 165 | <InputGroupAddon align=" |
| 166 | <InputGroupButton |
| 167 | size="icon-xs" |
| 168 | aria-label={ |
| 169 | isPasswordVisible |
| 170 | ? localization.auth. |
| 171 | : localization.auth. |
| 172 | } |
| 173 | title={ |
| 174 | isPasswordVisible |
| 175 | ? localization.auth. |
| 176 | : localization.auth. |
| 177 | } |
| 178 | onClick={() => |
| 179 | setIsPasswordVisible((visible) |
| 180 | }} |
| 181 | > |
| 182 | {isPasswordVisible ? <EyeOff |
| 183 | </InputGroupButton> |
| 184 | </InputGroupAddon> |
| 185 | </InputGroup> |
| 186 | |
| 187 | {isCompromised ? ( |
| 188 | <FieldError> |
| 189 | {localization.auth.passwordCompromised |
| 190 | </FieldError> |
| 191 | ) : ( |
| 192 | <field.AuthFormFieldError /> |
| 193 | )} |
| 194 | |
| 195 | <PasswordStrengthMeter password={ |
| 196 | </Field> |
| 197 | ); |
| 198 | }} |
| 199 | </form.AppField> |
| 200 | |
| 201 | {emailAndPassword?.confirmPassword && |
| 202 | <form.AppField |
| 203 | name="confirmPassword" |
| 204 | validators={{ |
| 205 | onChangeListenTo: ["password"], |
| 206 | onChange: ({ fieldApi |
| 207 | validateStringLength(value, { |
| 208 | maxLength: emailAndPassword?.maxPasswordLength |
| 209 | maxLengthMessage: localization.auth |
| 210 | "{{max}}", |
| 211 | String(emailAndPassword?.maxPasswordLength |
| 212 | ), |
| 213 | minLength: emailAndPassword?.minPasswordLength |
| 214 | minLengthMessage: localization.auth |
| 215 | "{{min}}", |
| 216 | String(emailAndPassword?.minPasswordLength |
| 217 | ), |
| 218 | requiredMessage: localization.auth |
| 219 | }) ?? |
| 220 | validateMatchingValue( |
| 221 | value, |
| 222 | fieldApi.form.getFieldValue |
| 223 | localization.auth.passwordsDoNotMatch |
| 224 | ), |
| 225 | }} |
| 226 | > |
| 227 | {(field) => |
| 228 | const isInvalid = |
| 229 | |
| 230 | return ( |
| 231 | <Field data-invalid={ |
| 232 | <FieldLabel htmlFor=" |
| 233 | {localization.auth.confirmPassword |
| 234 | </FieldLabel> |
| 235 | |
| 236 | <InputGroup> |
| 237 | <InputGroupInput |
| 238 | id="confirmPassword" |
| 239 | name={field.name |
| 240 | type={ |
| 241 | isConfirmPasswordVisible ? "text" |
| 242 | } |
| 243 | autoComplete="new-password" |
| 244 | placeholder={ |
| 245 | localization.auth.confirmPasswordPlaceholder |
| 246 | } |
| 247 | required |
| 248 | minLength={emailAndPassword?.minPasswordLength |
| 249 | maxLength={emailAndPassword?.maxPasswordLength |
| 250 | disabled={isPending} |
| 251 | onBlur={field.handleBlur |
| 252 | onChange={(event) |
| 253 | field.handleChange(event |
| 254 | } |
| 255 | aria-invalid={isInvalid} |
| 256 | value={field.state |
| 257 | /> |
| 258 | |
| 259 | <InputGroupAddon align=" |
| 260 | <InputGroupButton |
| 261 | size="icon-xs" |
| 262 | aria-label={ |
| 263 | isConfirmPasswordVisible |
| 264 | ? localization.auth. |
| 265 | : localization.auth. |
| 266 | } |
| 267 | title={ |
| 268 | isConfirmPasswordVisible |
| 269 | ? localization.auth. |
| 270 | : localization.auth. |
| 271 | } |
| 272 | onClick={() => |
| 273 | setIsConfirmPasswordVisible( |
| 274 | (visible) => |
| 275 | ); |
| 276 | }} |
| 277 | > |
| 278 | {isConfirmPasswordVisible ? <EyeOff |
| 279 | </InputGroupButton> |
| 280 | </InputGroupAddon> |
| 281 | </InputGroup> |
| 282 | |
| 283 | <field.AuthFormFieldError /> |
| 284 | </Field> |
| 285 | ); |
| 286 | }} |
| 287 | </form.AppField> |
| 288 | )} |
| 289 | |
| 290 | <div className="flex flex-col gap-3 |
| 291 | <form.AuthFormSubmitButton disabled={ |
| 292 | {localization.auth.resetPassword |
| 293 | </form.AuthFormSubmitButton> |
| 294 | </div> |
| 295 | </FieldGroup> |
| 296 | </form.AuthFormRoot> |
| 297 | </form.AppForm> |
| 298 | |
| 299 | <div className="flex flex-col gap-3 items-center w-full mt-4 |
| 300 | <FieldDescription className=" |
| 301 | {localization.auth.rememberYourPassword |
| 302 | <Link |
| 303 | href={getAuthLinkURL( |
| 304 | `${basePaths.auth} |
| 305 | redirectTo, |
| 306 | )} |
| 307 | className="underline underline-offset-4" |
| 308 | > |
| 309 | {localization.auth.signIn |
| 310 | </Link> |
| 311 | </FieldDescription> |
| 312 | </div> |
| 313 | </CardContent> |
| 314 | </Card> |
| 315 | ); |
| 316 | } |