@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.
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -1
- package/src/__tests__/auth-client.test.ts +105 -0
- package/src/__tests__/security/localStorage-encryption.test.ts +171 -0
- package/src/auth-client.ts +158 -0
- package/src/dashboard-client.ts +60 -0
- package/src/index.ts +88 -0
- package/src/kv-client.ts +316 -0
- package/src/lib/cross-origin-auth.ts +99 -0
- package/src/stubs/captcha.ts +24 -0
- package/src/stubs/hashes.ts +16 -0
- package/src/stubs/index.ts +17 -0
- package/src/stubs/passkey.ts +12 -0
- package/src/stubs/qr-code.ts +10 -0
- package/src/stubs/query.ts +16 -0
- package/src/stubs/realtime.ts +17 -0
- package/src/stubs/use-sync-external-store.ts +12 -0
- package/src/styles.css +141 -0
- package/src/types.ts +14 -0
- package/src/ui/components/auth/auth-callback.tsx +36 -0
- package/src/ui/components/auth/auth-form.tsx +310 -0
- package/src/ui/components/auth/auth-view.tsx +435 -0
- package/src/ui/components/auth/email-otp-button.tsx +53 -0
- package/src/ui/components/auth/forms/email-otp-form.tsx +312 -0
- package/src/ui/components/auth/forms/email-verification-form.tsx +271 -0
- package/src/ui/components/auth/forms/forgot-password-form.tsx +173 -0
- package/src/ui/components/auth/forms/magic-link-form.tsx +196 -0
- package/src/ui/components/auth/forms/recover-account-form.tsx +143 -0
- package/src/ui/components/auth/forms/reset-password-form.tsx +220 -0
- package/src/ui/components/auth/forms/sign-in-form.tsx +323 -0
- package/src/ui/components/auth/forms/sign-up-form.tsx +820 -0
- package/src/ui/components/auth/forms/two-factor-form.tsx +381 -0
- package/src/ui/components/auth/magic-link-button.tsx +54 -0
- package/src/ui/components/auth/one-tap.tsx +53 -0
- package/src/ui/components/auth/otp-input-group.tsx +65 -0
- package/src/ui/components/auth/passkey-button.tsx +91 -0
- package/src/ui/components/auth/provider-button.tsx +155 -0
- package/src/ui/components/auth/sign-out.tsx +25 -0
- package/src/ui/components/auth/wallet-button.tsx +192 -0
- package/src/ui/components/auth-loading.tsx +21 -0
- package/src/ui/components/captcha/captcha.tsx +91 -0
- package/src/ui/components/captcha/recaptcha-badge.tsx +61 -0
- package/src/ui/components/captcha/recaptcha-v2.tsx +58 -0
- package/src/ui/components/captcha/recaptcha-v3.tsx +73 -0
- package/src/ui/components/email/email-template.tsx +216 -0
- package/src/ui/components/form-error.tsx +27 -0
- package/src/ui/components/password-input.tsx +56 -0
- package/src/ui/components/provider-icons.tsx +404 -0
- package/src/ui/components/redirect-to-sign-in.tsx +16 -0
- package/src/ui/components/redirect-to-sign-up.tsx +16 -0
- package/src/ui/components/signed-in.tsx +20 -0
- package/src/ui/components/signed-out.tsx +20 -0
- package/src/ui/components/ui/alert.tsx +66 -0
- package/src/ui/components/ui/button.tsx +70 -0
- package/src/ui/components/ui/card.tsx +92 -0
- package/src/ui/components/ui/checkbox.tsx +66 -0
- package/src/ui/components/ui/field.tsx +248 -0
- package/src/ui/components/ui/form.tsx +165 -0
- package/src/ui/components/ui/input-otp.tsx +77 -0
- package/src/ui/components/ui/input.tsx +21 -0
- package/src/ui/components/ui/label.tsx +23 -0
- package/src/ui/components/ui/separator.tsx +34 -0
- package/src/ui/components/ui/skeleton.tsx +13 -0
- package/src/ui/components/ui/textarea.tsx +18 -0
- package/src/ui/components/user-avatar.tsx +151 -0
- package/src/ui/hooks/use-auth-data.ts +193 -0
- package/src/ui/hooks/use-authenticate.ts +64 -0
- package/src/ui/hooks/use-captcha.tsx +151 -0
- package/src/ui/hooks/use-hydrated.ts +13 -0
- package/src/ui/hooks/use-lang.ts +32 -0
- package/src/ui/hooks/use-success-transition.ts +41 -0
- package/src/ui/hooks/use-theme.ts +39 -0
- package/src/ui/index.ts +46 -0
- package/src/ui/instantdb.ts +1 -0
- package/src/ui/lib/auth-data-cache.ts +90 -0
- package/src/ui/lib/auth-ui-provider.tsx +769 -0
- package/src/ui/lib/gravatar-utils.ts +58 -0
- package/src/ui/lib/image-utils.ts +55 -0
- package/src/ui/lib/instantdb/model-names.ts +24 -0
- package/src/ui/lib/instantdb/use-instant-options.ts +98 -0
- package/src/ui/lib/instantdb/use-list-accounts.ts +38 -0
- package/src/ui/lib/instantdb/use-list-sessions.ts +53 -0
- package/src/ui/lib/instantdb/use-session.ts +55 -0
- package/src/ui/lib/social-providers.ts +150 -0
- package/src/ui/lib/tanstack/auth-ui-provider-tanstack.tsx +49 -0
- package/src/ui/lib/tanstack/use-tanstack-options.ts +112 -0
- package/src/ui/lib/triplit/model-names.ts +24 -0
- package/src/ui/lib/triplit/use-conditional-query.ts +82 -0
- package/src/ui/lib/triplit/use-list-accounts.ts +31 -0
- package/src/ui/lib/triplit/use-list-sessions.ts +33 -0
- package/src/ui/lib/triplit/use-session.ts +42 -0
- package/src/ui/lib/triplit/use-triplit-hooks.ts +68 -0
- package/src/ui/lib/triplit/use-triplit-token.ts +44 -0
- package/src/ui/lib/utils.ts +119 -0
- package/src/ui/lib/view-paths.ts +61 -0
- package/src/ui/lib/wallet.ts +129 -0
- package/src/ui/localization/admin-error-codes.ts +20 -0
- package/src/ui/localization/anonymous-error-codes.ts +6 -0
- package/src/ui/localization/api-key-error-codes.ts +32 -0
- package/src/ui/localization/auth-localization.ts +865 -0
- package/src/ui/localization/base-error-codes.ts +27 -0
- package/src/ui/localization/captcha-error-codes.ts +17 -0
- package/src/ui/localization/email-otp-error-codes.ts +7 -0
- package/src/ui/localization/generic-oauth-error-codes.ts +3 -0
- package/src/ui/localization/haveibeenpwned-error-codes.ts +4 -0
- package/src/ui/localization/multi-session-error-codes.ts +3 -0
- package/src/ui/localization/organization-error-codes.ts +57 -0
- package/src/ui/localization/passkey-error-codes.ts +10 -0
- package/src/ui/localization/phone-number-error-codes.ts +10 -0
- package/src/ui/localization/stripe-localization.ts +12 -0
- package/src/ui/localization/team-error-codes.ts +12 -0
- package/src/ui/localization/two-factor-error-codes.ts +12 -0
- package/src/ui/localization/username-error-codes.ts +9 -0
- package/src/ui/server.ts +4 -0
- package/src/ui/style.css +146 -0
- package/src/ui/tanstack.ts +1 -0
- package/src/ui/triplit.ts +1 -0
- package/src/ui/types/account-options.ts +35 -0
- package/src/ui/types/additional-fields.ts +21 -0
- package/src/ui/types/any-auth-client.ts +6 -0
- package/src/ui/types/api-key.ts +9 -0
- package/src/ui/types/auth-client.ts +41 -0
- package/src/ui/types/auth-hooks.ts +81 -0
- package/src/ui/types/auth-mutators.ts +21 -0
- package/src/ui/types/avatar-options.ts +29 -0
- package/src/ui/types/captcha-options.ts +32 -0
- package/src/ui/types/captcha-provider.ts +7 -0
- package/src/ui/types/credentials-options.ts +38 -0
- package/src/ui/types/delete-user-options.ts +7 -0
- package/src/ui/types/email-verification-options.ts +7 -0
- package/src/ui/types/fetch-error.ts +6 -0
- package/src/ui/types/generic-oauth-options.ts +16 -0
- package/src/ui/types/gravatar-options.ts +21 -0
- package/src/ui/types/image.ts +7 -0
- package/src/ui/types/invitation.ts +10 -0
- package/src/ui/types/link.ts +7 -0
- package/src/ui/types/organization-options.ts +106 -0
- package/src/ui/types/password-validation.ts +16 -0
- package/src/ui/types/profile.ts +15 -0
- package/src/ui/types/refetch.ts +1 -0
- package/src/ui/types/render-toast.ts +9 -0
- package/src/ui/types/sign-up-options.ts +7 -0
- package/src/ui/types/social-options.ts +16 -0
- 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,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
|
+
}
|