@erikey/react 0.4.26 → 0.4.28

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.
Files changed (145) hide show
  1. package/dist/index.mjs +1 -1
  2. package/dist/index.mjs.map +1 -1
  3. package/package.json +2 -1
  4. package/src/__tests__/auth-client.test.ts +105 -0
  5. package/src/__tests__/security/localStorage-encryption.test.ts +171 -0
  6. package/src/auth-client.ts +158 -0
  7. package/src/dashboard-client.ts +60 -0
  8. package/src/index.ts +88 -0
  9. package/src/kv-client.ts +316 -0
  10. package/src/lib/cross-origin-auth.ts +99 -0
  11. package/src/stubs/captcha.ts +24 -0
  12. package/src/stubs/hashes.ts +16 -0
  13. package/src/stubs/index.ts +17 -0
  14. package/src/stubs/passkey.ts +12 -0
  15. package/src/stubs/qr-code.ts +10 -0
  16. package/src/stubs/query.ts +16 -0
  17. package/src/stubs/realtime.ts +17 -0
  18. package/src/stubs/use-sync-external-store.ts +12 -0
  19. package/src/styles.css +141 -0
  20. package/src/types.ts +14 -0
  21. package/src/ui/components/auth/auth-callback.tsx +36 -0
  22. package/src/ui/components/auth/auth-form.tsx +310 -0
  23. package/src/ui/components/auth/auth-view.tsx +435 -0
  24. package/src/ui/components/auth/email-otp-button.tsx +53 -0
  25. package/src/ui/components/auth/forms/email-otp-form.tsx +312 -0
  26. package/src/ui/components/auth/forms/email-verification-form.tsx +271 -0
  27. package/src/ui/components/auth/forms/forgot-password-form.tsx +173 -0
  28. package/src/ui/components/auth/forms/magic-link-form.tsx +196 -0
  29. package/src/ui/components/auth/forms/recover-account-form.tsx +143 -0
  30. package/src/ui/components/auth/forms/reset-password-form.tsx +220 -0
  31. package/src/ui/components/auth/forms/sign-in-form.tsx +323 -0
  32. package/src/ui/components/auth/forms/sign-up-form.tsx +820 -0
  33. package/src/ui/components/auth/forms/two-factor-form.tsx +381 -0
  34. package/src/ui/components/auth/magic-link-button.tsx +54 -0
  35. package/src/ui/components/auth/one-tap.tsx +53 -0
  36. package/src/ui/components/auth/otp-input-group.tsx +65 -0
  37. package/src/ui/components/auth/passkey-button.tsx +91 -0
  38. package/src/ui/components/auth/provider-button.tsx +155 -0
  39. package/src/ui/components/auth/sign-out.tsx +25 -0
  40. package/src/ui/components/auth/wallet-button.tsx +192 -0
  41. package/src/ui/components/auth-loading.tsx +21 -0
  42. package/src/ui/components/captcha/captcha.tsx +91 -0
  43. package/src/ui/components/captcha/recaptcha-badge.tsx +61 -0
  44. package/src/ui/components/captcha/recaptcha-v2.tsx +58 -0
  45. package/src/ui/components/captcha/recaptcha-v3.tsx +73 -0
  46. package/src/ui/components/email/email-template.tsx +216 -0
  47. package/src/ui/components/form-error.tsx +27 -0
  48. package/src/ui/components/password-input.tsx +56 -0
  49. package/src/ui/components/provider-icons.tsx +404 -0
  50. package/src/ui/components/redirect-to-sign-in.tsx +16 -0
  51. package/src/ui/components/redirect-to-sign-up.tsx +16 -0
  52. package/src/ui/components/signed-in.tsx +20 -0
  53. package/src/ui/components/signed-out.tsx +20 -0
  54. package/src/ui/components/ui/alert.tsx +66 -0
  55. package/src/ui/components/ui/button.tsx +70 -0
  56. package/src/ui/components/ui/card.tsx +92 -0
  57. package/src/ui/components/ui/checkbox.tsx +66 -0
  58. package/src/ui/components/ui/field.tsx +248 -0
  59. package/src/ui/components/ui/form.tsx +165 -0
  60. package/src/ui/components/ui/input-otp.tsx +77 -0
  61. package/src/ui/components/ui/input.tsx +21 -0
  62. package/src/ui/components/ui/label.tsx +23 -0
  63. package/src/ui/components/ui/separator.tsx +34 -0
  64. package/src/ui/components/ui/skeleton.tsx +13 -0
  65. package/src/ui/components/ui/textarea.tsx +18 -0
  66. package/src/ui/components/user-avatar.tsx +151 -0
  67. package/src/ui/hooks/use-auth-data.ts +193 -0
  68. package/src/ui/hooks/use-authenticate.ts +64 -0
  69. package/src/ui/hooks/use-captcha.tsx +151 -0
  70. package/src/ui/hooks/use-hydrated.ts +13 -0
  71. package/src/ui/hooks/use-lang.ts +32 -0
  72. package/src/ui/hooks/use-success-transition.ts +41 -0
  73. package/src/ui/hooks/use-theme.ts +39 -0
  74. package/src/ui/index.ts +46 -0
  75. package/src/ui/instantdb.ts +1 -0
  76. package/src/ui/lib/auth-data-cache.ts +90 -0
  77. package/src/ui/lib/auth-ui-provider.tsx +769 -0
  78. package/src/ui/lib/gravatar-utils.ts +58 -0
  79. package/src/ui/lib/image-utils.ts +55 -0
  80. package/src/ui/lib/instantdb/model-names.ts +24 -0
  81. package/src/ui/lib/instantdb/use-instant-options.ts +98 -0
  82. package/src/ui/lib/instantdb/use-list-accounts.ts +38 -0
  83. package/src/ui/lib/instantdb/use-list-sessions.ts +53 -0
  84. package/src/ui/lib/instantdb/use-session.ts +55 -0
  85. package/src/ui/lib/social-providers.ts +150 -0
  86. package/src/ui/lib/tanstack/auth-ui-provider-tanstack.tsx +49 -0
  87. package/src/ui/lib/tanstack/use-tanstack-options.ts +112 -0
  88. package/src/ui/lib/triplit/model-names.ts +24 -0
  89. package/src/ui/lib/triplit/use-conditional-query.ts +82 -0
  90. package/src/ui/lib/triplit/use-list-accounts.ts +31 -0
  91. package/src/ui/lib/triplit/use-list-sessions.ts +33 -0
  92. package/src/ui/lib/triplit/use-session.ts +42 -0
  93. package/src/ui/lib/triplit/use-triplit-hooks.ts +68 -0
  94. package/src/ui/lib/triplit/use-triplit-token.ts +44 -0
  95. package/src/ui/lib/utils.ts +119 -0
  96. package/src/ui/lib/view-paths.ts +61 -0
  97. package/src/ui/lib/wallet.ts +129 -0
  98. package/src/ui/localization/admin-error-codes.ts +20 -0
  99. package/src/ui/localization/anonymous-error-codes.ts +6 -0
  100. package/src/ui/localization/api-key-error-codes.ts +32 -0
  101. package/src/ui/localization/auth-localization.ts +865 -0
  102. package/src/ui/localization/base-error-codes.ts +27 -0
  103. package/src/ui/localization/captcha-error-codes.ts +17 -0
  104. package/src/ui/localization/email-otp-error-codes.ts +7 -0
  105. package/src/ui/localization/generic-oauth-error-codes.ts +3 -0
  106. package/src/ui/localization/haveibeenpwned-error-codes.ts +4 -0
  107. package/src/ui/localization/multi-session-error-codes.ts +3 -0
  108. package/src/ui/localization/organization-error-codes.ts +57 -0
  109. package/src/ui/localization/passkey-error-codes.ts +10 -0
  110. package/src/ui/localization/phone-number-error-codes.ts +10 -0
  111. package/src/ui/localization/stripe-localization.ts +12 -0
  112. package/src/ui/localization/team-error-codes.ts +12 -0
  113. package/src/ui/localization/two-factor-error-codes.ts +12 -0
  114. package/src/ui/localization/username-error-codes.ts +9 -0
  115. package/src/ui/server.ts +4 -0
  116. package/src/ui/style.css +146 -0
  117. package/src/ui/tanstack.ts +1 -0
  118. package/src/ui/triplit.ts +1 -0
  119. package/src/ui/types/account-options.ts +35 -0
  120. package/src/ui/types/additional-fields.ts +21 -0
  121. package/src/ui/types/any-auth-client.ts +6 -0
  122. package/src/ui/types/api-key.ts +9 -0
  123. package/src/ui/types/auth-client.ts +41 -0
  124. package/src/ui/types/auth-hooks.ts +81 -0
  125. package/src/ui/types/auth-mutators.ts +21 -0
  126. package/src/ui/types/avatar-options.ts +29 -0
  127. package/src/ui/types/captcha-options.ts +32 -0
  128. package/src/ui/types/captcha-provider.ts +7 -0
  129. package/src/ui/types/credentials-options.ts +38 -0
  130. package/src/ui/types/delete-user-options.ts +7 -0
  131. package/src/ui/types/email-verification-options.ts +7 -0
  132. package/src/ui/types/fetch-error.ts +6 -0
  133. package/src/ui/types/generic-oauth-options.ts +16 -0
  134. package/src/ui/types/gravatar-options.ts +21 -0
  135. package/src/ui/types/image.ts +7 -0
  136. package/src/ui/types/invitation.ts +10 -0
  137. package/src/ui/types/link.ts +7 -0
  138. package/src/ui/types/organization-options.ts +106 -0
  139. package/src/ui/types/password-validation.ts +16 -0
  140. package/src/ui/types/profile.ts +15 -0
  141. package/src/ui/types/refetch.ts +1 -0
  142. package/src/ui/types/render-toast.ts +9 -0
  143. package/src/ui/types/sign-up-options.ts +7 -0
  144. package/src/ui/types/social-options.ts +16 -0
  145. package/src/ui/types/team-options.ts +47 -0
@@ -0,0 +1,34 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+
5
+ import { cn } from "../../lib/utils"
6
+
7
+ interface SeparatorProps extends React.ComponentProps<"div"> {
8
+ orientation?: "horizontal" | "vertical"
9
+ decorative?: boolean
10
+ }
11
+
12
+ function Separator({
13
+ className,
14
+ orientation = "horizontal",
15
+ decorative = true,
16
+ ...props
17
+ }: SeparatorProps) {
18
+ return (
19
+ <div
20
+ role={decorative ? "none" : "separator"}
21
+ aria-orientation={decorative ? undefined : orientation}
22
+ data-slot="separator"
23
+ data-orientation={orientation}
24
+ className={cn(
25
+ "bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
26
+ className
27
+ )}
28
+ {...props}
29
+ />
30
+ )
31
+ }
32
+
33
+ export { Separator }
34
+ export type { SeparatorProps }
@@ -0,0 +1,13 @@
1
+ import { cn } from "../../lib/utils"
2
+
3
+ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
4
+ return (
5
+ <div
6
+ data-slot="skeleton"
7
+ className={cn("bg-accent animate-pulse rounded-md", className)}
8
+ {...props}
9
+ />
10
+ )
11
+ }
12
+
13
+ export { Skeleton }
@@ -0,0 +1,18 @@
1
+ import * as React from "react"
2
+
3
+ import { cn } from "../../lib/utils"
4
+
5
+ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
6
+ return (
7
+ <textarea
8
+ data-slot="textarea"
9
+ className={cn(
10
+ "border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
11
+ className
12
+ )}
13
+ {...props}
14
+ />
15
+ )
16
+ }
17
+
18
+ export { Textarea }
@@ -0,0 +1,151 @@
1
+ "use client"
2
+
3
+ import { UserRoundIcon } from "lucide-react"
4
+ import { type ComponentProps, useContext, useState } from "react"
5
+
6
+ import { AuthUIContext } from "../lib/auth-ui-provider"
7
+ import { getGravatarUrl } from "../lib/gravatar-utils"
8
+ import { cn } from "../lib/utils"
9
+ import type { AuthLocalization } from "../localization/auth-localization"
10
+ import type { Profile } from "../types/profile"
11
+ import { Skeleton } from "./ui/skeleton"
12
+
13
+ export interface UserAvatarClassNames {
14
+ base?: string
15
+ image?: string
16
+ fallback?: string
17
+ fallbackIcon?: string
18
+ skeleton?: string
19
+ }
20
+
21
+ export interface UserAvatarProps {
22
+ classNames?: UserAvatarClassNames
23
+ isPending?: boolean
24
+ size?: "sm" | "default" | "lg" | "xl" | null
25
+ user?: Profile | null
26
+ /**
27
+ * @default authLocalization
28
+ * @remarks `AuthLocalization`
29
+ */
30
+ localization?: Partial<AuthLocalization>
31
+ }
32
+
33
+ /**
34
+ * Displays a user avatar with image and fallback support
35
+ *
36
+ * Renders a user's avatar image when available, with appropriate fallbacks:
37
+ * - Shows a skeleton when isPending is true
38
+ * - Displays first two characters of user's name when no image is available
39
+ * - Falls back to a generic user icon when neither image nor name is available
40
+ */
41
+ export function UserAvatar({
42
+ className,
43
+ classNames,
44
+ isPending,
45
+ size,
46
+ user,
47
+ localization: propLocalization,
48
+ ...props
49
+ }: UserAvatarProps & ComponentProps<"div">) {
50
+ const {
51
+ localization: contextLocalization,
52
+ gravatar,
53
+ avatar
54
+ } = useContext(AuthUIContext)
55
+
56
+ const localization = { ...contextLocalization, ...propLocalization }
57
+
58
+ const [imageError, setImageError] = useState(false)
59
+
60
+ const name =
61
+ user?.displayName ||
62
+ user?.name ||
63
+ user?.fullName ||
64
+ user?.firstName ||
65
+ user?.displayUsername ||
66
+ user?.username ||
67
+ user?.email
68
+ const userImage = user?.image || user?.avatar || user?.avatarUrl
69
+
70
+ // Calculate gravatar URL synchronously
71
+ const gravatarUrl =
72
+ gravatar && user?.email
73
+ ? getGravatarUrl(
74
+ user.email,
75
+ gravatar === true ? undefined : gravatar
76
+ )
77
+ : null
78
+
79
+ const src = gravatar ? gravatarUrl : userImage
80
+ const showFallback = !src || imageError
81
+
82
+ const sizeClass =
83
+ size === "sm"
84
+ ? "size-6"
85
+ : size === "lg"
86
+ ? "size-10"
87
+ : size === "xl"
88
+ ? "size-12"
89
+ : "size-8"
90
+
91
+ if (isPending) {
92
+ return (
93
+ <Skeleton
94
+ className={cn(
95
+ "shrink-0 rounded-full",
96
+ sizeClass,
97
+ className,
98
+ classNames?.base,
99
+ classNames?.skeleton
100
+ )}
101
+ />
102
+ )
103
+ }
104
+
105
+ return (
106
+ <div
107
+ className={cn(
108
+ "relative flex shrink-0 overflow-hidden rounded-full bg-muted",
109
+ sizeClass,
110
+ className,
111
+ classNames?.base
112
+ )}
113
+ {...props}
114
+ >
115
+ {!showFallback && (
116
+ avatar?.Image ? (
117
+ <avatar.Image
118
+ alt={name || localization?.USER}
119
+ className={cn("aspect-square h-full w-full object-cover", classNames?.image)}
120
+ src={src || ""}
121
+ />
122
+ ) : (
123
+ <img
124
+ alt={name || localization?.USER}
125
+ className={cn("aspect-square h-full w-full object-cover", classNames?.image)}
126
+ src={src || undefined}
127
+ onError={() => setImageError(true)}
128
+ />
129
+ )
130
+ )}
131
+
132
+ {showFallback && (
133
+ <span
134
+ className={cn(
135
+ "flex h-full w-full items-center justify-center text-foreground uppercase",
136
+ size === "sm" ? "text-xs" : size === "lg" || size === "xl" ? "text-base" : "text-sm",
137
+ classNames?.fallback
138
+ )}
139
+ >
140
+ {firstTwoCharacters(name) || (
141
+ <UserRoundIcon
142
+ className={cn("size-[50%]", classNames?.fallbackIcon)}
143
+ />
144
+ )}
145
+ </span>
146
+ )}
147
+ </div>
148
+ )
149
+ }
150
+
151
+ const firstTwoCharacters = (name?: string | null) => name?.slice(0, 2)
@@ -0,0 +1,193 @@
1
+ import {
2
+ useCallback,
3
+ useContext,
4
+ useEffect,
5
+ useRef,
6
+ useState,
7
+ useSyncExternalStore
8
+ } from "react"
9
+
10
+ import { authDataCache } from "../lib/auth-data-cache"
11
+ import { AuthUIContext } from "../lib/auth-ui-provider"
12
+ import { getLocalizedError } from "../lib/utils"
13
+ import type { FetchError } from "../types/fetch-error"
14
+
15
+ export function useAuthData<T>({
16
+ queryFn,
17
+ cacheKey,
18
+ staleTime = 10000 // Default 10 seconds
19
+ }: {
20
+ queryFn: () => Promise<{ data: T | null; error?: FetchError | null }>
21
+ cacheKey?: string
22
+ staleTime?: number
23
+ }) {
24
+ const {
25
+ hooks: { useSession },
26
+ toast,
27
+ localization,
28
+ localizeErrors
29
+ } = useContext(AuthUIContext)
30
+ const { data: sessionData, isPending: sessionPending } = useSession()
31
+
32
+ // Generate a stable cache key based on the queryFn if not provided
33
+ const queryFnRef = useRef(queryFn)
34
+ queryFnRef.current = queryFn
35
+
36
+ const stableCacheKey = cacheKey || queryFn.toString()
37
+
38
+ // Subscribe to cache updates for this key
39
+ const cacheEntry = useSyncExternalStore(
40
+ useCallback(
41
+ (callback) => authDataCache.subscribe(stableCacheKey, callback),
42
+ [stableCacheKey]
43
+ ),
44
+ useCallback(
45
+ () => authDataCache.get<T>(stableCacheKey),
46
+ [stableCacheKey]
47
+ ),
48
+ useCallback(
49
+ () => authDataCache.get<T>(stableCacheKey),
50
+ [stableCacheKey]
51
+ )
52
+ )
53
+
54
+ const initialized = useRef(false)
55
+ const previousUserId = useRef<string | undefined>(undefined)
56
+ const [error, setError] = useState<FetchError | null>(null)
57
+
58
+ const refetch = useCallback(async () => {
59
+ // Check if there's already an in-flight request for this key
60
+ const existingRequest = authDataCache.getInFlightRequest<{
61
+ data: T | null
62
+ error?: FetchError | null
63
+ }>(stableCacheKey)
64
+ if (existingRequest) {
65
+ // Wait for the existing request to complete
66
+ try {
67
+ const result = await existingRequest
68
+ if (result.error) {
69
+ setError(result.error)
70
+ } else {
71
+ setError(null)
72
+ }
73
+ } catch (err) {
74
+ setError(err as FetchError)
75
+ }
76
+ return
77
+ }
78
+
79
+ // Mark as refetching if we have cached data
80
+ if (cacheEntry?.data !== undefined) {
81
+ authDataCache.setRefetching(stableCacheKey, true)
82
+ }
83
+
84
+ // Create the fetch promise
85
+ const fetchPromise = queryFnRef.current()
86
+
87
+ // Store the promise as in-flight
88
+ authDataCache.setInFlightRequest(stableCacheKey, fetchPromise)
89
+
90
+ try {
91
+ const { data, error } = await fetchPromise
92
+
93
+ if (error) {
94
+ setError(error)
95
+ toast({
96
+ variant: "error",
97
+ message: getLocalizedError({
98
+ error,
99
+ localization,
100
+ localizeErrors
101
+ })
102
+ })
103
+ } else {
104
+ setError(null)
105
+ }
106
+
107
+ // Update cache with new data
108
+ authDataCache.set(stableCacheKey, data)
109
+ } catch (err) {
110
+ const error = err as FetchError
111
+ setError(error)
112
+ toast({
113
+ variant: "error",
114
+ message: getLocalizedError({
115
+ error,
116
+ localization,
117
+ localizeErrors
118
+ })
119
+ })
120
+ } finally {
121
+ authDataCache.setRefetching(stableCacheKey, false)
122
+ authDataCache.removeInFlightRequest(stableCacheKey)
123
+ }
124
+ }, [stableCacheKey, toast, localization, localizeErrors, cacheEntry])
125
+
126
+ useEffect(() => {
127
+ const currentUserId = sessionData?.user?.id
128
+
129
+ if (!sessionData) {
130
+ // Clear cache when session is lost
131
+ authDataCache.setRefetching(stableCacheKey, false)
132
+ authDataCache.clear(stableCacheKey)
133
+ initialized.current = false
134
+ previousUserId.current = undefined
135
+ return
136
+ }
137
+
138
+ // Check if user ID has changed
139
+ const userIdChanged =
140
+ previousUserId.current !== undefined &&
141
+ previousUserId.current !== currentUserId
142
+
143
+ // If user changed, clear cache to ensure isPending becomes true
144
+ if (userIdChanged) {
145
+ authDataCache.clear(stableCacheKey)
146
+ }
147
+
148
+ // If we have cached data, we're not pending anymore
149
+ const hasCachedData = cacheEntry?.data !== undefined
150
+
151
+ // Check if data is stale
152
+ const isStale =
153
+ !cacheEntry || Date.now() - cacheEntry.timestamp > staleTime
154
+
155
+ if (
156
+ !initialized.current ||
157
+ !hasCachedData ||
158
+ userIdChanged ||
159
+ (hasCachedData && isStale)
160
+ ) {
161
+ // Only fetch if we don't have data or if the data is stale
162
+ if (!hasCachedData || isStale) {
163
+ initialized.current = true
164
+ refetch()
165
+ }
166
+ }
167
+
168
+ // Update the previous user ID
169
+ previousUserId.current = currentUserId
170
+ }, [
171
+ sessionData,
172
+ sessionData?.user?.id,
173
+ stableCacheKey,
174
+ refetch,
175
+ cacheEntry,
176
+ staleTime
177
+ ])
178
+
179
+ // Determine if we're in a pending state
180
+ // We're only pending if:
181
+ // 1. Session is still loading, OR
182
+ // 2. We have no cached data and no error
183
+ const isPending =
184
+ sessionPending || (cacheEntry?.data === undefined && !error)
185
+
186
+ return {
187
+ data: cacheEntry?.data ?? null,
188
+ isPending,
189
+ isRefetching: cacheEntry?.isRefetching ?? false,
190
+ error,
191
+ refetch
192
+ }
193
+ }
@@ -0,0 +1,64 @@
1
+ import { useContext, useEffect } from "react"
2
+ import { AuthUIContext } from "../lib/auth-ui-provider"
3
+ import type { AuthViewPath } from "../server"
4
+ import type { AnyAuthClient } from "../types/any-auth-client"
5
+
6
+ interface AuthenticateOptions<TAuthClient extends AnyAuthClient> {
7
+ authClient?: TAuthClient
8
+ authView?: AuthViewPath
9
+ enabled?: boolean
10
+ }
11
+
12
+ export function useAuthenticate<TAuthClient extends AnyAuthClient>(
13
+ options?: AuthenticateOptions<TAuthClient>
14
+ ) {
15
+ type Session = TAuthClient["$Infer"]["Session"]["session"]
16
+ type User = TAuthClient["$Infer"]["Session"]["user"]
17
+
18
+ const { authView = "SIGN_IN", enabled = true } = options ?? {}
19
+
20
+ const {
21
+ hooks: { useSession },
22
+ basePath,
23
+ viewPaths,
24
+ replace
25
+ } = useContext(AuthUIContext)
26
+
27
+ const { data, isPending, error, refetch } = useSession()
28
+ const sessionData = data as
29
+ | {
30
+ session: Session
31
+ user: User
32
+ }
33
+ | null
34
+ | undefined
35
+
36
+ useEffect(() => {
37
+ if (!enabled || isPending || sessionData) return
38
+
39
+ const searchParams = new URLSearchParams(window.location.search)
40
+ const redirectTo =
41
+ searchParams.get("redirectTo") ||
42
+ window.location.pathname + window.location.search
43
+
44
+ replace(
45
+ `${basePath}/${viewPaths[authView]}?redirectTo=${encodeURIComponent(redirectTo)}`
46
+ )
47
+ }, [
48
+ isPending,
49
+ sessionData,
50
+ basePath,
51
+ viewPaths,
52
+ replace,
53
+ authView,
54
+ enabled
55
+ ])
56
+
57
+ return {
58
+ data: sessionData,
59
+ user: sessionData?.user,
60
+ isPending,
61
+ error,
62
+ refetch
63
+ }
64
+ }
@@ -0,0 +1,151 @@
1
+ import type { CaptchaFoxInstance } from "@captchafox/react"
2
+ import type HCaptcha from "@hcaptcha/react-hcaptcha"
3
+ import type { TurnstileInstance } from "@marsidev/react-turnstile"
4
+ import { useGoogleReCaptcha } from "@wojtekmaj/react-recaptcha-v3"
5
+ import { type RefObject, useContext, useRef } from "react"
6
+ import type ReCAPTCHA from "react-google-recaptcha"
7
+
8
+ import { AuthUIContext } from "../lib/auth-ui-provider"
9
+ import type { AuthLocalization } from "../localization/auth-localization"
10
+
11
+ // Default captcha endpoints
12
+ const DEFAULT_CAPTCHA_ENDPOINTS = [
13
+ "/sign-up/email",
14
+ "/sign-in/email",
15
+ "/forget-password"
16
+ ]
17
+
18
+ // Sanitize action name for reCAPTCHA
19
+ // Google reCAPTCHA only allows A-Za-z/_ in action names
20
+ const sanitizeActionName = (action: string): string => {
21
+ // First remove leading slash if present
22
+ let result = action.startsWith("/") ? action.substring(1) : action
23
+
24
+ // Convert both kebab-case and path separators to camelCase
25
+ // Example: "/sign-in/email" becomes "signInEmail"
26
+ result = result
27
+ .replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())
28
+ .replace(/\/([a-z])/g, (_, letter) => letter.toUpperCase())
29
+ .replace(/\//g, "")
30
+ .replace(/[^A-Za-z0-9_]/g, "")
31
+
32
+ return result
33
+ }
34
+
35
+ export function useCaptcha({
36
+ localization
37
+ }: {
38
+ localization: Partial<AuthLocalization>
39
+ }) {
40
+ const { captcha, localization: contextLocalization } =
41
+ useContext(AuthUIContext)
42
+
43
+ localization = { ...contextLocalization, ...localization }
44
+
45
+ // biome-ignore lint/suspicious/noExplicitAny: ignore
46
+ const captchaRef = useRef<any>(null)
47
+ const { executeRecaptcha } = useGoogleReCaptcha()
48
+
49
+ const executeCaptcha = async (action: string) => {
50
+ if (!captcha) throw new Error(localization.MISSING_RESPONSE)
51
+
52
+ // Sanitize the action name for reCAPTCHA
53
+ let response: string | undefined | null
54
+
55
+ switch (captcha.provider) {
56
+ case "google-recaptcha-v3": {
57
+ const sanitizedAction = sanitizeActionName(action)
58
+ response = await executeRecaptcha?.(sanitizedAction)
59
+ break
60
+ }
61
+ case "google-recaptcha-v2-checkbox": {
62
+ const recaptchaRef = captchaRef as RefObject<ReCAPTCHA>
63
+ response = recaptchaRef.current.getValue()
64
+ break
65
+ }
66
+ case "google-recaptcha-v2-invisible": {
67
+ const recaptchaRef = captchaRef as RefObject<ReCAPTCHA>
68
+ response = await recaptchaRef.current.executeAsync()
69
+ break
70
+ }
71
+ case "cloudflare-turnstile": {
72
+ const turnstileRef = captchaRef as RefObject<TurnstileInstance>
73
+ response = turnstileRef.current.getResponse()
74
+ break
75
+ }
76
+ case "hcaptcha": {
77
+ const hcaptchaRef = captchaRef as RefObject<HCaptcha>
78
+ response = hcaptchaRef.current.getResponse()
79
+ break
80
+ }
81
+ case "captchafox": {
82
+ const captchafoxRef =
83
+ captchaRef as RefObject<CaptchaFoxInstance>
84
+ response = captchafoxRef.current.getResponse()
85
+ break
86
+ }
87
+ }
88
+
89
+ if (!response) {
90
+ throw new Error(localization.MISSING_RESPONSE)
91
+ }
92
+
93
+ return response
94
+ }
95
+
96
+ const getCaptchaHeaders = async (action: string) => {
97
+ if (!captcha) return undefined
98
+
99
+ // Use custom endpoints if provided, otherwise use defaults
100
+ const endpoints = captcha.endpoints || DEFAULT_CAPTCHA_ENDPOINTS
101
+
102
+ // Only execute captcha if the action is in the endpoints list
103
+ if (endpoints.includes(action)) {
104
+ return { "x-captcha-response": await executeCaptcha(action) }
105
+ }
106
+
107
+ return undefined
108
+ }
109
+
110
+ const resetCaptcha = () => {
111
+ if (!captcha) return
112
+
113
+ switch (captcha.provider) {
114
+ case "google-recaptcha-v3": {
115
+ // No widget to reset; token is generated per execute call
116
+ break
117
+ }
118
+ case "google-recaptcha-v2-checkbox":
119
+ case "google-recaptcha-v2-invisible": {
120
+ const recaptchaRef = captchaRef as RefObject<ReCAPTCHA>
121
+ recaptchaRef.current?.reset?.()
122
+ break
123
+ }
124
+ case "cloudflare-turnstile": {
125
+ const turnstileRef = captchaRef as RefObject<TurnstileInstance>
126
+ // Some versions expose reset on the instance
127
+ // biome-ignore lint/suspicious/noExplicitAny: defensive
128
+ ;(turnstileRef.current as any)?.reset?.()
129
+ break
130
+ }
131
+ case "hcaptcha": {
132
+ const hcaptchaRef = captchaRef as RefObject<HCaptcha>
133
+ // HCaptcha uses resetCaptcha()
134
+ hcaptchaRef.current?.resetCaptcha?.()
135
+ break
136
+ }
137
+ case "captchafox": {
138
+ const captchafoxRef =
139
+ captchaRef as RefObject<CaptchaFoxInstance>
140
+ captchafoxRef.current?.reset?.()
141
+ break
142
+ }
143
+ }
144
+ }
145
+
146
+ return {
147
+ captchaRef,
148
+ getCaptchaHeaders,
149
+ resetCaptcha
150
+ }
151
+ }
@@ -0,0 +1,13 @@
1
+ import { useSyncExternalStore } from "react"
2
+
3
+ function subscribe() {
4
+ return () => {}
5
+ }
6
+
7
+ export function useIsHydrated() {
8
+ return useSyncExternalStore(
9
+ subscribe,
10
+ () => true,
11
+ () => false
12
+ )
13
+ }
@@ -0,0 +1,32 @@
1
+ import { useEffect, useState } from "react"
2
+
3
+ export function useLang() {
4
+ const [lang, setLang] = useState<string>()
5
+
6
+ useEffect(() => {
7
+ const checkLang = () => {
8
+ const currentLang = document.documentElement.getAttribute("lang")
9
+ setLang(currentLang ?? undefined)
10
+ }
11
+
12
+ // Initial check
13
+ checkLang()
14
+
15
+ // Listen for changes to lang attribute on html tag
16
+ const observer = new MutationObserver((mutations) => {
17
+ for (const mutation of mutations) {
18
+ if (mutation.attributeName === "lang") {
19
+ checkLang()
20
+ }
21
+ }
22
+ })
23
+
24
+ observer.observe(document.documentElement, { attributes: true })
25
+
26
+ return () => {
27
+ observer.disconnect()
28
+ }
29
+ }, [])
30
+
31
+ return { lang }
32
+ }