Add captcha support to authentication features
The update includes the implementation of captcha support during the sign-in and sign-up process for user accounts. The process ensures a better level of security against bot-based attacks. Also, the code has been refactored to separate error and success alerts and unnecessary useEffect hooks have been removed. Moreover, some logic concerning the authentication rendering has been simplified.
This commit is contained in:
@@ -10,16 +10,21 @@ import { Database } from '@kit/supabase/database';
|
||||
import { requireUser } from '@kit/supabase/require-user';
|
||||
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
|
||||
|
||||
import { TeamCheckoutSchema } from '~/(dashboard)/home/[account]/_lib/schema/team-checkout.schema';
|
||||
import appConfig from '~/config/app.config';
|
||||
import billingConfig from '~/config/billing.config';
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
|
||||
import { TeamCheckoutSchema } from '../../_lib/schema/team-checkout.schema';
|
||||
|
||||
export class TeamBillingService {
|
||||
private readonly namespace = 'billing.team-account';
|
||||
|
||||
constructor(private readonly client: SupabaseClient<Database>) {}
|
||||
|
||||
/**
|
||||
* @name createCheckout
|
||||
* @description Creates a checkout session for a Team account
|
||||
*/
|
||||
async createCheckout(params: z.infer<typeof TeamCheckoutSchema>) {
|
||||
// we require the user to be authenticated
|
||||
const { data: user } = await requireUser(this.client);
|
||||
@@ -126,6 +131,12 @@ export class TeamBillingService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @name createBillingPortalSession
|
||||
* @description Creates a new billing portal session for a team account
|
||||
* @param accountId
|
||||
* @param slug
|
||||
*/
|
||||
async createBillingPortalSession({
|
||||
accountId,
|
||||
slug,
|
||||
|
||||
@@ -4,13 +4,16 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactQueryStreamedHydration } from '@tanstack/react-query-next-experimental';
|
||||
import { ThemeProvider } from 'next-themes';
|
||||
|
||||
import { CaptchaProvider, CaptchaTokenSetter } from '@kit/auth/captcha';
|
||||
import { I18nProvider } from '@kit/i18n/provider';
|
||||
import { AuthChangeListener } from '@kit/supabase/components/auth-change-listener';
|
||||
|
||||
import appConfig from '~/config/app.config';
|
||||
import authConfig from '~/config/auth.config';
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
import { i18nResolver } from '~/lib/i18n/i18n.resolver';
|
||||
|
||||
const captchaSiteKey = authConfig.captchaTokenSiteKey;
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
export function RootProviders({
|
||||
@@ -22,18 +25,22 @@ export function RootProviders({
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ReactQueryStreamedHydration>
|
||||
<AuthChangeListener appHomePath={pathsConfig.app.home}>
|
||||
<I18nProvider lang={lang} resolver={i18nResolver}>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
defaultTheme={appConfig.theme}
|
||||
>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
</AuthChangeListener>
|
||||
<CaptchaProvider>
|
||||
<CaptchaTokenSetter siteKey={captchaSiteKey} />
|
||||
|
||||
<AuthChangeListener appHomePath={pathsConfig.app.home}>
|
||||
<I18nProvider lang={lang} resolver={i18nResolver}>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
defaultTheme={appConfig.theme}
|
||||
>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
</AuthChangeListener>
|
||||
</CaptchaProvider>
|
||||
</ReactQueryStreamedHydration>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { z } from 'zod';
|
||||
const providers: z.ZodType<Provider> = getProviders();
|
||||
|
||||
const AuthConfigSchema = z.object({
|
||||
captchaTokenSiteKey: z.string().min(1).optional(),
|
||||
providers: z.object({
|
||||
password: z.boolean({
|
||||
description: 'Enable password authentication.',
|
||||
@@ -17,6 +18,10 @@ const AuthConfigSchema = z.object({
|
||||
});
|
||||
|
||||
const authConfig = AuthConfigSchema.parse({
|
||||
// NB: This is a public key, so it's safe to expose.
|
||||
// Copy the value from the Supabase Dashboard.
|
||||
captchaTokenSiteKey: process.env.NEXT_PUBLIC_CAPTCHA_SITE_KEY ?? '',
|
||||
|
||||
// NB: Enable the providers below in the Supabase Console
|
||||
// in your production project
|
||||
providers: {
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"@kit/supabase": "workspace:^",
|
||||
"@kit/team-accounts": "workspace:^",
|
||||
"@kit/ui": "workspace:^",
|
||||
"@marsidev/react-turnstile": "^0.5.4",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@supabase/ssr": "^0.1.0",
|
||||
"@supabase/supabase-js": "^2.42.0",
|
||||
|
||||
@@ -63,6 +63,8 @@
|
||||
"sendingEmailCode": "Sending code...",
|
||||
"resetPasswordError": "Sorry, we could not reset your password. Please try again.",
|
||||
"emailPlaceholder": "your@email.com",
|
||||
"inviteAlertHeading": "You have been invited to join a team",
|
||||
"inviteAlertBody": "You have been invited to join a team. Please sign in or sign up to accept the invite.",
|
||||
"errors": {
|
||||
"Invalid login credentials": "The credentials entered are invalid",
|
||||
"User already registered": "This credential is already in use. Please try with another one.",
|
||||
|
||||
@@ -67,6 +67,8 @@ export async function createLemonSqueezyCheckout(
|
||||
},
|
||||
productOptions: {
|
||||
redirectUrl: params.returnUrl,
|
||||
// only show the selected variant ID
|
||||
enabledVariants: [variantId],
|
||||
},
|
||||
expiresAt: null,
|
||||
preview: true,
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
"./sign-up": "./src/sign-up.ts",
|
||||
"./password-reset": "./src/password-reset.ts",
|
||||
"./shared": "./src/shared.ts",
|
||||
"./mfa": "./src/mfa.ts"
|
||||
"./mfa": "./src/mfa.ts",
|
||||
"./captcha": "./src/components/captcha/index.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
@@ -24,6 +25,7 @@
|
||||
"@kit/tailwind-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@marsidev/react-turnstile": "^0.5.4",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@tanstack/react-query": "5.28.6",
|
||||
"react-i18next": "^14.1.0",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useState } from 'react';
|
||||
|
||||
export const Captcha = createContext<{
|
||||
token: string;
|
||||
setToken: (token: string) => void;
|
||||
}>({
|
||||
token: '',
|
||||
setToken: (_: string) => {
|
||||
// do nothing
|
||||
return '';
|
||||
},
|
||||
});
|
||||
|
||||
export function CaptchaProvider(props: { children: React.ReactNode }) {
|
||||
const [token, setToken] = useState<string>('');
|
||||
|
||||
return (
|
||||
<Captcha.Provider value={{ token, setToken }}>
|
||||
{props.children}
|
||||
</Captcha.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { useContext } from 'react';
|
||||
|
||||
import { Turnstile } from '@marsidev/react-turnstile';
|
||||
|
||||
import { Captcha } from './captcha-provider';
|
||||
|
||||
export function CaptchaTokenSetter(props: { siteKey: string | undefined }) {
|
||||
const { setToken } = useContext(Captcha);
|
||||
|
||||
if (!props.siteKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Turnstile siteKey={props.siteKey} onSuccess={setToken} />;
|
||||
}
|
||||
3
packages/features/auth/src/components/captcha/index.ts
Normal file
3
packages/features/auth/src/components/captcha/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './captchaTokenSetter';
|
||||
export * from './use-captcha-token';
|
||||
export * from './captcha-provider';
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useContext } from 'react';
|
||||
|
||||
import { Captcha } from './captcha-provider';
|
||||
|
||||
export function useCaptchaToken() {
|
||||
const context = useContext(Captcha);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(`useCaptchaToken must be used within a CaptchaProvider`);
|
||||
}
|
||||
|
||||
return context.token;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { CheckIcon } from '@radix-ui/react-icons';
|
||||
import { CheckIcon, ExclamationTriangleIcon } from '@radix-ui/react-icons';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
@@ -25,8 +25,10 @@ import { Trans } from '@kit/ui/trans';
|
||||
export function MagicLinkAuthContainer({
|
||||
inviteToken,
|
||||
redirectUrl,
|
||||
captchaToken,
|
||||
}: {
|
||||
inviteToken?: string;
|
||||
captchaToken?: string;
|
||||
redirectUrl: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -52,6 +54,7 @@ export function MagicLinkAuthContainer({
|
||||
email,
|
||||
options: {
|
||||
emailRedirectTo,
|
||||
captchaToken,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -63,34 +66,14 @@ export function MagicLinkAuthContainer({
|
||||
};
|
||||
|
||||
if (signInWithOtpMutation.data) {
|
||||
return (
|
||||
<Alert variant={'success'}>
|
||||
<CheckIcon className={'h-4'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'auth:sendLinkSuccess'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'auth:sendLinkSuccessDescription'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
return <SuccessAlert />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form className={'w-full'} onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<If condition={signInWithOtpMutation.error}>
|
||||
<Alert variant={'destructive'}>
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'auth:errors.generic'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'auth:errors.link'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<ErrorAlert />
|
||||
</If>
|
||||
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
@@ -130,3 +113,35 @@ export function MagicLinkAuthContainer({
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function SuccessAlert() {
|
||||
return (
|
||||
<Alert variant={'success'}>
|
||||
<CheckIcon className={'h-4'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'auth:sendLinkSuccess'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'auth:sendLinkSuccessDescription'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorAlert() {
|
||||
return (
|
||||
<Alert variant={'destructive'}>
|
||||
<ExclamationTriangleIcon className={'h-4'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'auth:errors.generic'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'auth:errors.link'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
import { Check } from 'lucide-react';
|
||||
|
||||
@@ -12,30 +12,22 @@ import { Trans } from '@kit/ui/trans';
|
||||
import { AuthErrorAlert } from './auth-error-alert';
|
||||
import { PasswordSignUpForm } from './password-sign-up-form';
|
||||
|
||||
interface EmailPasswordSignUpContainerProps {
|
||||
onSignUp?: (userId?: string) => unknown;
|
||||
emailRedirectTo: string;
|
||||
captchaToken?: string;
|
||||
}
|
||||
|
||||
export function EmailPasswordSignUpContainer({
|
||||
onSignUp,
|
||||
onError,
|
||||
emailRedirectTo,
|
||||
}: React.PropsWithChildren<{
|
||||
onSignUp?: (userId?: string) => unknown;
|
||||
onError?: (error?: unknown) => unknown;
|
||||
emailRedirectTo: string;
|
||||
}>) {
|
||||
captchaToken,
|
||||
}: EmailPasswordSignUpContainerProps) {
|
||||
const signUpMutation = useSignUpWithEmailAndPassword();
|
||||
const redirecting = useRef(false);
|
||||
const loading = signUpMutation.isPending || redirecting.current;
|
||||
const [showVerifyEmailAlert, setShowVerifyEmailAlert] = useState(false);
|
||||
|
||||
const callOnErrorCallback = useCallback(() => {
|
||||
if (signUpMutation.error && onError) {
|
||||
onError(signUpMutation.error);
|
||||
}
|
||||
}, [signUpMutation.error, onError]);
|
||||
|
||||
useEffect(() => {
|
||||
callOnErrorCallback();
|
||||
}, [callOnErrorCallback]);
|
||||
|
||||
const onSignupRequested = useCallback(
|
||||
async (credentials: { email: string; password: string }) => {
|
||||
if (loading) {
|
||||
@@ -46,6 +38,7 @@ export function EmailPasswordSignUpContainer({
|
||||
const data = await signUpMutation.mutateAsync({
|
||||
...credentials,
|
||||
emailRedirectTo,
|
||||
captchaToken,
|
||||
});
|
||||
|
||||
setShowVerifyEmailAlert(true);
|
||||
@@ -54,28 +47,16 @@ export function EmailPasswordSignUpContainer({
|
||||
onSignUp(data.user?.id);
|
||||
}
|
||||
} catch (error) {
|
||||
if (onError) {
|
||||
onError(error);
|
||||
}
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
[emailRedirectTo, loading, onError, onSignUp, signUpMutation],
|
||||
[emailRedirectTo, loading, onSignUp, signUpMutation],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<If condition={showVerifyEmailAlert}>
|
||||
<Alert variant={'success'}>
|
||||
<Check className={'w-4'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'auth:emailConfirmationAlertHeading'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription data-test={'email-confirmation-alert'}>
|
||||
<Trans i18nKey={'auth:emailConfirmationAlertBody'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<SuccessAlert />
|
||||
</If>
|
||||
|
||||
<If condition={!showVerifyEmailAlert}>
|
||||
@@ -86,3 +67,19 @@ export function EmailPasswordSignUpContainer({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SuccessAlert() {
|
||||
return (
|
||||
<Alert variant={'success'}>
|
||||
<Check className={'w-4'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'auth:emailConfirmationAlertHeading'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription data-test={'email-confirmation-alert'}>
|
||||
<Trans i18nKey={'auth:emailConfirmationAlertBody'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ import { isBrowser } from '@kit/shared/utils';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Separator } from '@kit/ui/separator';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { useCaptchaToken } from './captcha';
|
||||
import { MagicLinkAuthContainer } from './magic-link-auth-container';
|
||||
import { OauthProviders } from './oauth-providers';
|
||||
import { EmailPasswordSignUpContainer } from './password-sign-up-container';
|
||||
@@ -26,27 +28,26 @@ export function SignUpMethodsContainer(props: {
|
||||
inviteToken?: string;
|
||||
}) {
|
||||
const redirectUrl = getCallbackUrl(props);
|
||||
const captchaToken = useCaptchaToken();
|
||||
|
||||
return (
|
||||
<>
|
||||
<If condition={props.inviteToken}>
|
||||
<Alert variant={'info'}>
|
||||
<AlertTitle>You have been invited to join a team</AlertTitle>
|
||||
<AlertDescription>
|
||||
Please sign up to continue with the invitation and create your
|
||||
account.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<InviteAlert />
|
||||
</If>
|
||||
|
||||
<If condition={props.providers.password}>
|
||||
<EmailPasswordSignUpContainer emailRedirectTo={redirectUrl} />
|
||||
<EmailPasswordSignUpContainer
|
||||
captchaToken={captchaToken}
|
||||
emailRedirectTo={redirectUrl}
|
||||
/>
|
||||
</If>
|
||||
|
||||
<If condition={props.providers.magicLink}>
|
||||
<MagicLinkAuthContainer
|
||||
inviteToken={props.inviteToken}
|
||||
redirectUrl={redirectUrl}
|
||||
captchaToken={captchaToken}
|
||||
/>
|
||||
</If>
|
||||
|
||||
@@ -88,3 +89,17 @@ function getCallbackUrl(props: {
|
||||
|
||||
return url.href;
|
||||
}
|
||||
|
||||
function InviteAlert() {
|
||||
return (
|
||||
<Alert variant={'info'}>
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'auth:inviteAlertHeading'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'auth:inviteAlertBody'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ interface Credentials {
|
||||
email: string;
|
||||
password: string;
|
||||
emailRedirectTo: string;
|
||||
captchaToken?: string;
|
||||
}
|
||||
|
||||
export function useSignUpWithEmailAndPassword() {
|
||||
@@ -13,12 +14,13 @@ export function useSignUpWithEmailAndPassword() {
|
||||
const mutationKey = ['auth', 'sign-up-with-email-password'];
|
||||
|
||||
const mutationFn = async (params: Credentials) => {
|
||||
const { emailRedirectTo, ...credentials } = params;
|
||||
const { emailRedirectTo, captchaToken, ...credentials } = params;
|
||||
|
||||
const response = await client.auth.signUp({
|
||||
...credentials,
|
||||
options: {
|
||||
emailRedirectTo,
|
||||
captchaToken,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
15
pnpm-lock.yaml
generated
15
pnpm-lock.yaml
generated
@@ -83,6 +83,9 @@ importers:
|
||||
'@kit/ui':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/ui
|
||||
'@marsidev/react-turnstile':
|
||||
specifier: ^0.5.4
|
||||
version: 0.5.4(react-dom@18.2.0)(react@18.2.0)
|
||||
'@radix-ui/react-icons':
|
||||
specifier: ^1.3.0
|
||||
version: 1.3.0(react@18.2.0)
|
||||
@@ -574,6 +577,9 @@ importers:
|
||||
'@kit/ui':
|
||||
specifier: workspace:*
|
||||
version: link:../../ui
|
||||
'@marsidev/react-turnstile':
|
||||
specifier: ^0.5.4
|
||||
version: 0.5.4(react-dom@18.2.0)(react@18.2.0)
|
||||
'@radix-ui/react-icons':
|
||||
specifier: ^1.3.0
|
||||
version: 1.3.0(react@18.2.0)
|
||||
@@ -2209,6 +2215,15 @@ packages:
|
||||
read-yaml-file: 1.1.0
|
||||
dev: false
|
||||
|
||||
/@marsidev/react-turnstile@0.5.4(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-cloUDkEcJm+G7p3J8DwPtRNNB2GZqVi/nlIbgu9o3VzNyV5K/bWcSfOyWouRiR3umAQZmsFpR3OFYa4mCmy4AQ==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
dependencies:
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
|
||||
/@mdx-js/esbuild@2.3.0(esbuild@0.20.2):
|
||||
resolution: {integrity: sha512-r/vsqsM0E+U4Wr0DK+0EfmABE/eg+8ITW4DjvYdh3ve/tK2safaqHArNnaqbOk1DjYGrhxtoXoGaM3BY8fGBTA==}
|
||||
peerDependencies:
|
||||
|
||||
Reference in New Issue
Block a user