[CU-8689k2b8c] Migrates google recaptcha to cloudflare turnstile (#1353)

* Migrates google recaptcha to cloudflare turnstile

* Includes example env vars

* Fixes turnstile when purchasing buzz

* Applies code review feedback

* Removes console.log
This commit is contained in:
Manuel Emilio Urena
2024-09-05 16:29:58 -04:00
committed by GitHub
parent 8ac9ddf2bb
commit 4ea8bcd34a
17 changed files with 538 additions and 426 deletions
+5
View File
@@ -165,6 +165,11 @@ PADDLE_SECRET_KEY=
PADDLE_WEBHOOK_SECRET=
NEXT_PUBLIC_PADDLE_TOKEN=
NEXT_PUBLIC_DEFAULT_PAYMENT_PROVIDER= # Paddle OR Stripe. Defaults to Stripe if Paddle env. variables are missing.
# Fingerprint
FINGERPRINT_SECRET=61952c5f9c9f1938abcf288bff56021a927a0a829f2e839a7a9fe219c83dca0c # 32 bytes
FINGERPRINT_IV=b5f09724c7567e53d47d0a26bfa263e4 # 16 bytes
# CF Turnstile
NEXT_PUBLIC_CLOUDFLARE_TURNSTILE_SITEKEY=1x00000000000000000000BB
CLOUDFLARE_TURNSTILE_SECRET=1x0000000000000000000000000000000AA
+259 -304
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -73,6 +73,7 @@
"@mantine/rte": "^5.10.4",
"@mantine/spotlight": "^5.10.4",
"@mantine/tiptap": "^5.10.4",
"@marsidev/react-turnstile": "^1.0.1",
"@meilisearch/instant-meilisearch": "0.13.5",
"@microsoft/signalr": "^7.0.10",
"@next-auth/prisma-adapter": "^1.0.7",
@@ -149,7 +149,6 @@ export function OnboardingBuzz() {
Done
</Button>
</Group>
<RecaptchaNotice />
{showReferral && (
<Button
variant="subtle"
@@ -31,14 +31,17 @@ export function OnboardingProfile() {
const debouncer = useDebouncer(500);
const [username, setUsername] = useState('');
const [typing, setTyping] = useState(false);
const { data: usernameAvailable, isRefetching: usernameAvailableLoading } =
trpc.user.usernameAvailable.useQuery({ username }, { enabled: username.length >= 3 });
const {
data: usernameAvailable,
isRefetching: refetchingUsernameAvailable,
isInitialLoading: loadingUsarnameAvailable,
} = trpc.user.usernameAvailable.useQuery({ username }, { enabled: username.length >= 3 });
const form = useForm({
schema,
mode: 'onChange',
shouldUnregister: false,
defaultValues: { ...currentUser },
defaultValues: { email: currentUser?.email, username: currentUser?.username },
});
const handleSubmit = (data: z.infer<typeof schema>) => {
@@ -64,7 +67,8 @@ export function OnboardingProfile() {
const buttonDisabled =
!form.formState.isValid ||
typing ||
(form.formState.isDirty && (!usernameAvailable || usernameAvailableLoading));
(form.formState.isDirty &&
(!usernameAvailable || refetchingUsernameAvailable || loadingUsarnameAvailable));
return (
<Container size="xs" px={0}>
@@ -79,7 +83,7 @@ export function OnboardingProfile() {
label="Username"
clearable={false}
rightSection={
usernameAvailableLoading ? (
refetchingUsernameAvailable ? (
<Loader size="sm" mr="xs" />
) : (
usernameAvailable !== undefined && (
+45 -10
View File
@@ -5,6 +5,7 @@ import {
Loader,
ScrollArea,
Stack,
Text,
Title,
TypographyStylesProvider,
} from '@mantine/core';
@@ -19,25 +20,40 @@ import rehypeRaw from 'rehype-raw';
import { OnboardingSteps } from '~/server/common/enums';
import { trpc } from '~/utils/trpc';
import { showErrorNotification } from '~/utils/notifications';
import { RECAPTCHA_ACTIONS } from '~/server/common/constants';
import { useRecaptchaToken } from '~/components/Recaptcha/useReptchaToken';
import { useState } from 'react';
import {
CaptchaState,
TurnstilePrivacyNotice,
TurnstileWidget,
} from '~/components/TurnstileWidget/TurnstileWidget';
export function OnboardingTos() {
const [captchaState, setCaptchaState] = useState<CaptchaState>({
status: null,
token: null,
error: null,
});
const { next } = useOnboardingWizardContext();
const { mutate, isLoading } = useOnboardingStepCompleteMutation();
const { token: recaptchaToken, loading: isLoadingRecaptcha } = useRecaptchaToken(
RECAPTCHA_ACTIONS.COMPLETE_ONBOARDING
);
const handleStepComplete = () => {
if (!recaptchaToken)
if (captchaState.status !== 'success')
return showErrorNotification({
title: 'Cannot save',
error: new Error('Recaptcha token is missing'),
error: new Error(captchaState.error ?? 'Captcha token expired. Please try again.'),
});
mutate({ step: OnboardingSteps.TOS, recaptchaToken }, { onSuccess: () => next() });
if (!captchaState.token)
return showErrorNotification({
title: 'Cannot save',
error: new Error('Captcha token is missing'),
});
mutate(
{ step: OnboardingSteps.TOS, recaptchaToken: captchaState.token },
{ onSuccess: () => next() }
);
};
const { data: terms, isLoading: termsLoading } = trpc.content.get.useQuery({ slug: 'tos' });
@@ -81,12 +97,31 @@ export function OnboardingTos() {
size="lg"
onClick={handleStepComplete}
loading={isLoading}
disabled={isLoadingRecaptcha || !recaptchaToken}
disabled={captchaState.status !== 'success'}
>
Accept
</Button>
</Group>
)}
<TurnstilePrivacyNotice />
<TurnstileWidget
onSuccess={(token) => setCaptchaState({ status: 'success', token, error: null })}
onError={(error) =>
setCaptchaState({
status: 'error',
token: null,
error: `There was an error generating the captcha: ${error}`,
})
}
onExpire={(token) =>
setCaptchaState({ status: 'expired', token, error: 'Captcha token expired' })
}
/>
{captchaState.status === 'error' && (
<Text size="xs" color="red">
{captchaState.error}
</Text>
)}
</Stack>
);
}
+53 -12
View File
@@ -8,7 +8,6 @@ import {
ModalProps,
Divider,
Text,
Alert,
Group,
Paper,
useMantineTheme,
@@ -17,8 +16,6 @@ import {
import { PaymentProvider } from '@prisma/client';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTrackEvent } from '../TrackView/track.utils';
import { RecaptchaNotice } from '../Recaptcha/RecaptchaWidget';
import { AlertWithIcon } from '../AlertWithIcon/AlertWithIcon';
import { IconAlertCircle } from '@tabler/icons-react';
import { useDialogContext } from '~/components/Dialog/DialogProvider';
@@ -28,6 +25,11 @@ import { usePaddle } from '~/providers/PaddleProvider';
import { useActiveSubscription } from '~/components/Stripe/memberships.util';
import { formatPriceForDisplay, numberWithCommas } from '~/utils/number-helpers';
import { useMutatePaddle } from '~/components/Paddle/util';
import {
CaptchaState,
TurnstilePrivacyNotice,
TurnstileWidget,
} from '~/components/TurnstileWidget/TurnstileWidget';
const Error = ({ error, onClose }: { error: string; onClose: () => void }) => (
<Stack>
@@ -41,7 +43,7 @@ const Error = ({ error, onClose }: { error: string; onClose: () => void }) => (
{error}
</AlertWithIcon>
<RecaptchaNotice />
<TurnstilePrivacyNotice />
<Center>
<Button onClick={onClose}>Close this window</Button>
@@ -71,6 +73,11 @@ export const PaddleTransacionModal = ({
const [success, setSuccess] = useState(false);
const [error, setError] = useState<string | null>();
const [processingSuccess, setProcessingSuccess] = useState(false);
const [captchaState, setCaptchaState] = useState<CaptchaState>({
status: null,
token: null,
error: null,
});
const onCheckoutComplete = useCallback(
(data?: CheckoutEventsData) => {
@@ -108,10 +115,11 @@ export const PaddleTransacionModal = ({
!paddleTransactionLoading &&
!transactionId &&
!subscriptionLoading &&
(!subscription || subscriptionPaymentProvider !== PaymentProvider.Paddle)
(!subscription || subscriptionPaymentProvider !== PaymentProvider.Paddle) &&
captchaState.status === 'success'
) {
// Go ahead and automatically trigger the checkout
getTransaction();
getTransaction(captchaState.token);
}
}, [
transactionError,
@@ -121,6 +129,8 @@ export const PaddleTransacionModal = ({
getTransaction,
transactionId,
paddleTransactionLoading,
captchaState.status,
captchaState.token,
]);
const handlePurchaseWithSubscription = useCallback(async () => {
@@ -154,7 +164,12 @@ export const PaddleTransacionModal = ({
[]
);
if (subscriptionLoading || paddleTransactionLoading || processingSuccess) {
if (
subscriptionLoading ||
paddleTransactionLoading ||
processingSuccess ||
(captchaState.status !== 'error' && !captchaState.token)
) {
return (
<Modal {...dialog} {...modalProps}>
<Stack spacing="md">
@@ -162,16 +177,32 @@ export const PaddleTransacionModal = ({
<Loader />
</Center>
<RecaptchaNotice />
<TurnstilePrivacyNotice />
<TurnstileWidget
onSuccess={(token) => setCaptchaState({ status: 'success', token, error: null })}
onError={(error) =>
setCaptchaState({
status: 'error',
token: null,
error: `There was an error generating the captcha: ${error}`,
})
}
onExpire={(token) =>
setCaptchaState({ status: 'expired', token, error: 'Captcha token expired' })
}
/>
</Stack>
</Modal>
);
}
if (transactionError && !paddleTransactionLoading && !transactionId) {
if (
(transactionError && !paddleTransactionLoading && !transactionId) ||
captchaState.status === 'error'
) {
return (
<Modal {...dialog} {...modalProps}>
<Error error={transactionError} onClose={dialog.onClose} />
<Error error={captchaState.error ?? transactionError ?? ''} onClose={dialog.onClose} />
</Modal>
);
}
@@ -266,14 +297,24 @@ export const PaddleTransacionModal = ({
<Divider size="sm" label="OR" my="sm" labelPosition="center" />
<Button
variant="outline"
onClick={getTransaction}
onClick={() =>
captchaState.status === 'success' ? getTransaction(captchaState.token) : undefined
}
disabled={purchasingBuzzWithSubscription}
radius="xl"
>
Use a different payment method
</Button>
</Stack>
<RecaptchaNotice />
<TurnstilePrivacyNotice />
<TurnstileWidget
onSuccess={(token) => setCaptchaState({ status: 'success', token, error: null })}
onError={(error) => setCaptchaState({ status: 'error', token: null, error })}
onExpire={(token) =>
setCaptchaState({ status: 'expired', token, error: 'Captcha token expired' })
}
/>
</Stack>
</Modal>
);
+35 -37
View File
@@ -1,8 +1,6 @@
import { CurrencyCode } from '@paddle/paddle-js';
import { useState, useCallback } from 'react';
import { RECAPTCHA_ACTIONS } from '~/server/common/constants';
import { trpc } from '~/utils/trpc';
import { useRecaptchaToken } from '~/components/Recaptcha/useReptchaToken';
import { useDebouncer } from '~/utils/debouncer';
export const usePaddleBuzzTransaction = ({
@@ -16,48 +14,48 @@ export const usePaddleBuzzTransaction = ({
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const createTransactionMutation = trpc.paddle.createBuzzPurchaseTransaction.useMutation();
const { getToken, loading: isLoadingToken } = useRecaptchaToken(
RECAPTCHA_ACTIONS.PADDLE_TRANSACTION,
false
const getTransaction = useCallback(
(captchaToken: string | null) => async () => {
if (isLoading || createTransactionMutation.isLoading) return;
setTransactionId(null);
setError(null);
setIsLoading(true);
try {
if (!captchaToken) {
throw new Error('Unable to get captcha token.');
}
const data = await createTransactionMutation.mutateAsync({
unitAmount,
currency,
recaptchaToken: captchaToken,
});
setTransactionId(data.transactionId);
} catch (err: any) {
setError(err?.message ?? err ?? 'An error occurred');
} finally {
setIsLoading(false);
}
},
[unitAmount, currency, createTransactionMutation, isLoading]
);
const getTransaction = useCallback(async () => {
if (isLoading || createTransactionMutation.isLoading || isLoadingToken) return;
setTransactionId(null);
setError(null);
setIsLoading(true);
try {
const recaptchaToken = await getToken();
if (!recaptchaToken) {
throw new Error('Unable to get recaptcha token.');
}
const data = await createTransactionMutation.mutateAsync({
unitAmount,
currency,
recaptchaToken: recaptchaToken as string,
});
setTransactionId(data.transactionId);
} catch (err: any) {
setError(err?.message ?? err ?? 'An error occurred');
} finally {
setIsLoading(false);
}
}, [unitAmount, currency, getToken, createTransactionMutation]);
const debouncer = useDebouncer(300);
const debouncedGetTransaction = useCallback(() => {
debouncer(getTransaction);
}, [getTransaction, debouncer]);
const debouncedGetTransaction = useCallback(
(captchaToken: string | null) => {
debouncer(getTransaction(captchaToken));
},
[getTransaction, debouncer]
);
return {
error,
transactionId,
isLoading: isLoading || isLoadingToken,
isLoading,
getTransaction: debouncedGetTransaction,
};
};
@@ -0,0 +1,45 @@
import { Anchor, Text, TextProps } from '@mantine/core';
import { Turnstile, TurnstileProps, TurnstileInstance } from '@marsidev/react-turnstile';
import { useRef } from 'react';
import { env } from '~/env/client.mjs';
import { showExpiredCaptchaTokenNotification } from '~/utils/notifications';
export type CaptchaState = {
status: 'success' | 'error' | 'expired' | null;
token: string | null;
error: string | null;
};
export function TurnstileWidget(props: Props) {
const ref = useRef<TurnstileInstance>(null);
const handleExpired: Props['onExpire'] = (token) => {
const instance = ref.current;
if (instance) showExpiredCaptchaTokenNotification({ onRetryClick: () => instance.reset() });
return props.onExpire?.(token);
};
if (!env.NEXT_PUBLIC_CLOUDFLARE_TURNSTILE_SITEKEY) return null;
return (
<Turnstile
ref={ref}
siteKey={env.NEXT_PUBLIC_CLOUDFLARE_TURNSTILE_SITEKEY}
options={{ size: 'invisible' }}
{...props}
onExpire={handleExpired}
/>
);
}
type Props = Omit<TurnstileProps, 'siteKey'>;
export function TurnstilePrivacyNotice(props: TextProps) {
return (
<Text size="xs" {...props}>
This site is protected by Cloudflare Turnstile and the Cloudflare{' '}
<Anchor href="https://www.cloudflare.com/privacypolicy/">Privacy Policy</Anchor> applies.
</Text>
);
}
+4 -1
View File
@@ -7,7 +7,7 @@ import {
stringToArray,
} from '~/utils/zod-helpers';
/**
* Specify your server-side environment variables schema here.
* This way you can ensure the app isn't built with invalid env vars.
@@ -178,6 +178,7 @@ export const serverSchema = z.object({
STRIPE_DONATE_ID: z.string().optional(),
PADDLE_SECRET_KEY: z.string().optional(),
PADDLE_WEBHOOK_SECRET: z.string().optional(),
CLOUDFLARE_TURNSTILE_SECRET: z.string().optional(),
});
/**
@@ -220,6 +221,7 @@ export const clientSchema = z.object({
NEXT_PUBLIC_PADDLE_TOKEN: z.string().optional(),
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().optional(),
NEXT_PUBLIC_DEFAULT_PAYMENT_PROVIDER: z.enum(['Stripe', 'Paddle']).default('Stripe'),
NEXT_PUBLIC_CLOUDFLARE_TURNSTILE_SITEKEY: z.string().optional(),
});
/**
@@ -264,4 +266,5 @@ export const clientEnv = {
NEXT_PUBLIC_PADDLE_TOKEN: process.env.NEXT_PUBLIC_PADDLE_TOKEN,
// Default to Stripe in case the env var is not set
NEXT_PUBLIC_DEFAULT_PAYMENT_PROVIDER: process.env.NEXT_PUBLIC_DEFAULT_PAYMENT_PROVIDER === 'Paddle' ? 'Paddle' : 'Stripe',
NEXT_PUBLIC_CLOUDFLARE_TURNSTILE_SITEKEY: process.env.NEXT_PUBLIC_CLOUDFLARE_TURNSTILE_SITEKEY,
};
+14 -16
View File
@@ -160,22 +160,20 @@ function MyApp(props: CustomAppProps) {
zIndex={9999}
>
<BrowserRouterProvider>
<RecaptchaWidgetProvider>
<GenerationProvider>
<IntersectionObserverProvider>
<BaseLayout>
<ChatContextProvider>
<CustomModalsProvider>
{getLayout(<Component {...pageProps} />)}
<StripeSetupSuccessProvider />
<DialogProvider />
<RoutedDialogProvider />
</CustomModalsProvider>
</ChatContextProvider>
</BaseLayout>
</IntersectionObserverProvider>
</GenerationProvider>
</RecaptchaWidgetProvider>
<GenerationProvider>
<IntersectionObserverProvider>
<BaseLayout>
<ChatContextProvider>
<CustomModalsProvider>
{getLayout(<Component {...pageProps} />)}
<StripeSetupSuccessProvider />
<DialogProvider />
<RoutedDialogProvider />
</CustomModalsProvider>
</ChatContextProvider>
</BaseLayout>
</IntersectionObserverProvider>
</GenerationProvider>
</BrowserRouterProvider>
</NotificationsProvider>
</CivitaiLinkProvider>
-1
View File
@@ -1,6 +1,5 @@
import { createGetInitialProps } from '@mantine/next';
import Document, { Html, Main, NextScript, Head } from 'next/document';
import Script from 'next/script';
const getInitialProps = createGetInitialProps();
+1 -1
View File
@@ -27,7 +27,7 @@ import { env } from '~/env/client.mjs';
export const getServerSideProps = createServerSideProps({
useSession: true,
resolver: async ({ features }) => {
if (!features?.isGreen)
if (!features?.canBuyBuzz)
return {
redirect: {
destination: `https://${env.NEXT_PUBLIC_SERVER_DOMAIN_GREEN}/purchase/buzz?sync-account=blue`,
+3 -19
View File
@@ -1,7 +1,6 @@
import {
throwAuthorizationError,
throwBadRequestError,
throwDbError,
throwNotFoundError,
} from '~/server/utils/errorHandling';
import { Context } from '~/server/createContext';
@@ -14,13 +13,11 @@ import {
} from '~/server/services/paddle.service';
import {
TransactionCreateInput,
TransactionMetadataSchema,
TransactionWithSubscriptionCreateInput,
UpdateSubscriptionInputSchema,
} from '~/server/schema/paddle.schema';
import { getTRPCErrorFromUnknown } from '@trpc/server';
import { RECAPTCHA_ACTIONS } from '~/server/common/constants';
import { createRecaptchaAssesment } from '~/server/recaptcha/client';
import { verifyCaptchaToken } from '~/server/recaptcha/client';
import { getPaddleSubscription, getTransactionById } from '~/server/paddle/client';
import { GetByIdStringInput } from '~/server/schema/base.schema';
import { getPlans, getUserSubscription } from '~/server/services/subscriptions.service';
@@ -40,23 +37,10 @@ export const createBuzzPurchaseTransactionHandler = async ({
}
const { recaptchaToken } = input;
if (!recaptchaToken) throw throwAuthorizationError('recaptchaToken required');
const { score, reasons } = await createRecaptchaAssesment({
token: recaptchaToken,
recaptchaAction: RECAPTCHA_ACTIONS.PADDLE_TRANSACTION,
});
if ((score || 0) < 0.7) {
if (reasons.length) {
throw throwAuthorizationError(
`Recaptcha Failed. The following reasons were detected: ${reasons.join(', ')}`
);
} else {
throw throwAuthorizationError('We could not verify the authenticity of your request.');
}
}
const validCaptcha = await verifyCaptchaToken({ token: recaptchaToken, ip: ctx.ip });
if (!validCaptcha) throw throwAuthorizationError('Captcha Failed. Please try again.');
const user = { id: ctx.user.id, email: ctx.user.email as string };
return await createBuzzPurchaseTransaction({ user, ...input });
+6 -18
View File
@@ -3,7 +3,7 @@ import { TRPCError } from '@trpc/server';
import { orderBy } from 'lodash-es';
import { isProd } from '~/env/other';
import { clickhouse } from '~/server/clickhouse/client';
import { constants, RECAPTCHA_ACTIONS } from '~/server/common/constants';
import { constants } from '~/server/common/constants';
import {
NotificationCategory,
OnboardingComplete,
@@ -110,7 +110,7 @@ import { Flags } from '~/shared/utils';
import { isUUID } from '~/utils/string-helpers';
import { isDefined } from '~/utils/type-guards';
import { getUserBuzzBonusAmount } from '../common/user-helpers';
import { createRecaptchaAssesment } from '../recaptcha/client';
import { verifyCaptchaToken } from '../recaptcha/client';
import { TransactionType } from '../schema/buzz.schema';
import { createBuzzTransaction } from '../services/buzz.service';
import { FeatureAccess, toggleableFeatures } from '../services/feature-flags.service';
@@ -267,23 +267,11 @@ export const completeOnboardingHandler = async ({
switch (input.step) {
case OnboardingSteps.TOS:
// const { recaptchaToken } = input;
// if (!recaptchaToken) throw throwAuthorizationError('recaptchaToken required');
const { recaptchaToken } = input;
if (!recaptchaToken) throw throwAuthorizationError('recaptchaToken required');
// const { score, reasons } = await createRecaptchaAssesment({
// token: recaptchaToken,
// recaptchaAction: RECAPTCHA_ACTIONS.COMPLETE_ONBOARDING,
// });
// if ((score || 0) < 0.5) {
// if (reasons.length) {
// throw throwAuthorizationError(
// `Recaptcha Failed. The following reasons were detected: ${reasons.join(', ')}`
// );
// } else {
// throw throwAuthorizationError('We could not verify the authenticity of your request.');
// }
// }
const validCaptcha = await verifyCaptchaToken({ token: recaptchaToken, ip: ctx.ip });
if (!validCaptcha) throw throwAuthorizationError('Recaptcha Failed. Please try again.');
await dbWrite.user.update({ where: { id }, data: { onboarding } });
break;
+31
View File
@@ -3,6 +3,7 @@ import { env } from '~/env/server.mjs';
import { isDev } from '../../env/other';
import { throwBadRequestError } from '~/server/utils/errorHandling';
import { isDefined } from '~/utils/type-guards';
import { z } from 'zod';
// Taken from package as they don't export it :shrug:
// enum ClassificationReason {
@@ -86,3 +87,33 @@ export async function createRecaptchaAssesment({
throw throwBadRequestError('Provided token does not match performed action');
}
}
type SiteVerifyResponse = z.infer<typeof siteVerifyResponseSchema>;
const siteVerifyResponseSchema = z.object({
success: z.boolean(),
challenge_ts: z.coerce.date().optional(),
hostname: z.string().optional(),
'error-codes': z.array(z.string()),
action: z.string().optional(),
cdata: z.string().optional(),
});
export async function verifyCaptchaToken({ token, ip }: { token: string; ip?: string }) {
const result = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
secret: env.CLOUDFLARE_TURNSTILE_SECRET,
response: token,
remoteip: ip,
}),
});
if (!result.ok) throw throwBadRequestError('No response from captcha service');
const outcome = (await result.json()) as SiteVerifyResponse;
if (outcome.success) {
return true;
} else {
throw throwBadRequestError('Unable to verify captcha token');
}
}
+27 -1
View File
@@ -1,6 +1,12 @@
import { Button, Group, Stack, Text, ThemeIcon } from '@mantine/core';
import { NotificationProps, showNotification } from '@mantine/notifications';
import { IconBolt, IconCheck, IconExclamationMark, IconX } from '@tabler/icons-react';
import {
IconAlertTriangle,
IconBolt,
IconCheck,
IconExclamationMark,
IconX,
} from '@tabler/icons-react';
export function showErrorNotification({
error,
@@ -138,3 +144,23 @@ export function showConfirmNotification({
disallowClose: true,
});
}
export function showExpiredCaptchaTokenNotification({
onRetryClick,
}: {
onRetryClick: VoidFunction;
}) {
showNotification({
icon: <IconAlertTriangle size={18} />,
color: 'yellow',
title: 'Captcha token expired',
message: (
<div>
<Text inherit>Your token expired, click the button below to reset your token</Text>
<Button size="sm" variant="subtle" onClick={onRetryClick}>
Reset
</Button>
</div>
),
});
}