|
1 | 1 | import { useStore } from '@stores' |
2 | 2 | import { SiCloudflare } from 'react-icons/si' |
3 | 3 | import { Turnstile } from '@marsidev/react-turnstile' |
4 | | -import axios from 'axios' |
5 | | -import { useState, useRef, useEffect } from 'react' |
| 4 | +import { useState, useRef, useCallback } from 'react' |
6 | 5 | import Config from '@config' |
| 6 | +import { SlugPageLoader } from './SlugPageLoader' |
| 7 | + |
7 | 8 | type Props = { |
8 | 9 | showTurnstile: boolean |
9 | 10 | } |
10 | | -import { SlugPageLoader } from './SlugPageLoader' |
| 11 | + |
| 12 | +type TurnstileState = |
| 13 | + | 'loading' // Widget is loading |
| 14 | + | 'ready' // Widget ready for interaction |
| 15 | + | 'solving' // User is solving the challenge |
| 16 | + | 'verifying' // Verifying with our API |
| 17 | + | 'success' // Verification complete |
| 18 | + | 'error' // Error occurred |
| 19 | + | 'expired' // Token expired |
| 20 | + | 'unsupported' // Browser doesn't support Turnstile |
11 | 21 |
|
12 | 22 | const TurnstileModal = ({ showTurnstile }: Props) => { |
| 23 | + const [state, setState] = useState<TurnstileState>('loading') |
13 | 24 | const [error, setError] = useState<string | null>(null) |
| 25 | + const [retryCount, setRetryCount] = useState(0) |
14 | 26 | const ref = useRef<any>(null) |
15 | 27 | const setWorkspaceSetting = useStore((state) => state.setWorkspaceSetting) |
16 | 28 |
|
17 | | - useEffect(() => { |
18 | | - setWorkspaceSetting('isTurnstileVerified', !showTurnstile) |
19 | | - }, [showTurnstile, setWorkspaceSetting]) |
| 29 | + const handleLoad = useCallback(() => { |
| 30 | + setState('ready') |
| 31 | + setError(null) |
| 32 | + }, []) |
20 | 33 |
|
21 | | - const handleVerification = async (token: string | null) => { |
22 | | - if (!token) return |
| 34 | + const handleBeforeInteractive = useCallback(() => { |
| 35 | + setState('solving') |
| 36 | + }, []) |
23 | 37 |
|
24 | | - try { |
25 | | - const response = await axios.post( |
26 | | - Config.app.turnstile.verifyUrl, |
27 | | - { token }, |
28 | | - { |
29 | | - headers: { |
30 | | - 'Content-Type': 'application/json' |
31 | | - } |
| 38 | + const handleVerification = useCallback( |
| 39 | + async (token: string | null) => { |
| 40 | + if (!token || state === 'verifying') return |
| 41 | + |
| 42 | + setState('verifying') |
| 43 | + setError(null) |
| 44 | + |
| 45 | + try { |
| 46 | + const response = await fetch(Config.app.turnstile.verifyUrl, { |
| 47 | + method: 'POST', |
| 48 | + headers: { 'Content-Type': 'application/json' }, |
| 49 | + body: JSON.stringify({ token }), |
| 50 | + signal: AbortSignal.timeout(15000) |
| 51 | + }) |
| 52 | + |
| 53 | + const data = await response.json() |
| 54 | + |
| 55 | + if (data.success) { |
| 56 | + setState('success') |
| 57 | + setWorkspaceSetting('isTurnstileVerified', true) |
| 58 | + // No page reload needed! |
| 59 | + } else { |
| 60 | + throw new Error(data.message || 'Verification failed') |
32 | 61 | } |
33 | | - ) |
| 62 | + } catch (err) { |
| 63 | + let errorMessage = 'Verification failed. Please try again.' |
34 | 64 |
|
35 | | - const data = response.data |
| 65 | + if (err instanceof Error) { |
| 66 | + if (err.name === 'TimeoutError') { |
| 67 | + errorMessage = 'Verification timed out. Please try again.' |
| 68 | + } else if (err.message.includes('fetch')) { |
| 69 | + errorMessage = 'Network error. Please check your connection.' |
| 70 | + } else { |
| 71 | + errorMessage = err.message |
| 72 | + } |
| 73 | + } |
36 | 74 |
|
37 | | - if (data.success) { |
38 | | - setWorkspaceSetting('isTurnstileVerified', true) |
39 | | - window.location.reload() |
40 | | - } else { |
41 | | - setError('Verification failed. Please try again.') |
42 | | - } |
43 | | - } catch (err) { |
44 | | - if (axios.isAxiosError(err)) { |
45 | | - console.error('Verification error:', err.response?.data || err.message) |
46 | | - } else { |
47 | | - console.error('Verification error:', err) |
| 75 | + setState('error') |
| 76 | + setError(errorMessage) |
| 77 | + console.error('Turnstile verification error:', err) |
48 | 78 | } |
49 | | - setError('An error occurred during verification. Please try again.') |
50 | | - } |
51 | | - } |
| 79 | + }, |
| 80 | + [state, setWorkspaceSetting] |
| 81 | + ) |
52 | 82 |
|
53 | | - const handleError = (error: string) => { |
54 | | - setError(error) |
55 | | - } |
| 83 | + const handleError = useCallback((errorCode: string) => { |
| 84 | + console.error('Turnstile widget error:', errorCode) |
| 85 | + setState('error') |
| 86 | + setError('Captcha failed to load. Please refresh the page.') |
| 87 | + }, []) |
56 | 88 |
|
57 | | - if (!Config.app.turnstile.siteKey) { |
58 | | - return <p>Error: Turnstile site key is not set.</p> |
59 | | - } |
| 89 | + const handleExpire = useCallback(() => { |
| 90 | + setState('expired') |
| 91 | + setError('Verification expired. Please try again.') |
| 92 | + }, []) |
60 | 93 |
|
61 | | - const retryHandler = () => { |
62 | | - window.location.reload() |
| 94 | + const handleUnsupported = useCallback(() => { |
| 95 | + setState('unsupported') |
| 96 | + setError('Your browser does not support this security feature.') |
| 97 | + }, []) |
| 98 | + |
| 99 | + const handleRetry = useCallback(() => { |
| 100 | + setState('loading') |
63 | 101 | setError(null) |
| 102 | + setRetryCount((prev) => prev + 1) |
| 103 | + ref.current?.reset() |
| 104 | + }, []) |
| 105 | + |
| 106 | + // Get display text and loading state based on current state |
| 107 | + const getStateInfo = () => { |
| 108 | + switch (state) { |
| 109 | + case 'loading': |
| 110 | + return { text: 'Loading security check', showSpinner: true } |
| 111 | + case 'ready': |
| 112 | + return { text: 'Complete security check', showSpinner: false } |
| 113 | + case 'solving': |
| 114 | + return { text: 'Solving challenge', showSpinner: true } |
| 115 | + case 'verifying': |
| 116 | + return { text: 'Verifying', showSpinner: true } |
| 117 | + case 'success': |
| 118 | + return { text: 'Verified', showSpinner: false } |
| 119 | + case 'error': |
| 120 | + case 'expired': |
| 121 | + case 'unsupported': |
| 122 | + return { text: 'Security check failed', showSpinner: false } |
| 123 | + default: |
| 124 | + return { text: 'Security check', showSpinner: false } |
| 125 | + } |
64 | 126 | } |
65 | 127 |
|
66 | | - return ( |
67 | | - <div className="fixed inset-0 z-20 flex size-full h-dvh flex-col items-center justify-center"> |
68 | | - {error && ( |
69 | | - <div className="rounded-md p-6 py-2 drop-shadow-md backdrop-blur-md"> |
70 | | - <div> |
71 | | - <h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900"> |
72 | | - Cloudflare Verification Failed |
73 | | - </h2> |
74 | | - <div className="mt-4 rounded-md border border-red-200 bg-red-50 p-4"> |
75 | | - <div className="text-sm text-red-700"> |
76 | | - <p className="mt-1">Details: {error}</p> |
77 | | - </div> |
78 | | - </div> |
79 | | - <div className="mt-4 text-center"> |
80 | | - <button |
81 | | - onClick={retryHandler} |
82 | | - className="text-sm font-medium text-indigo-600 hover:text-indigo-500"> |
83 | | - Retry |
84 | | - </button> |
85 | | - </div> |
86 | | - </div> |
| 128 | + const { text, showSpinner } = getStateInfo() |
| 129 | + |
| 130 | + if (!Config.app.turnstile.siteKey) { |
| 131 | + return ( |
| 132 | + <div className="fixed inset-0 bottom-0 z-30 flex items-center justify-center bg-red-50/40 backdrop-blur-sm"> |
| 133 | + <div className="text-center"> |
| 134 | + <h2 className="text-xl font-semibold text-red-700">Configuration Error</h2> |
| 135 | + <p className="mt-2 text-red-600">Turnstile site key is not configured.</p> |
87 | 136 | </div> |
88 | | - )} |
| 137 | + </div> |
| 138 | + ) |
| 139 | + } |
89 | 140 |
|
| 141 | + return ( |
| 142 | + <div className="fixed bottom-4 left-0 z-10 flex w-auto items-center rounded-lg bg-gray-100 px-4 py-1 text-sm text-gray-700 md:left-4"> |
90 | 143 | <Turnstile |
| 144 | + key={retryCount} |
91 | 145 | ref={ref} |
92 | 146 | siteKey={Config.app.turnstile.siteKey} |
| 147 | + onLoad={handleLoad} |
| 148 | + onBeforeInteractive={handleBeforeInteractive} |
93 | 149 | onSuccess={handleVerification} |
94 | 150 | onError={handleError} |
95 | | - onExpire={handleError} |
| 151 | + onExpire={handleExpire} |
| 152 | + onUnsupported={handleUnsupported} |
96 | 153 | /> |
97 | | - <div className="fixed bottom-4 left-4 flex w-fit items-center rounded-full bg-gray-100 px-3 py-1 text-sm text-gray-700 md:right-4"> |
98 | | - <SiCloudflare size={20} className="mr-2 text-[#f38020]" /> |
99 | | - Security by Cloudflare |
100 | | - <span className="loading loading-dots loading-xs mt-2 ml-2"></span> |
| 154 | + |
| 155 | + <div className="flex w-full items-center justify-center gap-1"> |
| 156 | + <span className={`flex ${error && 'hidden md:flex'} justify-center gap-1`}> |
| 157 | + <SiCloudflare size={20} className="mr-2 text-[#f38020]" /> |
| 158 | + Security by Cloudflare |
| 159 | + </span> |
| 160 | + |
| 161 | + {/* Show current state */} |
| 162 | + {!error && ( |
| 163 | + <> |
| 164 | + <div className="divider divider-horizontal m-0 w-0 py-1"></div> |
| 165 | + <span className="text-xs font-normal text-gray-400">{text}</span> |
| 166 | + {showSpinner && <span className="loading loading-dots loading-xs"></span>} |
| 167 | + </> |
| 168 | + )} |
| 169 | + |
| 170 | + {/* Error state */} |
| 171 | + {error && ( |
| 172 | + <> |
| 173 | + <div className="divider divider-horizontal m-0 hidden w-0 py-1 md:flex"></div> |
| 174 | + <p className="text-sm text-red-700">{error}</p> |
| 175 | + <button |
| 176 | + onClick={handleRetry} |
| 177 | + disabled={state === 'verifying'} |
| 178 | + className={`btn btn-sm btn-ghost btn-error btn-dash h-5 md:h-auto`}> |
| 179 | + Try Again |
| 180 | + </button> |
| 181 | + </> |
| 182 | + )} |
101 | 183 | </div> |
102 | 184 | </div> |
103 | 185 | ) |
104 | 186 | } |
105 | 187 |
|
106 | 188 | export const SlugPageLoaderWithTurnstile = ({ showTurnstile }: Props) => { |
| 189 | + if (!showTurnstile) { |
| 190 | + return <SlugPageLoader /> |
| 191 | + } |
| 192 | + |
107 | 193 | return ( |
108 | 194 | <div className="h-full"> |
109 | 195 | <TurnstileModal showTurnstile={showTurnstile} /> |
|
0 commit comments