@better-auth-ui/heroui 1.6.22 → 1.6.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@better-auth-ui/heroui",
3
- "version": "1.6.22",
3
+ "version": "1.6.24",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "build": "vite build",
@@ -33,8 +33,8 @@
33
33
  },
34
34
  "devDependencies": {
35
35
  "@gravity-ui/icons": "^2.18.0",
36
- "@heroui/react": "^3.2.0",
37
- "@heroui/styles": "^3.2.0",
36
+ "@heroui/react": "^3.2.1",
37
+ "@heroui/styles": "^3.2.1",
38
38
  "@internationalized/date": "^3.12.2",
39
39
  "@tanstack/react-pacer": "^0.22.1",
40
40
  "@tanstack/react-query": "^5.100.14",
@@ -54,8 +54,8 @@
54
54
  "@better-auth-ui/core": "*",
55
55
  "@better-auth-ui/react": "*",
56
56
  "@gravity-ui/icons": ">=2.18.0",
57
- "@heroui/react": ">=3.2.0",
58
- "@heroui/styles": ">=3.2.0",
57
+ "@heroui/react": ">=3.2.1",
58
+ "@heroui/styles": ">=3.2.1",
59
59
  "@internationalized/date": ">=3.12.2",
60
60
  "@tanstack/react-pacer": ">=0.22.1",
61
61
  "@tanstack/react-query": ">=5.100.14",
@@ -34,7 +34,7 @@ import {
34
34
  toCalendarDate,
35
35
  toCalendarDateTime
36
36
  } from "@internationalized/date"
37
- import { useRef, useState } from "react"
37
+ import { type ComponentType, useRef, useState } from "react"
38
38
 
39
39
  import type { AdditionalFieldProps } from "../../lib/auth/auth-plugin"
40
40
 
@@ -128,12 +128,17 @@ export function AdditionalField({
128
128
  const { localization } = useAuth()
129
129
  const inputType = resolveInputType(field)
130
130
  const inputVariant = variant === "transparent" ? "primary" : "secondary"
131
- // Used by `inputType: "input"` with `copyable: true` so the copy button
132
- // reads the input's *live* value rather than a stale `defaultValue`.
133
- const inputRef = useRef<HTMLInputElement>(null)
134
131
 
135
132
  if (field.render) {
136
- return <>{field.render({ name, field, isPending, variant })}</>
133
+ const FieldRenderer = field.render as ComponentType<AdditionalFieldProps>
134
+ return (
135
+ <FieldRenderer
136
+ name={name}
137
+ field={field}
138
+ isPending={isPending}
139
+ variant={variant}
140
+ />
141
+ )
137
142
  }
138
143
 
139
144
  if (inputType === "hidden") {
@@ -471,13 +476,28 @@ export function AdditionalField({
471
476
  )
472
477
  }
473
478
 
474
- // inputType === "input"
479
+ return (
480
+ <HeroInputField
481
+ name={name}
482
+ field={field}
483
+ isPending={isPending}
484
+ variant={variant}
485
+ />
486
+ )
487
+ }
488
+
489
+ function HeroInputField({
490
+ name,
491
+ field,
492
+ isPending,
493
+ variant
494
+ }: AdditionalFieldProps) {
495
+ const inputRef = useRef<HTMLInputElement>(null)
496
+ const inputVariant = variant === "transparent" ? "primary" : "secondary"
497
+
475
498
  const hasPrefix = field.prefix != null
476
499
  const hasSuffix = field.suffix != null || field.copyable
477
500
 
478
- // When `inputType: "input"` is paired with `type: "number"`, restrict the
479
- // native input to numbers. `formatOptions.maximumFractionDigits` enables
480
- // fractional input via `step`.
481
501
  const isNumeric = field.type === "number"
482
502
  const maxFractionDigits = field.formatOptions?.maximumFractionDigits
483
503
  const nativeInputType = isNumeric ? "number" : undefined
@@ -8,6 +8,7 @@ import { ResetPassword } from "./reset-password"
8
8
  import { SignIn } from "./sign-in"
9
9
  import { SignOut } from "./sign-out"
10
10
  import { SignUp } from "./sign-up"
11
+ import { VerifyEmail } from "./verify-email"
11
12
 
12
13
  export type AuthProps = {
13
14
  className?: string
@@ -31,7 +32,8 @@ const AUTH_VIEWS: Partial<Record<AuthView, ComponentType<AuthProps>>> = {
31
32
  signOut: SignOut,
32
33
  signUp: SignUp,
33
34
  forgotPassword: ForgotPassword,
34
- resetPassword: ResetPassword
35
+ resetPassword: ResetPassword,
36
+ verifyEmail: VerifyEmail
35
37
  }
36
38
 
37
39
  /**
@@ -0,0 +1,50 @@
1
+ import { getEmailProviderLink } from "@better-auth-ui/core"
2
+ import { useAuth } from "@better-auth-ui/react"
3
+ import { ArrowUpRightFromSquare } from "@gravity-ui/icons"
4
+ import { cn, Link } from "@heroui/react"
5
+ import { buttonVariants } from "@heroui/styles"
6
+
7
+ export type OpenEmailButtonProps = {
8
+ /** Email address used to detect the provider, e.g. from the verify-email flow. */
9
+ email: string
10
+ className?: string
11
+ }
12
+
13
+ /**
14
+ * Render a link styled as a button that opens the user's email provider login
15
+ * page in a new tab.
16
+ *
17
+ * The provider is resolved from the email domain via the curated
18
+ * `@mikkelscheike/email-provider-links` dataset (Gmail, Outlook, GMX, etc.).
19
+ * Renders nothing when the domain is empty or not a known provider.
20
+ *
21
+ * @param email - Email address to resolve the provider from.
22
+ * @param className - Additional CSS classes applied to the link.
23
+ * @returns The open-email link element, or `null` when no provider matches.
24
+ */
25
+ export function OpenEmailButton({ email, className }: OpenEmailButtonProps) {
26
+ const { localization } = useAuth()
27
+
28
+ const provider = getEmailProviderLink(email)
29
+ if (!provider) return null
30
+
31
+ return (
32
+ <Link
33
+ href={provider.loginUrl}
34
+ target="_blank"
35
+ rel="noopener noreferrer"
36
+ className={cn(
37
+ buttonVariants({ variant: "primary" }),
38
+ "w-full gap-2",
39
+ className
40
+ )}
41
+ >
42
+ {localization.auth.openEmailProvider.replace(
43
+ "{{provider}}",
44
+ provider.companyProvider
45
+ )}
46
+
47
+ <ArrowUpRightFromSquare className="size-3" />
48
+ </Link>
49
+ )
50
+ }
@@ -1,10 +1,5 @@
1
1
  import { authMutationKeys } from "@better-auth-ui/core"
2
- import {
3
- useAuth,
4
- useFetchOptions,
5
- useSendVerificationEmail,
6
- useSignInEmail
7
- } from "@better-auth-ui/react"
2
+ import { useAuth, useFetchOptions, useSignInEmail } from "@better-auth-ui/react"
8
3
  import {
9
4
  Button,
10
5
  Card,
@@ -18,8 +13,7 @@ import {
18
13
  Label,
19
14
  Link,
20
15
  Spinner,
21
- TextField,
22
- toast
16
+ TextField
23
17
  } from "@heroui/react"
24
18
  import { useIsMutating } from "@tanstack/react-query"
25
19
  import { type SyntheticEvent, useState } from "react"
@@ -48,7 +42,6 @@ export function SignIn({
48
42
  const {
49
43
  authClient,
50
44
  basePaths,
51
- baseURL,
52
45
  emailAndPassword,
53
46
  localization,
54
47
  plugins,
@@ -62,13 +55,6 @@ export function SignIn({
62
55
 
63
56
  const [password, setPassword] = useState("")
64
57
 
65
- const { mutate: sendVerificationEmail } = useSendVerificationEmail(
66
- authClient,
67
- {
68
- onSuccess: () => toast.success(localization.auth.verificationEmailSent)
69
- }
70
- )
71
-
72
58
  const { mutate: signInEmail, isPending: signInEmailPending } = useSignInEmail(
73
59
  authClient,
74
60
  {
@@ -76,15 +62,9 @@ export function SignIn({
76
62
  setPassword("")
77
63
 
78
64
  if (error.error?.code === "EMAIL_NOT_VERIFIED") {
79
- toast.danger(error.error?.message || error.message, {
80
- actionProps: {
81
- children: localization.auth.resend,
82
- onClick: () =>
83
- sendVerificationEmail({
84
- email,
85
- callbackURL: `${baseURL}${redirectTo}`
86
- })
87
- }
65
+ sessionStorage.setItem("better-auth-ui.verify-email", email)
66
+ navigate({
67
+ to: `${basePaths.auth}/${viewPaths.auth.verifyEmail}`
88
68
  })
89
69
  }
90
70
 
@@ -75,10 +75,12 @@ export function SignUp({
75
75
  setConfirmPassword("")
76
76
  resetFetchOptions()
77
77
  },
78
- onSuccess: () => {
78
+ onSuccess: (_data, { email }) => {
79
79
  if (emailAndPassword?.requireEmailVerification) {
80
- toast.success(localization.auth.verifyYourEmail)
81
- navigate({ to: `${basePaths.auth}/${viewPaths.auth.signIn}` })
80
+ sessionStorage.setItem("better-auth-ui.verify-email", email)
81
+ navigate({
82
+ to: `${basePaths.auth}/${viewPaths.auth.verifyEmail}`
83
+ })
82
84
  } else {
83
85
  navigate({ to: redirectTo })
84
86
  }
@@ -4,7 +4,6 @@ import {
4
4
  useAuth,
5
5
  useAuthPlugin,
6
6
  useFetchOptions,
7
- useSendVerificationEmail,
8
7
  useSignInEmail,
9
8
  useSignInUsername
10
9
  } from "@better-auth-ui/react"
@@ -21,8 +20,7 @@ import {
21
20
  Label,
22
21
  Link,
23
22
  Spinner,
24
- TextField,
25
- toast
23
+ TextField
26
24
  } from "@heroui/react"
27
25
  import { useIsMutating } from "@tanstack/react-query"
28
26
  import { type SyntheticEvent, useState } from "react"
@@ -51,7 +49,6 @@ export function SignInUsername({
51
49
  const {
52
50
  authClient,
53
51
  basePaths,
54
- baseURL,
55
52
  emailAndPassword,
56
53
  localization,
57
54
  plugins,
@@ -67,13 +64,6 @@ export function SignInUsername({
67
64
 
68
65
  const [password, setPassword] = useState("")
69
66
 
70
- const { mutate: sendVerificationEmail } = useSendVerificationEmail(
71
- authClient,
72
- {
73
- onSuccess: () => toast.success(localization.auth.verificationEmailSent)
74
- }
75
- )
76
-
77
67
  function isEmail(value: string): boolean {
78
68
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
79
69
  }
@@ -84,15 +74,9 @@ export function SignInUsername({
84
74
  setPassword("")
85
75
 
86
76
  if (error.error?.code === "EMAIL_NOT_VERIFIED") {
87
- toast.danger(error.error?.message || error.message, {
88
- actionProps: {
89
- children: localization.auth.resend,
90
- onClick: () =>
91
- sendVerificationEmail({
92
- email,
93
- callbackURL: `${baseURL}${redirectTo}`
94
- })
95
- }
77
+ sessionStorage.setItem("better-auth-ui.verify-email", email)
78
+ navigate({
79
+ to: `${basePaths.auth}/${viewPaths.auth.verifyEmail}`
96
80
  })
97
81
  }
98
82
 
@@ -0,0 +1,144 @@
1
+ import { useAuth, useSendVerificationEmail } from "@better-auth-ui/react"
2
+ import {
3
+ Button,
4
+ Card,
5
+ type CardProps,
6
+ cn,
7
+ Description,
8
+ Link,
9
+ Spinner,
10
+ toast,
11
+ useIsHydrated
12
+ } from "@heroui/react"
13
+ import { useEffect, useState } from "react"
14
+
15
+ import { OpenEmailButton } from "./open-email-button"
16
+
17
+ export type VerifyEmailProps = {
18
+ className?: string
19
+ variant?: CardProps["variant"]
20
+ }
21
+
22
+ /** Seconds the resend button stays disabled to prevent spamming the endpoint. */
23
+ const RESEND_COOLDOWN_SECONDS = 60
24
+
25
+ /**
26
+ * Render a card prompting the user to verify their email, with a resend button
27
+ * that is rate-limited by a cooldown timer.
28
+ *
29
+ * The target email is read from `sessionStorage` (set when sign-up or sign-in
30
+ * redirects here); if none is stored the user is redirected back to sign-in.
31
+ * The resend button is disabled while a cooldown is active and shows the
32
+ * remaining seconds.
33
+ *
34
+ * @param className - Additional CSS classes applied to the outer card container
35
+ * @param variant - Variant to apply to the card
36
+ * @returns The verify-email card React element
37
+ */
38
+ export function VerifyEmail({ className, variant }: VerifyEmailProps) {
39
+ const {
40
+ authClient,
41
+ basePaths,
42
+ baseURL,
43
+ localization,
44
+ navigate,
45
+ redirectTo,
46
+ viewPaths
47
+ } = useAuth()
48
+
49
+ const isHydrated = useIsHydrated()
50
+ const [email, setEmail] = useState(
51
+ (isHydrated && sessionStorage.getItem("better-auth-ui.verify-email")) || ""
52
+ )
53
+ const [cooldown, setCooldown] = useState(RESEND_COOLDOWN_SECONDS)
54
+
55
+ useEffect(() => {
56
+ const storedEmail = sessionStorage.getItem("better-auth-ui.verify-email")
57
+
58
+ if (!storedEmail) {
59
+ navigate({ to: `${basePaths.auth}/${viewPaths.auth.signIn}` })
60
+ return
61
+ }
62
+
63
+ setEmail(storedEmail)
64
+ }, [basePaths.auth, navigate, viewPaths.auth.signIn])
65
+
66
+ useEffect(() => {
67
+ if (cooldown <= 0) return
68
+
69
+ const interval = setInterval(() => {
70
+ setCooldown((current) => (current > 0 ? current - 1 : 0))
71
+ }, 1000)
72
+
73
+ return () => clearInterval(interval)
74
+ }, [cooldown])
75
+
76
+ const { mutate: sendVerificationEmail, isPending } = useSendVerificationEmail(
77
+ authClient,
78
+ {
79
+ onSuccess: () => {
80
+ toast.success(localization.auth.verificationEmailSent)
81
+ setCooldown(RESEND_COOLDOWN_SECONDS)
82
+ }
83
+ }
84
+ )
85
+
86
+ const isCoolingDown = cooldown > 0
87
+
88
+ return (
89
+ <Card
90
+ className={cn("w-full max-w-sm gap-4 md:p-6", className)}
91
+ variant={variant}
92
+ >
93
+ <Card.Header>
94
+ <Card.Title className="text-xl font-semibold mb-1">
95
+ {localization.auth.verifyEmail}
96
+ </Card.Title>
97
+ </Card.Header>
98
+
99
+ <Card.Content className="gap-4">
100
+ <Description className="text-sm">
101
+ {localization.auth.checkYourEmail}
102
+ </Description>
103
+
104
+ <div className="flex flex-col gap-3">
105
+ <OpenEmailButton email={email} />
106
+
107
+ <Button
108
+ className="w-full"
109
+ variant="tertiary"
110
+ isDisabled={!email || isCoolingDown || isPending}
111
+ isPending={isPending}
112
+ onPress={() =>
113
+ sendVerificationEmail({
114
+ email,
115
+ callbackURL: `${baseURL}${redirectTo}`
116
+ })
117
+ }
118
+ >
119
+ {isPending && <Spinner color="current" size="sm" />}
120
+
121
+ {isCoolingDown
122
+ ? localization.auth.resendIn.replace(
123
+ "{{seconds}}",
124
+ String(cooldown)
125
+ )
126
+ : localization.auth.resend}
127
+ </Button>
128
+ </div>
129
+ </Card.Content>
130
+
131
+ <Card.Footer className="flex-col gap-3">
132
+ <Description className="text-sm">
133
+ {localization.auth.alreadyVerifiedYourEmail}{" "}
134
+ <Link
135
+ href={`${basePaths.auth}/${viewPaths.auth.signIn}`}
136
+ className="text-accent no-underline hover:underline decoration-accent-hover"
137
+ >
138
+ {localization.auth.signIn}
139
+ </Link>
140
+ </Description>
141
+ </Card.Footer>
142
+ </Card>
143
+ )
144
+ }
package/src/index.tsx CHANGED
@@ -4,6 +4,7 @@ export * from "./components/auth/additional-field"
4
4
  export * from "./components/auth/auth"
5
5
  export * from "./components/auth/auth-provider"
6
6
  export * from "./components/auth/forgot-password"
7
+ export * from "./components/auth/open-email-button"
7
8
  export * from "./components/auth/provider-button"
8
9
  export * from "./components/auth/provider-buttons"
9
10
  export * from "./components/auth/reset-password"
@@ -21,3 +22,4 @@ export * from "./components/auth/sign-up"
21
22
  export * from "./components/auth/user/user-avatar"
22
23
  export * from "./components/auth/user/user-button"
23
24
  export * from "./components/auth/user/user-view"
25
+ export * from "./components/auth/verify-email"