diff --git a/apps/web/app/(dashboard)/home/[account]/_lib/server/team-billing.service.ts b/apps/web/app/(dashboard)/home/[account]/_lib/server/team-billing.service.ts index 3d1e0e529..70043da62 100644 --- a/apps/web/app/(dashboard)/home/[account]/_lib/server/team-billing.service.ts +++ b/apps/web/app/(dashboard)/home/[account]/_lib/server/team-billing.service.ts @@ -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) {} + /** + * @name createCheckout + * @description Creates a checkout session for a Team account + */ async createCheckout(params: z.infer) { // 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, diff --git a/apps/web/components/root-providers.tsx b/apps/web/components/root-providers.tsx index b81244144..e9950b153 100644 --- a/apps/web/components/root-providers.tsx +++ b/apps/web/components/root-providers.tsx @@ -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 ( - - - - {children} - - - + + + + + + + {children} + + + + ); diff --git a/apps/web/config/auth.config.ts b/apps/web/config/auth.config.ts index d90a6c078..70c4d4246 100644 --- a/apps/web/config/auth.config.ts +++ b/apps/web/config/auth.config.ts @@ -5,6 +5,7 @@ import { z } from 'zod'; const providers: z.ZodType = 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: { diff --git a/apps/web/package.json b/apps/web/package.json index fa988db64..375adbc1b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/public/locales/en/auth.json b/apps/web/public/locales/en/auth.json index d617722ac..1c002aae6 100644 --- a/apps/web/public/locales/en/auth.json +++ b/apps/web/public/locales/en/auth.json @@ -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.", diff --git a/packages/billing/lemon-squeezy/src/services/create-lemon-squeezy-checkout.ts b/packages/billing/lemon-squeezy/src/services/create-lemon-squeezy-checkout.ts index 9d4c3ce4b..5b2b46296 100644 --- a/packages/billing/lemon-squeezy/src/services/create-lemon-squeezy-checkout.ts +++ b/packages/billing/lemon-squeezy/src/services/create-lemon-squeezy-checkout.ts @@ -67,6 +67,8 @@ export async function createLemonSqueezyCheckout( }, productOptions: { redirectUrl: params.returnUrl, + // only show the selected variant ID + enabledVariants: [variantId], }, expiresAt: null, preview: true, diff --git a/packages/features/auth/package.json b/packages/features/auth/package.json index b71cc75e0..056336783 100644 --- a/packages/features/auth/package.json +++ b/packages/features/auth/package.json @@ -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", diff --git a/packages/features/auth/src/components/captcha/captcha-provider.tsx b/packages/features/auth/src/components/captcha/captcha-provider.tsx new file mode 100644 index 000000000..3bfc70c64 --- /dev/null +++ b/packages/features/auth/src/components/captcha/captcha-provider.tsx @@ -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(''); + + return ( + + {props.children} + + ); +} diff --git a/packages/features/auth/src/components/captcha/captchaTokenSetter.tsx b/packages/features/auth/src/components/captcha/captchaTokenSetter.tsx new file mode 100644 index 000000000..59068be39 --- /dev/null +++ b/packages/features/auth/src/components/captcha/captchaTokenSetter.tsx @@ -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 ; +} diff --git a/packages/features/auth/src/components/captcha/index.ts b/packages/features/auth/src/components/captcha/index.ts new file mode 100644 index 000000000..14f70c5f0 --- /dev/null +++ b/packages/features/auth/src/components/captcha/index.ts @@ -0,0 +1,3 @@ +export * from './captchaTokenSetter'; +export * from './use-captcha-token'; +export * from './captcha-provider'; diff --git a/packages/features/auth/src/components/captcha/use-captcha-token.ts b/packages/features/auth/src/components/captcha/use-captcha-token.ts new file mode 100644 index 000000000..461911ac7 --- /dev/null +++ b/packages/features/auth/src/components/captcha/use-captcha-token.ts @@ -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; +} diff --git a/packages/features/auth/src/components/magic-link-auth-container.tsx b/packages/features/auth/src/components/magic-link-auth-container.tsx index 30527ad27..aed7103c5 100644 --- a/packages/features/auth/src/components/magic-link-auth-container.tsx +++ b/packages/features/auth/src/components/magic-link-auth-container.tsx @@ -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 ( - - - - - - - - - - - - ); + return ; } return (
- - - - - - - - - +
@@ -130,3 +113,35 @@ export function MagicLinkAuthContainer({ ); } + +function SuccessAlert() { + return ( + + + + + + + + + + + + ); +} + +function ErrorAlert() { + return ( + + + + + + + + + + + + ); +} diff --git a/packages/features/auth/src/components/password-sign-up-container.tsx b/packages/features/auth/src/components/password-sign-up-container.tsx index 39731a4e1..c5286692f 100644 --- a/packages/features/auth/src/components/password-sign-up-container.tsx +++ b/packages/features/auth/src/components/password-sign-up-container.tsx @@ -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 ( <> - - - - - - - - - - - + @@ -86,3 +67,19 @@ export function EmailPasswordSignUpContainer({ ); } + +function SuccessAlert() { + return ( + + + + + + + + + + + + ); +} diff --git a/packages/features/auth/src/components/sign-up-methods-container.tsx b/packages/features/auth/src/components/sign-up-methods-container.tsx index 8cccb62a7..36ac4ca85 100644 --- a/packages/features/auth/src/components/sign-up-methods-container.tsx +++ b/packages/features/auth/src/components/sign-up-methods-container.tsx @@ -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 ( <> - - You have been invited to join a team - - Please sign up to continue with the invitation and create your - account. - - + - + @@ -88,3 +89,17 @@ function getCallbackUrl(props: { return url.href; } + +function InviteAlert() { + return ( + + + + + + + + + + ); +} diff --git a/packages/supabase/src/hooks/use-sign-up-with-email-password.ts b/packages/supabase/src/hooks/use-sign-up-with-email-password.ts index 0f6d7d00a..821482f93 100644 --- a/packages/supabase/src/hooks/use-sign-up-with-email-password.ts +++ b/packages/supabase/src/hooks/use-sign-up-with-email-password.ts @@ -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, }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 826d49b84..c86c43659 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: