Skip to content

Commit bfbce31

Browse files
committed
refactor: update turnstile flow, show all verification states and improve ux
1 parent 935f4ce commit bfbce31

5 files changed

Lines changed: 204 additions & 211 deletions

File tree

packages/webapp/.env.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ NEXT_PUBLIC_SPACES_SECRET=
1818

1919
# Turnstile
2020
NEXT_PUBLIC_TURNSTILE_SITE_KEY=
21-
TURNSTILE_SECRET_KEY=
21+
NEXT_PRIVATE_TURNSTILE_SECRET_KEY=
2222

2323
# Google One Tap
2424
NEXT_PUBLIC_GOOGLE_CLIENT_ID=

packages/webapp/components/TurnstilePage.tsx

Lines changed: 0 additions & 104 deletions
This file was deleted.

packages/webapp/components/skeleton/SlugPageLoaderWithTurnstile.tsx

Lines changed: 153 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,109 +1,195 @@
11
import { useStore } from '@stores'
22
import { SiCloudflare } from 'react-icons/si'
33
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'
65
import Config from '@config'
6+
import { SlugPageLoader } from './SlugPageLoader'
7+
78
type Props = {
89
showTurnstile: boolean
910
}
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
1121

1222
const TurnstileModal = ({ showTurnstile }: Props) => {
23+
const [state, setState] = useState<TurnstileState>('loading')
1324
const [error, setError] = useState<string | null>(null)
25+
const [retryCount, setRetryCount] = useState(0)
1426
const ref = useRef<any>(null)
1527
const setWorkspaceSetting = useStore((state) => state.setWorkspaceSetting)
1628

17-
useEffect(() => {
18-
setWorkspaceSetting('isTurnstileVerified', !showTurnstile)
19-
}, [showTurnstile, setWorkspaceSetting])
29+
const handleLoad = useCallback(() => {
30+
setState('ready')
31+
setError(null)
32+
}, [])
2033

21-
const handleVerification = async (token: string | null) => {
22-
if (!token) return
34+
const handleBeforeInteractive = useCallback(() => {
35+
setState('solving')
36+
}, [])
2337

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')
3261
}
33-
)
62+
} catch (err) {
63+
let errorMessage = 'Verification failed. Please try again.'
3464

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+
}
3674

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)
4878
}
49-
setError('An error occurred during verification. Please try again.')
50-
}
51-
}
79+
},
80+
[state, setWorkspaceSetting]
81+
)
5282

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+
}, [])
5688

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+
}, [])
6093

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')
63101
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+
}
64126
}
65127

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>
87136
</div>
88-
)}
137+
</div>
138+
)
139+
}
89140

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">
90143
<Turnstile
144+
key={retryCount}
91145
ref={ref}
92146
siteKey={Config.app.turnstile.siteKey}
147+
onLoad={handleLoad}
148+
onBeforeInteractive={handleBeforeInteractive}
93149
onSuccess={handleVerification}
94150
onError={handleError}
95-
onExpire={handleError}
151+
onExpire={handleExpire}
152+
onUnsupported={handleUnsupported}
96153
/>
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+
)}
101183
</div>
102184
</div>
103185
)
104186
}
105187

106188
export const SlugPageLoaderWithTurnstile = ({ showTurnstile }: Props) => {
189+
if (!showTurnstile) {
190+
return <SlugPageLoader />
191+
}
192+
107193
return (
108194
<div className="h-full">
109195
<TurnstileModal showTurnstile={showTurnstile} />

packages/webapp/config/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ const AVATARS_BUCKET_URL = `${PUBLIC_BUCKET_URL}/user_avatars`
66
const config: Config = {
77
app: {
88
turnstile: {
9-
isEnabled: process.env.TURNSTILE_SECRET_KEY ? true : false,
9+
isEnabled: process.env.NEXT_PRIVATE_TURNSTILE_SECRET_KEY ? true : false,
1010
siteKey: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || '',
1111
verifyUrl: '/api/verify-turnstile',
1212
expireTime: 60 * 60 * 24 * 2 // 2 days

0 commit comments

Comments
 (0)