@better-auth-ui/heroui 1.6.4 → 1.6.6

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.
@@ -11,14 +11,38 @@ import {
11
11
  type ButtonProps,
12
12
  cn,
13
13
  Dropdown,
14
+ type DropdownItemProps,
14
15
  type DropdownPopoverProps,
15
- Label,
16
- Separator
16
+ Label
17
17
  } from "@heroui/react"
18
+ import { isValidElement, type ReactElement, type ReactNode } from "react"
18
19
 
19
20
  import { UserAvatar } from "./user-avatar"
20
21
  import { UserView } from "./user-view"
21
22
 
23
+ /** Auth states a `UserButton` link can be visible in. */
24
+ export type UserButtonLinkVisibility =
25
+ | "authenticated"
26
+ | "unauthenticated"
27
+ | "always"
28
+
29
+ /** A simple link entry rendered as a `Dropdown.Item` in the `UserButton` menu. */
30
+ export type UserButtonLink = {
31
+ /** Visible label. */
32
+ label: ReactNode
33
+ /** Destination URL. */
34
+ href: string
35
+ /** Optional leading icon. Sized/coloured to match built-in items. */
36
+ icon?: ReactNode
37
+ /** Forwarded to the underlying `Dropdown.Item`. */
38
+ variant?: DropdownItemProps["variant"]
39
+ /**
40
+ * When this link is visible based on auth state.
41
+ * @default "always"
42
+ */
43
+ visibility?: UserButtonLinkVisibility
44
+ }
45
+
22
46
  export type UserButtonProps = {
23
47
  className?: string
24
48
  size?: "default" | "icon"
@@ -28,6 +52,30 @@ export type UserButtonProps = {
28
52
  */
29
53
  placement?: DropdownPopoverProps["placement"]
30
54
  variant?: ButtonProps["variant"]
55
+ /** Additional menu entries rendered above the built-in items. */
56
+ links?: (UserButtonLink | ReactElement)[]
57
+ /** Hide the built-in "Settings" link. Useful when replacing it via `links`. */
58
+ hideSettings?: boolean
59
+ }
60
+
61
+ function renderUserLink(
62
+ link: UserButtonLink | ReactElement,
63
+ fallbackKey: string
64
+ ): ReactNode {
65
+ if (isValidElement(link)) return link
66
+
67
+ const { label, href, icon, variant } = link
68
+ return (
69
+ <Dropdown.Item
70
+ key={fallbackKey}
71
+ href={href}
72
+ variant={variant}
73
+ textValue={typeof label === "string" ? label : undefined}
74
+ >
75
+ {icon}
76
+ <Label>{label}</Label>
77
+ </Dropdown.Item>
78
+ )
31
79
  }
32
80
 
33
81
  /**
@@ -37,13 +85,17 @@ export type UserButtonProps = {
37
85
  * @param placement - Dropdown popover placement (e.g., "bottom", "top-start", "bottom-end")
38
86
  * @param size - "icon" renders an avatar-only trigger; "default" renders a button with label and chevron
39
87
  * @param variant - Button visual variant passed to the underlying Button component
88
+ * @param links - Additional menu entries rendered above the built-in items
89
+ * @param hideSettings - Hide the built-in "Settings" link
40
90
  * @returns The user button and its dropdown menu as a JSX element
41
91
  */
42
92
  export function UserButton({
43
93
  className,
44
94
  placement = "bottom",
45
95
  size = "default",
46
- variant = "ghost"
96
+ variant = "ghost",
97
+ links,
98
+ hideSettings = false
47
99
  }: UserButtonProps) {
48
100
  const { authClient, basePaths, viewPaths, localization, plugins } = useAuth()
49
101
 
@@ -55,6 +107,16 @@ export function UserButton({
55
107
  <Item key={`${plugin.id}-${index.toString()}`} />
56
108
  )) ?? []
57
109
  )
110
+
111
+ const userLinks = links?.flatMap((link, index) => {
112
+ if (!isValidElement(link)) {
113
+ const visibility = link.visibility ?? "always"
114
+ if (visibility === "authenticated" && !session) return []
115
+ if (visibility === "unauthenticated" && session) return []
116
+ }
117
+ return [renderUserLink(link, `user-button-link-${index.toString()}`)]
118
+ })
119
+
58
120
  return (
59
121
  <Dropdown>
60
122
  {size === "icon" ? (
@@ -96,30 +158,35 @@ export function UserButton({
96
158
  <Dropdown.Menu>
97
159
  {session ? (
98
160
  <>
99
- <Dropdown.Item
100
- textValue={localization.settings.settings}
101
- href={`${basePaths.settings}/${viewPaths.settings.account}`}
102
- >
103
- <Gear className="text-muted" />
161
+ {userLinks}
104
162
 
105
- <Label>{localization.settings.settings}</Label>
106
- </Dropdown.Item>
163
+ {!hideSettings && (
164
+ <Dropdown.Item
165
+ textValue={localization.settings.settings}
166
+ href={`${basePaths.settings}/${viewPaths.settings.account}`}
167
+ >
168
+ <Gear className="text-muted" />
107
169
 
108
- {userMenuItems}
170
+ <Label>{localization.settings.settings}</Label>
171
+ </Dropdown.Item>
172
+ )}
109
173
 
110
- <Separator />
174
+ {userMenuItems}
111
175
 
112
176
  <Dropdown.Item
113
177
  textValue={localization.auth.signOut}
114
178
  href={`${basePaths.auth}/${viewPaths.auth.signOut}`}
179
+ variant="danger"
115
180
  >
116
- <ArrowRightFromSquare className="text-muted" />
181
+ <ArrowRightFromSquare className="text-danger" />
117
182
 
118
183
  <Label>{localization.auth.signOut}</Label>
119
184
  </Dropdown.Item>
120
185
  </>
121
186
  ) : (
122
187
  <>
188
+ {userLinks}
189
+
123
190
  <Dropdown.Item
124
191
  textValue={localization.auth.signIn}
125
192
  href={`${basePaths.auth}/${viewPaths.auth.signIn}`}
@@ -3,6 +3,7 @@ import {
3
3
  type UsernameAuthClient,
4
4
  useAuth,
5
5
  useAuthPlugin,
6
+ useFetchOptions,
6
7
  useSendVerificationEmail,
7
8
  useSignInEmail,
8
9
  useSignInUsername
@@ -61,6 +62,8 @@ export function SignInUsername({
61
62
  navigate
62
63
  } = useAuth()
63
64
 
65
+ const { fetchOptions, resetFetchOptions } = useFetchOptions()
66
+
64
67
  const { localization: usernameLocalization } = useAuthPlugin(usernamePlugin)
65
68
 
66
69
  const [password, setPassword] = useState("")
@@ -76,33 +79,37 @@ export function SignInUsername({
76
79
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
77
80
  }
78
81
 
79
- const { mutate: signInEmail } = useSignInEmail(authClient, {
80
- onError: (error, { email }) => {
81
- setPassword("")
82
-
83
- if (error.error?.code === "EMAIL_NOT_VERIFIED") {
84
- toast.danger(error.error?.message || error.message, {
85
- actionProps: {
86
- children: localization.auth.resend,
87
- onClick: () =>
88
- sendVerificationEmail({
89
- email,
90
- callbackURL: `${baseURL}${redirectTo}`
91
- })
92
- }
93
- })
94
- } else {
95
- toast.danger(error.error?.message || error.message)
96
- }
97
- },
98
- onSuccess: () => navigate({ to: redirectTo })
99
- })
82
+ const { mutate: signInEmail, isPending: isSignInEmailPending } =
83
+ useSignInEmail(authClient, {
84
+ onError: (error, { email }) => {
85
+ setPassword("")
86
+
87
+ if (error.error?.code === "EMAIL_NOT_VERIFIED") {
88
+ toast.danger(error.error?.message || error.message, {
89
+ actionProps: {
90
+ children: localization.auth.resend,
91
+ onClick: () =>
92
+ sendVerificationEmail({
93
+ email,
94
+ callbackURL: `${baseURL}${redirectTo}`
95
+ })
96
+ }
97
+ })
98
+ } else {
99
+ toast.danger(error.error?.message || error.message)
100
+ }
101
+
102
+ resetFetchOptions()
103
+ },
104
+ onSuccess: () => navigate({ to: redirectTo })
105
+ })
100
106
 
101
- const { mutate: signInUsername, isPending: isSignInPending } =
107
+ const { mutate: signInUsername, isPending: isSignInUsernamePending } =
102
108
  useSignInUsername(authClient as UsernameAuthClient, {
103
109
  onError: (error) => {
104
110
  setPassword("")
105
111
  toast.danger(error.error?.message || error.message)
112
+ resetFetchOptions()
106
113
  },
107
114
  onSuccess: () => navigate({ to: redirectTo })
108
115
  })
@@ -118,13 +125,15 @@ export function SignInUsername({
118
125
  signInEmail({
119
126
  email,
120
127
  password,
121
- ...(emailAndPassword?.rememberMe ? { rememberMe } : {})
128
+ ...(emailAndPassword?.rememberMe ? { rememberMe } : {}),
129
+ fetchOptions
122
130
  })
123
131
  } else {
124
132
  signInUsername({
125
133
  username: email,
126
134
  password,
127
- ...(emailAndPassword?.rememberMe ? { rememberMe } : {})
135
+ ...(emailAndPassword?.rememberMe ? { rememberMe } : {}),
136
+ fetchOptions
128
137
  })
129
138
  }
130
139
  }
@@ -136,6 +145,11 @@ export function SignInUsername({
136
145
  mutationKey: authMutationKeys.signUp.all
137
146
  })
138
147
  const isPending = signInMutating + signUpMutating > 0
148
+ const isSignInPending = isSignInEmailPending || isSignInUsernamePending
149
+
150
+ const Captcha = plugins.find(
151
+ (plugin) => plugin.captchaComponent
152
+ )?.captchaComponent
139
153
 
140
154
  const showSeparator = emailAndPassword?.enabled && !!socialProviders?.length
141
155
 
@@ -220,6 +234,8 @@ export function SignInUsername({
220
234
  </Checkbox>
221
235
  )}
222
236
 
237
+ {Captcha && <div className="flex justify-center">{Captcha}</div>}
238
+
223
239
  <div className="flex flex-col gap-3">
224
240
  <Button
225
241
  type="submit"
@@ -84,7 +84,6 @@ export function UsernameField({
84
84
  minLength={minUsernameLength}
85
85
  maxLength={maxUsernameLength}
86
86
  isDisabled={isPending}
87
- isRequired={field.required}
88
87
  isReadOnly={field.readOnly}
89
88
  value={value}
90
89
  onChange={handleChange}
@@ -92,7 +91,10 @@ export function UsernameField({
92
91
  <Label>{field.label}</Label>
93
92
 
94
93
  <InputGroup variant={variant === "transparent" ? "primary" : "secondary"}>
95
- <InputGroup.Input placeholder={field.placeholder} />
94
+ <InputGroup.Input
95
+ placeholder={field.placeholder}
96
+ required={field.required}
97
+ />
96
98
 
97
99
  {isCheckingAvailability && (
98
100
  <InputGroup.Suffix