@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,155 @@
1
+ import type { SocialProvider } from "better-auth/social-providers"
2
+ import { useCallback, useContext } from "react"
3
+
4
+ import { AuthUIContext } from "../../lib/auth-ui-provider"
5
+ import type { Provider } from "../../lib/social-providers"
6
+ import { cn, getLocalizedError, getSearchParam } from "../../lib/utils"
7
+ import type { AuthLocalization } from "../../localization/auth-localization"
8
+ import { Button } from "../ui/button"
9
+ import type { AuthViewClassNames } from "./auth-view"
10
+
11
+ interface ProviderButtonProps {
12
+ className?: string
13
+ classNames?: AuthViewClassNames
14
+ callbackURL?: string
15
+ isSubmitting: boolean
16
+ localization: Partial<AuthLocalization>
17
+ onError?: (error: string) => void
18
+ other?: boolean
19
+ provider: Provider
20
+ redirectTo?: string
21
+ socialLayout: "auto" | "horizontal" | "grid" | "vertical"
22
+ setIsSubmitting: (isSubmitting: boolean) => void
23
+ }
24
+
25
+ export function ProviderButton({
26
+ className,
27
+ classNames,
28
+ callbackURL: callbackURLProp,
29
+ isSubmitting,
30
+ localization,
31
+ onError,
32
+ other,
33
+ provider,
34
+ redirectTo: redirectToProp,
35
+ socialLayout,
36
+ setIsSubmitting
37
+ }: ProviderButtonProps) {
38
+ const {
39
+ authClient,
40
+ basePath,
41
+ baseURL,
42
+ persistClient,
43
+ redirectTo: contextRedirectTo,
44
+ viewPaths,
45
+ social,
46
+ genericOAuth,
47
+ toast,
48
+ localizeErrors
49
+ } = useContext(AuthUIContext)
50
+
51
+ const getRedirectTo = useCallback(
52
+ () =>
53
+ redirectToProp || getSearchParam("redirectTo") || contextRedirectTo,
54
+ [redirectToProp, contextRedirectTo]
55
+ )
56
+
57
+ const getCallbackURL = useCallback(
58
+ () =>
59
+ `${baseURL}${
60
+ callbackURLProp ||
61
+ (persistClient
62
+ ? `${basePath}/${viewPaths.CALLBACK}?redirectTo=${encodeURIComponent(getRedirectTo())}`
63
+ : getRedirectTo())
64
+ }`,
65
+ [
66
+ callbackURLProp,
67
+ persistClient,
68
+ basePath,
69
+ viewPaths,
70
+ baseURL,
71
+ getRedirectTo
72
+ ]
73
+ )
74
+
75
+ const doSignInSocial = async () => {
76
+ setIsSubmitting(true)
77
+
78
+ try {
79
+ if (other) {
80
+ const oauth2Params = {
81
+ providerId: provider.provider,
82
+ callbackURL: getCallbackURL(),
83
+ fetchOptions: { throw: true }
84
+ }
85
+
86
+ if (genericOAuth?.signIn) {
87
+ await genericOAuth.signIn(oauth2Params)
88
+
89
+ setTimeout(() => {
90
+ setIsSubmitting(false)
91
+ }, 10000)
92
+ } else {
93
+ await authClient.signIn.oauth2(oauth2Params)
94
+ }
95
+ } else {
96
+ const socialParams = {
97
+ provider: provider.provider as SocialProvider,
98
+ callbackURL: getCallbackURL(),
99
+ fetchOptions: { throw: true }
100
+ }
101
+
102
+ if (social?.signIn) {
103
+ await social.signIn(socialParams)
104
+
105
+ setTimeout(() => {
106
+ setIsSubmitting(false)
107
+ }, 10000)
108
+ } else {
109
+ await authClient.signIn.social(socialParams)
110
+ }
111
+ }
112
+ } catch (error) {
113
+ const errorMessage = getLocalizedError({
114
+ error,
115
+ localization,
116
+ localizeErrors
117
+ })
118
+
119
+ // Call onError callback if provided (for inline display in parent)
120
+ onError?.(errorMessage)
121
+
122
+ // Also call toast for users who provide custom toast handler
123
+ toast({
124
+ variant: "error",
125
+ message: errorMessage
126
+ })
127
+
128
+ setIsSubmitting(false)
129
+ }
130
+ }
131
+
132
+ return (
133
+ <Button
134
+ className={cn(
135
+ socialLayout === "vertical" && "w-full",
136
+ socialLayout === "horizontal" && "flex-1 min-w-0",
137
+ className,
138
+ classNames?.form?.button,
139
+ classNames?.form?.outlineButton,
140
+ classNames?.form?.providerButton
141
+ )}
142
+ disabled={isSubmitting}
143
+ variant="outline"
144
+ onClick={doSignInSocial}
145
+ >
146
+ {provider.icon && (
147
+ <provider.icon className={classNames?.form?.icon} />
148
+ )}
149
+
150
+ {socialLayout === "grid" && provider.name}
151
+ {socialLayout === "vertical" &&
152
+ `${localization.SIGN_IN_WITH} ${provider.name}`}
153
+ </Button>
154
+ )
155
+ }
@@ -0,0 +1,25 @@
1
+ "use client"
2
+
3
+ import { Loader2 } from "lucide-react"
4
+ import { useContext, useEffect, useRef } from "react"
5
+
6
+ import { useOnSuccessTransition } from "../../hooks/use-success-transition"
7
+ import { AuthUIContext } from "../../lib/auth-ui-provider"
8
+
9
+ export function SignOut({ redirectTo }: { redirectTo?: string }) {
10
+ const signingOut = useRef(false)
11
+
12
+ const { authClient, basePath, viewPaths } = useContext(AuthUIContext)
13
+ const { onSuccess } = useOnSuccessTransition({
14
+ redirectTo: redirectTo || `${basePath}/${viewPaths.SIGN_IN}`
15
+ })
16
+
17
+ useEffect(() => {
18
+ if (signingOut.current) return
19
+ signingOut.current = true
20
+
21
+ authClient.signOut().finally(onSuccess)
22
+ }, [authClient, onSuccess])
23
+
24
+ return <Loader2 className="animate-spin" />
25
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Wallet Button - Sign-In with Ethereum (SIWE)
3
+ *
4
+ * Flow: Connect wallet → Get nonce → Sign message → Verify → Success
5
+ */
6
+ import { WalletIcon } from 'lucide-react';
7
+ import { useContext, useState, useEffect, useCallback } from 'react';
8
+
9
+ import { useOnSuccessTransition } from '../../hooks/use-success-transition';
10
+ import { AuthUIContext } from '../../lib/auth-ui-provider';
11
+ import { cn, getLocalizedError } from '../../lib/utils';
12
+ import type { AuthLocalization } from '../../localization/auth-localization';
13
+ import { Button } from '../ui/button';
14
+ import type { AuthViewClassNames } from './auth-view';
15
+ import {
16
+ hasInjectedWallet,
17
+ connectWallet,
18
+ signMessage,
19
+ buildSiweMessage,
20
+ } from '../../lib/wallet';
21
+
22
+ interface WalletButtonProps {
23
+ classNames?: AuthViewClassNames;
24
+ isSubmitting?: boolean;
25
+ localization?: Partial<AuthLocalization>;
26
+ redirectTo?: string;
27
+ setIsSubmitting?: (isSubmitting: boolean) => void;
28
+ }
29
+
30
+ export function WalletButton({
31
+ classNames,
32
+ isSubmitting,
33
+ localization,
34
+ redirectTo,
35
+ setIsSubmitting,
36
+ }: WalletButtonProps) {
37
+ const {
38
+ authClient,
39
+ localization: contextLocalization,
40
+ toast,
41
+ localizeErrors,
42
+ } = useContext(AuthUIContext);
43
+
44
+ localization = { ...contextLocalization, ...localization };
45
+
46
+ const { onSuccess } = useOnSuccessTransition({ redirectTo });
47
+
48
+ // Track mounted state to avoid hydration mismatch
49
+ const [mounted, setMounted] = useState(false);
50
+ const [walletAddress, setWalletAddress] = useState<string | null>(null);
51
+ const [chainId, setChainId] = useState<number | null>(null);
52
+
53
+ useEffect(() => {
54
+ setMounted(true);
55
+ }, []);
56
+
57
+ const handleWalletAuth = useCallback(async () => {
58
+ setIsSubmitting?.(true);
59
+
60
+ try {
61
+ // Step 1: Connect wallet if not connected
62
+ let address = walletAddress;
63
+ let chain = chainId;
64
+
65
+ if (!address || !chain) {
66
+ const connection = await connectWallet();
67
+ address = connection.address;
68
+ chain = connection.chainId;
69
+ setWalletAddress(address);
70
+ setChainId(chain);
71
+ }
72
+
73
+ // Step 2: Get nonce from server
74
+ const nonceResult = await (authClient as any).siwe.nonce({
75
+ walletAddress: address,
76
+ chainId: chain,
77
+ });
78
+
79
+ if (nonceResult.error) {
80
+ throw new Error(nonceResult.error.message || 'Failed to get nonce');
81
+ }
82
+
83
+ const nonce = nonceResult.data?.nonce;
84
+ if (!nonce) {
85
+ throw new Error('No nonce received from server');
86
+ }
87
+
88
+ // Step 3: Build SIWE message
89
+ const message = buildSiweMessage({
90
+ domain: window.location.host,
91
+ address: address,
92
+ statement: 'Sign in with Ethereum',
93
+ uri: window.location.origin,
94
+ version: '1',
95
+ chainId: chain,
96
+ nonce,
97
+ });
98
+
99
+ // Step 4: Sign with wallet
100
+ const signature = await signMessage(message, address);
101
+
102
+ // Step 5: Verify with server
103
+ const verifyResult = await (authClient as any).siwe.verify({
104
+ message,
105
+ signature,
106
+ walletAddress: address,
107
+ chainId: chain,
108
+ });
109
+
110
+ if (verifyResult.error) {
111
+ throw new Error(verifyResult.error.message || 'Verification failed');
112
+ }
113
+
114
+ // Success!
115
+ onSuccess();
116
+ } catch (error) {
117
+ toast({
118
+ variant: 'error',
119
+ message: getLocalizedError({
120
+ error,
121
+ localization,
122
+ localizeErrors,
123
+ }),
124
+ });
125
+ setIsSubmitting?.(false);
126
+ }
127
+ }, [
128
+ authClient,
129
+ walletAddress,
130
+ chainId,
131
+ localization,
132
+ localizeErrors,
133
+ onSuccess,
134
+ setIsSubmitting,
135
+ toast,
136
+ ]);
137
+
138
+ // Don't render during SSR to avoid hydration mismatch
139
+ if (!mounted) {
140
+ return (
141
+ <Button
142
+ className={cn(
143
+ 'w-full',
144
+ classNames?.form?.button,
145
+ classNames?.form?.secondaryButton
146
+ )}
147
+ disabled
148
+ variant="secondary"
149
+ >
150
+ <WalletIcon className="h-4 w-4" />
151
+ Connect Wallet
152
+ </Button>
153
+ );
154
+ }
155
+
156
+ // No wallet available
157
+ if (!hasInjectedWallet()) {
158
+ return (
159
+ <Button
160
+ className={cn(
161
+ 'w-full',
162
+ classNames?.form?.button,
163
+ classNames?.form?.secondaryButton
164
+ )}
165
+ disabled
166
+ variant="secondary"
167
+ title="Install MetaMask or Phantom to use wallet sign-in"
168
+ >
169
+ <WalletIcon className="h-4 w-4" />
170
+ No Wallet Found
171
+ </Button>
172
+ );
173
+ }
174
+
175
+ return (
176
+ <Button
177
+ className={cn(
178
+ 'w-full',
179
+ classNames?.form?.button,
180
+ classNames?.form?.secondaryButton
181
+ )}
182
+ disabled={isSubmitting}
183
+ variant="secondary"
184
+ onClick={handleWalletAuth}
185
+ >
186
+ <WalletIcon className="h-4 w-4" />
187
+ {walletAddress
188
+ ? `Sign in with ${walletAddress.slice(0, 6)}...${walletAddress.slice(-4)}`
189
+ : 'Sign in with Wallet'}
190
+ </Button>
191
+ );
192
+ }
@@ -0,0 +1,21 @@
1
+ "use client"
2
+
3
+ import { type ReactNode, useContext } from "react"
4
+ import { AuthUIContext } from "../lib/auth-ui-provider"
5
+
6
+ /**
7
+ * Conditionally renders content during authentication loading state
8
+ *
9
+ * Renders its children only when the authentication state is being determined
10
+ * (during the loading/pending phase). Once the authentication state is resolved,
11
+ * nothing is rendered. Useful for displaying loading indicators or temporary
12
+ * content while waiting for the authentication check to complete.
13
+ */
14
+ export function AuthLoading({ children }: { children: ReactNode }) {
15
+ const {
16
+ hooks: { useSession }
17
+ } = useContext(AuthUIContext)
18
+ const { isPending } = useSession()
19
+
20
+ return isPending ? children : null
21
+ }
@@ -0,0 +1,91 @@
1
+ import { CaptchaFox } from "@captchafox/react"
2
+ import HCaptcha from "@hcaptcha/react-hcaptcha"
3
+ import { Turnstile } from "@marsidev/react-turnstile"
4
+ import { type RefObject, useContext } from "react"
5
+
6
+ import { useTheme } from "../../hooks/use-theme"
7
+ import { AuthUIContext } from "../../lib/auth-ui-provider"
8
+ import type { AuthLocalization } from "../../localization/auth-localization"
9
+ import { RecaptchaBadge } from "./recaptcha-badge"
10
+ import { RecaptchaV2 } from "./recaptcha-v2"
11
+
12
+ // Default captcha endpoints
13
+ const DEFAULT_CAPTCHA_ENDPOINTS = [
14
+ "/sign-up/email",
15
+ "/sign-in/email",
16
+ "/forget-password"
17
+ ]
18
+
19
+ interface CaptchaProps {
20
+ // biome-ignore lint/suspicious/noExplicitAny: ignore
21
+ ref: RefObject<any>
22
+ localization: Partial<AuthLocalization>
23
+ action?: string // Optional action to check if it's in the endpoints list
24
+ }
25
+
26
+ export function Captcha({ ref, localization, action }: CaptchaProps) {
27
+ const { captcha } = useContext(AuthUIContext)
28
+ if (!captcha) return null
29
+
30
+ // If action is provided, check if it's in the list of captcha-enabled endpoints
31
+ if (action) {
32
+ const endpoints = captcha.endpoints || DEFAULT_CAPTCHA_ENDPOINTS
33
+ if (!endpoints.includes(action)) {
34
+ return null
35
+ }
36
+ }
37
+
38
+ const { theme } = useTheme()
39
+
40
+ const showRecaptchaV2 =
41
+ captcha.provider === "google-recaptcha-v2-checkbox" ||
42
+ captcha.provider === "google-recaptcha-v2-invisible"
43
+
44
+ const showRecaptchaBadge =
45
+ captcha.provider === "google-recaptcha-v3" ||
46
+ captcha.provider === "google-recaptcha-v2-invisible"
47
+
48
+ const showTurnstile = captcha.provider === "cloudflare-turnstile"
49
+
50
+ const showHCaptcha = captcha.provider === "hcaptcha"
51
+
52
+ const showCaptchaFox = captcha.provider === "captchafox"
53
+
54
+ return (
55
+ <>
56
+ {showRecaptchaV2 && <RecaptchaV2 ref={ref} />}
57
+ {showRecaptchaBadge && (
58
+ <RecaptchaBadge localization={localization} />
59
+ )}
60
+ {showTurnstile && (
61
+ <Turnstile
62
+ className="mx-auto"
63
+ ref={ref}
64
+ siteKey={captcha.siteKey}
65
+ options={{
66
+ theme: theme,
67
+ size: "flexible"
68
+ }}
69
+ />
70
+ )}
71
+ {showHCaptcha && (
72
+ <div className="mx-auto">
73
+ <HCaptcha
74
+ ref={ref}
75
+ sitekey={captcha.siteKey}
76
+ theme={theme}
77
+ />
78
+ </div>
79
+ )}
80
+ {showCaptchaFox && (
81
+ <div className="mx-auto">
82
+ <CaptchaFox
83
+ ref={ref}
84
+ sitekey={captcha.siteKey}
85
+ theme={theme}
86
+ />
87
+ </div>
88
+ )}
89
+ </>
90
+ )
91
+ }
@@ -0,0 +1,61 @@
1
+ import { useContext } from "react"
2
+ import { useIsHydrated } from "../../hooks/use-hydrated"
3
+ import { AuthUIContext } from "../../lib/auth-ui-provider"
4
+ import { cn } from "../../lib/utils"
5
+ import type { AuthLocalization } from "../../localization/auth-localization"
6
+
7
+ export interface RecaptchaBadgeProps {
8
+ className?: string
9
+ localization?: Partial<AuthLocalization>
10
+ }
11
+
12
+ export function RecaptchaBadge({
13
+ className,
14
+ localization: propLocalization
15
+ }: RecaptchaBadgeProps) {
16
+ const isHydrated = useIsHydrated()
17
+ const { captcha, localization: contextLocalization } =
18
+ useContext(AuthUIContext)
19
+ const localization = { ...contextLocalization, ...propLocalization }
20
+
21
+ if (!captcha) return null
22
+
23
+ if (!captcha.hideBadge) {
24
+ return isHydrated ? (
25
+ <style>{`
26
+ .grecaptcha-badge { visibility: visible !important; }
27
+ `}</style>
28
+ ) : null
29
+ }
30
+
31
+ return (
32
+ <>
33
+ <style>{`
34
+ .grecaptcha-badge { visibility: hidden; }
35
+ `}</style>
36
+
37
+ <p className={cn("text-muted-foreground text-xs", className)}>
38
+ {localization.PROTECTED_BY_RECAPTCHA}{" "}
39
+ {localization.BY_CONTINUING_YOU_AGREE} Google{" "}
40
+ <a
41
+ className="text-foreground hover:underline"
42
+ href="https://policies.google.com/privacy"
43
+ target="_blank"
44
+ rel="noreferrer"
45
+ >
46
+ {localization.PRIVACY_POLICY}
47
+ </a>{" "}
48
+ &{" "}
49
+ <a
50
+ className="text-foreground hover:underline"
51
+ href="https://policies.google.com/terms"
52
+ target="_blank"
53
+ rel="noreferrer"
54
+ >
55
+ {localization.TERMS_OF_SERVICE}
56
+ </a>
57
+ .
58
+ </p>
59
+ </>
60
+ )
61
+ }
@@ -0,0 +1,58 @@
1
+ import { type RefObject, useContext, useEffect } from "react"
2
+ import ReCAPTCHA from "react-google-recaptcha"
3
+ import { useLang } from "../../hooks/use-lang"
4
+ import { useTheme } from "../../hooks/use-theme"
5
+ import { AuthUIContext } from "../../lib/auth-ui-provider"
6
+ import { cn } from "../../lib/utils"
7
+
8
+ export function RecaptchaV2({ ref }: { ref: RefObject<ReCAPTCHA | null> }) {
9
+ const { captcha } = useContext(AuthUIContext)
10
+ const { theme } = useTheme()
11
+ const { lang } = useLang()
12
+
13
+ useEffect(() => {
14
+ // biome-ignore lint/suspicious/noExplicitAny: ignore
15
+ ;(window as any).recaptchaOptions = {
16
+ useRecaptchaNet: captcha?.recaptchaNet,
17
+ enterprise: captcha?.enterprise
18
+ }
19
+ }, [captcha])
20
+
21
+ if (!captcha) return null
22
+
23
+ return (
24
+ <>
25
+ <style>{`
26
+ .grecaptcha-badge {
27
+ border-radius: var(--radius) !important;
28
+ --tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, #0000000d);
29
+ box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow) !important;
30
+ border-style: var(--tw-border-style) !important;
31
+ border-width: 1px;
32
+ }
33
+
34
+ .dark .grecaptcha-badge {
35
+ border-color: var(--input) !important;
36
+ }
37
+ `}</style>
38
+
39
+ <ReCAPTCHA
40
+ ref={ref}
41
+ key={`${theme}-${lang}-${captcha.provider}`}
42
+ sitekey={captcha.siteKey}
43
+ theme={theme}
44
+ hl={lang}
45
+ size={
46
+ captcha.provider === "google-recaptcha-v2-invisible"
47
+ ? "invisible"
48
+ : "normal"
49
+ }
50
+ className={cn(
51
+ captcha.provider === "google-recaptcha-v2-invisible"
52
+ ? "absolute"
53
+ : "mx-auto h-[76px] w-[302px] overflow-hidden rounded bg-muted"
54
+ )}
55
+ />
56
+ </>
57
+ )
58
+ }
@@ -0,0 +1,73 @@
1
+ import {
2
+ GoogleReCaptchaProvider,
3
+ useGoogleReCaptcha
4
+ } from "@wojtekmaj/react-recaptcha-v3"
5
+ import { type ReactNode, useContext, useEffect } from "react"
6
+
7
+ import { useIsHydrated } from "../../hooks/use-hydrated"
8
+ import { useLang } from "../../hooks/use-lang"
9
+ import { useTheme } from "../../hooks/use-theme"
10
+ import { AuthUIContext } from "../../lib/auth-ui-provider"
11
+
12
+ export function RecaptchaV3({ children }: { children: ReactNode }) {
13
+ const isHydrated = useIsHydrated()
14
+ const { captcha } = useContext(AuthUIContext)
15
+
16
+ if (captcha?.provider !== "google-recaptcha-v3") return children
17
+
18
+ return (
19
+ <GoogleReCaptchaProvider
20
+ reCaptchaKey={captcha.siteKey}
21
+ useEnterprise={captcha.enterprise}
22
+ useRecaptchaNet={captcha.recaptchaNet}
23
+ >
24
+ {isHydrated && (
25
+ <style>{`
26
+ .grecaptcha-badge {
27
+ visibility: hidden;
28
+ border-radius: var(--radius) !important;
29
+ --tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, #0000000d);
30
+ box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow) !important;
31
+ border-style: var(--tw-border-style) !important;
32
+ border-width: 1px;
33
+ }
34
+
35
+ .dark .grecaptcha-badge {
36
+ border-color: var(--input) !important;
37
+ }
38
+ `}</style>
39
+ )}
40
+
41
+ <RecaptchaV3Style />
42
+
43
+ {children}
44
+ </GoogleReCaptchaProvider>
45
+ )
46
+ }
47
+
48
+ function RecaptchaV3Style() {
49
+ const { executeRecaptcha } = useGoogleReCaptcha()
50
+ const { theme } = useTheme()
51
+ const { lang } = useLang()
52
+
53
+ useEffect(() => {
54
+ if (!executeRecaptcha) return
55
+
56
+ const updateRecaptcha = async () => {
57
+ // find iframe with title "reCAPTCHA"
58
+ const iframe = document.querySelector(
59
+ "iframe[title='reCAPTCHA']"
60
+ ) as HTMLIFrameElement
61
+ if (iframe) {
62
+ const iframeSrcUrl = new URL(iframe.src)
63
+ iframeSrcUrl.searchParams.set("theme", theme)
64
+ if (lang) iframeSrcUrl.searchParams.set("hl", lang)
65
+ iframe.src = iframeSrcUrl.toString()
66
+ }
67
+ }
68
+
69
+ updateRecaptcha()
70
+ }, [executeRecaptcha, theme, lang])
71
+
72
+ return null
73
+ }