Skip to content

Commit 4524c70

Browse files
authored
FIx errors when turnstile token expiring near checkout button (#304)
1 parent 47be1d1 commit 4524c70

File tree

2 files changed

+99
-23
lines changed

2 files changed

+99
-23
lines changed

src/components/TurnstileWidget.tsx

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { useCallback, useRef, useState } from 'react';
2+
import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile';
3+
4+
const TOKEN_LIFETIME_MS = 300_000; // 300s per Cloudflare docs
5+
const REFRESH_BUFFER_MS = 5_000;
6+
7+
export function useTurnstile() {
8+
const [token, setToken] = useState<string>();
9+
const tokenObtainedAt = useRef<number>(0);
10+
const ref = useRef<TurnstileInstance>(null);
11+
12+
const onSuccess = useCallback((t: string) => {
13+
setToken(t);
14+
tokenObtainedAt.current = Date.now();
15+
}, []);
16+
17+
const resetToken = useCallback(() => {
18+
setToken(undefined);
19+
tokenObtainedAt.current = 0;
20+
ref.current?.reset();
21+
}, []);
22+
23+
const ensureFreshToken = useCallback(async (): Promise<
24+
string | undefined
25+
> => {
26+
const age = Date.now() - tokenObtainedAt.current;
27+
const isStale =
28+
!ref.current?.getResponse() ||
29+
ref.current?.isExpired() ||
30+
age >= TOKEN_LIFETIME_MS - REFRESH_BUFFER_MS;
31+
32+
if (isStale) {
33+
setToken(undefined);
34+
tokenObtainedAt.current = 0;
35+
ref.current?.reset();
36+
try {
37+
return await ref.current?.getResponsePromise(60_000);
38+
} catch {
39+
return undefined;
40+
}
41+
}
42+
43+
return ref.current?.getResponse();
44+
}, []);
45+
46+
return {
47+
token,
48+
ref,
49+
onSuccess,
50+
onExpire: resetToken,
51+
onError: resetToken,
52+
ensureFreshToken,
53+
resetToken,
54+
};
55+
}
56+
57+
export type UseTurnstileReturn = ReturnType<typeof useTurnstile>;
58+
59+
interface TurnstileWidgetProps {
60+
id: string;
61+
siteKey: string;
62+
turnstile: UseTurnstileReturn;
63+
className?: string;
64+
}
65+
66+
export default function TurnstileWidget({
67+
id,
68+
siteKey,
69+
turnstile,
70+
className,
71+
}: TurnstileWidgetProps) {
72+
return (
73+
<Turnstile
74+
ref={turnstile.ref}
75+
id={id}
76+
siteKey={siteKey}
77+
onSuccess={turnstile.onSuccess}
78+
onExpire={turnstile.onExpire}
79+
onError={turnstile.onError}
80+
options={{
81+
size: 'flexible',
82+
theme: 'light',
83+
}}
84+
className={className}
85+
/>
86+
);
87+
}

src/components/store/StoreItem.tsx

Lines changed: 12 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import ErrorPopup, { useErrorPopup } from '../ErrorPopup';
1515
import ReactNavbar from '../generic/ReactNavbar';
1616
import { LoadingSpinner } from '../generic/LargeLoadingSpinner';
1717
import AuthActionButton, { type ShowErrorFunction } from '../AuthActionButton';
18-
import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile';
18+
import TurnstileWidget, { useTurnstile } from '../TurnstileWidget';
1919
import type {
2020
IPublicClientApplication,
2121
AccountInfo,
@@ -77,7 +77,7 @@ const StoreItem = ({
7777
const [email, setEmail] = useState('');
7878
const [emailConfirm, setEmailConfirm] = useState('');
7979
const [isLoading, setIsLoading] = useState(false);
80-
const [turnstileToken, setTurnstileToken] = useState<string>();
80+
const turnstile = useTurnstile();
8181

8282
// MSAL state
8383
const [pca, setPca] = useState<IPublicClientApplication>();
@@ -90,8 +90,6 @@ const StoreItem = ({
9090
const [membershipPreloaded, setMembershipPreloaded] = useState(false);
9191
const activeMembershipKeyRef = useRef<string | null>(null);
9292
const membershipCache = useRef<Map<string, boolean>>(new Map());
93-
94-
const turnstileRef = useRef<TurnstileInstance>(null);
9593
const { error, showError, clearError } = useErrorPopup();
9694

9795
const turnstileSiteKey = import.meta.env.PUBLIC_TURNSTILE_SITE_KEY!;
@@ -442,7 +440,8 @@ const StoreItem = ({
442440
accessToken: string,
443441
showError: ShowErrorFunction
444442
) => {
445-
if (!turnstileToken) {
443+
const freshToken = await turnstile.ensureFreshToken();
444+
if (!freshToken) {
446445
showError(400, 'Please complete the security verification.');
447446
return;
448447
}
@@ -461,7 +460,7 @@ const StoreItem = ({
461460
const syncPromise = syncIfRequired();
462461

463462
const checkoutResponse = await storeApiClient.apiV1StoreCheckoutPost({
464-
xTurnstileResponse: turnstileToken,
463+
xTurnstileResponse: freshToken,
465464
xUiucToken: accessToken,
466465
apiV1StoreCheckoutPostRequest: {
467466
items: [
@@ -478,6 +477,7 @@ const StoreItem = ({
478477
await syncPromise;
479478
window.location.replace(checkoutResponse['checkoutUrl']);
480479
} catch (e) {
480+
turnstile.resetToken();
481481
if (e instanceof ResponseError) {
482482
const response = await e.response.json();
483483
showError(
@@ -498,15 +498,16 @@ const StoreItem = ({
498498
const handleGuestCheckout = async () => {
499499
setIsLoading(true);
500500

501-
if (!turnstileToken) {
501+
const freshToken = await turnstile.ensureFreshToken();
502+
if (!freshToken) {
502503
showError(400, 'Please complete the security verification.');
503504
setIsLoading(false);
504505
return;
505506
}
506507

507508
try {
508509
const checkoutResponse = await storeApiClient.apiV1StoreCheckoutPost({
509-
xTurnstileResponse: turnstileToken,
510+
xTurnstileResponse: freshToken,
510511
apiV1StoreCheckoutPostRequest: {
511512
items: [
512513
{
@@ -522,6 +523,7 @@ const StoreItem = ({
522523
});
523524
window.location.replace(checkoutResponse['checkoutUrl']);
524525
} catch (e) {
526+
turnstile.resetToken();
525527
if (e instanceof ResponseError) {
526528
const response = await e.response.json();
527529
showError(
@@ -942,23 +944,10 @@ const StoreItem = ({
942944
)}
943945

944946
<div className="w-full">
945-
<Turnstile
946-
ref={turnstileRef}
947+
<TurnstileWidget
947948
id={id}
948949
siteKey={turnstileSiteKey}
949-
onSuccess={setTurnstileToken}
950-
onExpire={() => {
951-
setTurnstileToken(undefined);
952-
turnstileRef.current?.reset();
953-
}}
954-
onError={() => {
955-
setTurnstileToken(undefined);
956-
turnstileRef.current?.reset();
957-
}}
958-
options={{
959-
size: 'flexible',
960-
theme: 'light',
961-
}}
950+
turnstile={turnstile}
962951
className="w-full"
963952
/>
964953
</div>

0 commit comments

Comments
 (0)