| 1 | "use client"; |
| 2 | |
| 3 | import { |
| 4 | getViewURL, |
| 5 | isPasswordCompromisedError, |
| 6 | validateMatchingValue, |
| 7 | validateStringLength, |
| 8 | } from "@better-auth-ui/core"; |
| 9 | import { |
| 10 | useAuth, |
| 11 | useChangePassword, |
| 12 | useFetchOptions, |
| 13 | useListAccounts, |
| 14 | useRequestPasswordReset, |
| 15 | useSession, |
| 16 | } from "@better-auth-ui/react"; |
| 17 | import { Eye, EyeOff |
| 18 | import { useState } |
| 19 | import { toast } |
| 20 | import { Button } |
| 21 | import { Card, CardContent |
| 22 | import { Field, FieldError |
| 23 | import { Input } |
| 24 | import { |
| 25 | InputGroup, |
| 26 | InputGroupAddon, |
| 27 | InputGroupButton, |
| 28 | InputGroupInput, |
| 29 | } from "@/components/ui/input-group"; |
| 30 | import { Skeleton } |
| 31 | import { Spinner } |
| 32 | import { cn } |
| 33 | import { isAuthFormFieldInvalid, useAuthForm |
| 34 | import { OpenEmailButton } |
| 35 | import { PasswordStrengthMeter } |
| 36 | |
| 37 | export type ChangePasswordProps |
| 38 | className?: string |
| 39 | }; |
| 40 | |
| 41 | /** Render a card form for changing the authenticated user's password. */ |
| 42 | export function ChangePassword({ |
| 43 | const { authClient, |
| 44 | const { data: |
| 45 | const { data: |
| 46 | useListAccounts(authClient); |
| 47 | |
| 48 | const hasCredentialAccount = |
| 49 | (account) => |
| 50 | ); |
| 51 | |
| 52 | if (!isAccountsPending && |
| 53 | return <SetPassword className |
| 54 | } |
| 55 | |
| 56 | return ( |
| 57 | <ChangePasswordForm |
| 58 | className={className} |
| 59 | emailAndPassword={emailAndPassword} |
| 60 | localization={localization} |
| 61 | session={isAccountsPending ? |
| 62 | /> |
| 63 | ); |
| 64 | } |
| 65 | |
| 66 | function SetPassword({ className |
| 67 | const { authClient, |
| 68 | useAuth(); |
| 69 | const { data: |
| 70 | const { fetchOptions, |
| 71 | const [sentEmail, |
| 72 | |
| 73 | const { mutate: |
| 74 | authClient, |
| 75 | { |
| 76 | onError: () => |
| 77 | resetFetchOptions(); |
| 78 | }, |
| 79 | onSuccess: (_data, |
| 80 | setSentEmail(email); |
| 81 | }, |
| 82 | }, |
| 83 | ); |
| 84 | |
| 85 | const Captcha = |
| 86 | (plugin) => |
| 87 | )?.captchaComponent; |
| 88 | |
| 89 | const handleSetPassword = |
| 90 | if (!session) |
| 91 | |
| 92 | requestPasswordReset({ |
| 93 | email: session.user |
| 94 | redirectTo: getViewURL( |
| 95 | baseURL, |
| 96 | basePaths.auth, |
| 97 | viewPaths.auth.resetPassword |
| 98 | ), |
| 99 | fetchOptions, |
| 100 | }); |
| 101 | }; |
| 102 | |
| 103 | return ( |
| 104 | <div> |
| 105 | <h2 className="text-sm font-semibold mb-3 |
| 106 | {localization.settings.changePassword |
| 107 | </h2> |
| 108 | |
| 109 | <Card className={ |
| 110 | <CardContent className=" |
| 111 | <div> |
| 112 | <p className="text-sm font-medium leading-tight |
| 113 | {localization.settings.setPassword |
| 114 | </p> |
| 115 | |
| 116 | <p className="text-muted-foreground text-xs mt-0.5 |
| 117 | {localization.settings.setPasswordDescription |
| 118 | </p> |
| 119 | </div> |
| 120 | |
| 121 | {sentEmail ? ( |
| 122 | <div className="flex flex-col gap-3 items-start sm:items-end |
| 123 | <p className="text-sm |
| 124 | {localization.auth.resetLinkSentTo |
| 125 | "{{email}}", |
| 126 | sentEmail, |
| 127 | )} |
| 128 | </p> |
| 129 | |
| 130 | <OpenEmailButton email={ |
| 131 | </div> |
| 132 | ) : ( |
| 133 | <div className="flex flex-col gap-3 items-start sm:items-end |
| 134 | {Captcha && <div |
| 135 | |
| 136 | <Button |
| 137 | size="sm" |
| 138 | disabled={isPending || |
| 139 | onClick={handleSetPassword} |
| 140 | > |
| 141 | {isPending && <Spinner |
| 142 | |
| 143 | {localization.auth.sendResetLink |
| 144 | </Button> |
| 145 | </div> |
| 146 | )} |
| 147 | </CardContent> |
| 148 | </Card> |
| 149 | </div> |
| 150 | ); |
| 151 | } |
| 152 | |
| 153 | function ChangePasswordForm({ |
| 154 | className, |
| 155 | emailAndPassword, |
| 156 | localization, |
| 157 | session, |
| 158 | }: { |
| 159 | className?: string |
| 160 | emailAndPassword: ReturnType |
| 161 | localization: ReturnType |
| 162 | session: ReturnType |
| 163 | }) { |
| 164 | const { authClient |
| 165 | const { mutateAsync: |
| 166 | authClient, |
| 167 | { |
| 168 | onError: (error) |
| 169 | // The haveIBeenPwned plugin rejects on the password itself, so it belongs against the field rather than in a toast. |
| 170 | setIsCompromised(isPasswordCompromisedError(error |
| 171 | }, |
| 172 | onSuccess: () => |
| 173 | form.reset(); |
| 174 | toast.success(localization |
| 175 | }, |
| 176 | }, |
| 177 | ); |
| 178 | |
| 179 | const [isCurrentPasswordVisible, |
| 180 | useState(false); |
| 181 | const [isNewPasswordVisible, |
| 182 | const [isConfirmPasswordVisible, |
| 183 | useState(false); |
| 184 | |
| 185 | const [isCompromised, |
| 186 | |
| 187 | const form = |
| 188 | defaultValues: { |
| 189 | confirmPassword: "", |
| 190 | currentPassword: "", |
| 191 | newPassword: "", |
| 192 | }, |
| 193 | onSubmit: async ( |
| 194 | try { |
| 195 | await changePassword({ |
| 196 | currentPassword: value.currentPassword |
| 197 | newPassword: value.newPassword |
| 198 | revokeOtherSessions: true, |
| 199 | }); |
| 200 | } catch { |
| 201 | // The mutation reports the error through its configured handler. |
| 202 | } |
| 203 | }, |
| 204 | }); |
| 205 | |
| 206 | return ( |
| 207 | <div> |
| 208 | <h2 className="text-sm font-semibold mb-3 |
| 209 | {localization.settings.changePassword |
| 210 | </h2> |
| 211 | |
| 212 | <form.AppForm> |
| 213 | <form.AuthFormRoot> |
| 214 | <Card className={ |
| 215 | <CardContent className=" |
| 216 | <form.AppField |
| 217 | name="currentPassword" |
| 218 | validators={{ |
| 219 | onChange: ({ value |
| 220 | validateStringLength(value, { |
| 221 | requiredMessage: localization.auth |
| 222 | }), |
| 223 | }} |
| 224 | > |
| 225 | {(field) => |
| 226 | <Field |
| 227 | data-invalid={isAuthFormFieldInvalid(field |
| 228 | > |
| 229 | <FieldLabel htmlFor=" |
| 230 | {localization.settings.currentPassword |
| 231 | </FieldLabel> |
| 232 | |
| 233 | {session ? ( |
| 234 | <InputGroup> |
| 235 | <InputGroupInput |
| 236 | id="currentPassword" |
| 237 | name={field.name |
| 238 | type={isCurrentPasswordVisible ? |
| 239 | autoComplete="current-password" |
| 240 | placeholder={ |
| 241 | localization.settings.currentPasswordPlaceholder |
| 242 | } |
| 243 | value={field.state |
| 244 | onBlur={field.handleBlur |
| 245 | onChange={(e) |
| 246 | disabled={isPending} |
| 247 | required |
| 248 | aria-invalid={isAuthFormFieldInvalid( |
| 249 | field.state.meta |
| 250 | )} |
| 251 | /> |
| 252 | |
| 253 | <InputGroupAddon align=" |
| 254 | <InputGroupButton |
| 255 | size="icon-xs" |
| 256 | aria-label={ |
| 257 | isCurrentPasswordVisible |
| 258 | ? localization.auth. |
| 259 | : localization.auth. |
| 260 | } |
| 261 | title={ |
| 262 | isCurrentPasswordVisible |
| 263 | ? localization.auth. |
| 264 | : localization.auth. |
| 265 | } |
| 266 | onClick={() => |
| 267 | setIsCurrentPasswordVisible( |
| 268 | (visible) => |
| 269 | ); |
| 270 | }} |
| 271 | > |
| 272 | {isCurrentPasswordVisible ? <EyeOff |
| 273 | </InputGroupButton> |
| 274 | </InputGroupAddon> |
| 275 | </InputGroup> |
| 276 | ) : ( |
| 277 | <Skeleton> |
| 278 | <Input className=" |
| 279 | </Skeleton> |
| 280 | )} |
| 281 | |
| 282 | <field.AuthFormFieldError /> |
| 283 | </Field> |
| 284 | )} |
| 285 | </form.AppField> |
| 286 | |
| 287 | <form.AppField |
| 288 | name="newPassword" |
| 289 | validators={{ |
| 290 | onChange: ({ value |
| 291 | validateStringLength(value, { |
| 292 | maxLength: emailAndPassword.maxPasswordLength |
| 293 | maxLengthMessage: localization.auth |
| 294 | "{{max}}", |
| 295 | String(emailAndPassword.maxPasswordLength |
| 296 | ), |
| 297 | minLength: emailAndPassword.minPasswordLength |
| 298 | minLengthMessage: localization.auth |
| 299 | "{{min}}", |
| 300 | String(emailAndPassword.minPasswordLength |
| 301 | ), |
| 302 | requiredMessage: localization.auth |
| 303 | }), |
| 304 | }} |
| 305 | > |
| 306 | {(field) => |
| 307 | const isInvalid = |
| 308 | isAuthFormFieldInvalid(field.state |
| 309 | |
| 310 | return ( |
| 311 | <Field data-invalid={ |
| 312 | <FieldLabel htmlFor=" |
| 313 | {localization.auth.newPassword |
| 314 | </FieldLabel> |
| 315 | |
| 316 | {session ? ( |
| 317 | <InputGroup> |
| 318 | <InputGroupInput |
| 319 | id="newPassword" |
| 320 | name={field.name |
| 321 | type={isNewPasswordVisible ? |
| 322 | autoComplete="new-password" |
| 323 | placeholder={ |
| 324 | localization.auth.newPasswordPlaceholder |
| 325 | } |
| 326 | value={field.state |
| 327 | onBlur={field.handleBlur |
| 328 | onChange={(e) |
| 329 | field.handleChange(e |
| 330 | setIsCompromised(false); |
| 331 | }} |
| 332 | minLength={emailAndPassword.minPasswordLength |
| 333 | maxLength={emailAndPassword.maxPasswordLength |
| 334 | disabled={isPending} |
| 335 | required |
| 336 | aria-invalid={isInvalid} |
| 337 | /> |
| 338 | |
| 339 | <InputGroupAddon align=" |
| 340 | <InputGroupButton |
| 341 | size="icon-xs" |
| 342 | aria-label={ |
| 343 | isNewPasswordVisible |
| 344 | ? localization.auth. |
| 345 | : localization.auth. |
| 346 | } |
| 347 | onClick={() => |
| 348 | setIsNewPasswordVisible((visible) |
| 349 | } |
| 350 | > |
| 351 | {isNewPasswordVisible ? <EyeOff |
| 352 | </InputGroupButton> |
| 353 | </InputGroupAddon> |
| 354 | </InputGroup> |
| 355 | ) : ( |
| 356 | <Skeleton> |
| 357 | <Input className=" |
| 358 | </Skeleton> |
| 359 | )} |
| 360 | |
| 361 | {isCompromised ? ( |
| 362 | <FieldError> |
| 363 | {localization.auth.passwordCompromised |
| 364 | </FieldError> |
| 365 | ) : ( |
| 366 | <field.AuthFormFieldError /> |
| 367 | )} |
| 368 | |
| 369 | <PasswordStrengthMeter password={ |
| 370 | </Field> |
| 371 | ); |
| 372 | }} |
| 373 | </form.AppField> |
| 374 | |
| 375 | {emailAndPassword.confirmPassword && |
| 376 | <form.AppField |
| 377 | name="confirmPassword" |
| 378 | validators={{ |
| 379 | onChangeListenTo: ["newPassword"], |
| 380 | onChange: ({ fieldApi |
| 381 | validateStringLength(value, { |
| 382 | maxLength: emailAndPassword.maxPasswordLength |
| 383 | maxLengthMessage: localization.auth |
| 384 | "{{max}}", |
| 385 | String(emailAndPassword.maxPasswordLength |
| 386 | ), |
| 387 | minLength: emailAndPassword.minPasswordLength |
| 388 | minLengthMessage: localization.auth |
| 389 | "{{min}}", |
| 390 | String(emailAndPassword.minPasswordLength |
| 391 | ), |
| 392 | requiredMessage: localization.auth |
| 393 | }) ?? |
| 394 | validateMatchingValue( |
| 395 | value, |
| 396 | fieldApi.form.getFieldValue |
| 397 | localization.auth.passwordsDoNotMatch |
| 398 | ), |
| 399 | }} |
| 400 | > |
| 401 | {(field) => |
| 402 | <Field |
| 403 | data-invalid={isAuthFormFieldInvalid(field |
| 404 | > |
| 405 | <FieldLabel htmlFor=" |
| 406 | {localization.auth.confirmPassword |
| 407 | </FieldLabel> |
| 408 | |
| 409 | {session ? ( |
| 410 | <InputGroup> |
| 411 | <InputGroupInput |
| 412 | id="confirmPassword" |
| 413 | name={field.name |
| 414 | type={ |
| 415 | isConfirmPasswordVisible ? "text" |
| 416 | } |
| 417 | autoComplete="new-password" |
| 418 | placeholder={ |
| 419 | localization.auth.confirmPasswordPlaceholder |
| 420 | } |
| 421 | value={field.state |
| 422 | onBlur={field.handleBlur |
| 423 | onChange={(e) |
| 424 | minLength={emailAndPassword.minPasswordLength |
| 425 | maxLength={emailAndPassword.maxPasswordLength |
| 426 | disabled={isPending} |
| 427 | required |
| 428 | aria-invalid={isAuthFormFieldInvalid( |
| 429 | field.state.meta |
| 430 | )} |
| 431 | /> |
| 432 | |
| 433 | <InputGroupAddon align=" |
| 434 | <InputGroupButton |
| 435 | size="icon-xs" |
| 436 | aria-label={ |
| 437 | isConfirmPasswordVisible |
| 438 | ? localization.auth. |
| 439 | : localization.auth. |
| 440 | } |
| 441 | onClick={() => |
| 442 | setIsConfirmPasswordVisible( |
| 443 | (visible) => |
| 444 | ) |
| 445 | } |
| 446 | > |
| 447 | {isConfirmPasswordVisible ? <EyeOff |
| 448 | </InputGroupButton> |
| 449 | </InputGroupAddon> |
| 450 | </InputGroup> |
| 451 | ) : ( |
| 452 | <Skeleton> |
| 453 | <Input className=" |
| 454 | </Skeleton> |
| 455 | )} |
| 456 | |
| 457 | <field.AuthFormFieldError /> |
| 458 | </Field> |
| 459 | )} |
| 460 | </form.AppField> |
| 461 | )} |
| 462 | </CardContent> |
| 463 | |
| 464 | <CardFooter> |
| 465 | <form.AuthFormSubmitButton |
| 466 | disabled={isPending || |
| 467 | size="sm" |
| 468 | > |
| 469 | {localization.settings.updatePassword |
| 470 | </form.AuthFormSubmitButton> |
| 471 | </CardFooter> |
| 472 | </Card> |
| 473 | </form.AuthFormRoot> |
| 474 | </form.AppForm> |
| 475 | </div> |
| 476 | ); |
| 477 | } |