feat: added turnstile to signup flow (#4516)

This commit is contained in:
Piyush Gupta
2025-01-06 17:56:53 +05:30
committed by GitHub
parent 7715789d0f
commit 7e8514e7be
18 changed files with 152 additions and 2 deletions

View File

@@ -103,6 +103,10 @@ TERMS_URL=
IMPRINT_URL=
IMPRINT_ADDRESS=
# Configure Turnstile in signup flow
# NEXT_PUBLIC_TURNSTILE_SITE_KEY=
# TURNSTILE_SECRET_KEY=
# Configure Github Login
GITHUB_ID=
GITHUB_SECRET=

View File

@@ -56,6 +56,8 @@ These variables are present inside your machines docker-compose file. Restart
| SMTP_PASSWORD | Password for your SMTP Server. | optional (required if email services are to be enabled) | |
| SMTP_SECURE_ENABLED | SMTP secure connection. For using TLS, set to 1 else to 0. | optional (required if email services are to be enabled) | |
| SMTP_REJECT_UNAUTHORIZED_TLS | If set to 0, the server will accept connections without requiring authorization from the list of supplied CAs. | optional | 1 |
| TURNSTILE_SITE_KEY | Site key for Turnstile. | optional | |
| TURNSTILE_SECRET_KEY | Secret key for Turnstile. | optional | |
| GITHUB_ID | Client ID for GitHub. | optional (required if GitHub auth is enabled) | |
| GITHUB_SECRET | Secret for GitHub. | optional (required if GitHub auth is enabled) | |
| GOOGLE_CLIENT_ID | Client ID for Google. | optional (required if Google auth is enabled) | |

View File

@@ -1,3 +1,4 @@
import { PHProvider } from "@/modules/ui/components/post-hog-client";
import { SpeedInsights } from "@vercel/speed-insights/next";
import { Metadata } from "next";
import { NextIntlClientProvider } from "next-intl";
@@ -20,7 +21,9 @@ const RootLayout = async ({ children }: { children: React.ReactNode }) => {
<html lang={locale} translate="no">
{process.env.VERCEL === "1" && <SpeedInsights sampleRate={0.1} />}
<body className="flex h-dvh flex-col transition-all ease-in-out">
<NextIntlClientProvider messages={messages}>{children}</NextIntlClientProvider>
<PHProvider>
<NextIntlClientProvider messages={messages}>{children}</NextIntlClientProvider>
</PHProvider>
</body>
</html>
);

View File

@@ -10,6 +10,7 @@ import {
EMAIL_VERIFICATION_DISABLED,
GITHUB_OAUTH_ENABLED,
GOOGLE_OAUTH_ENABLED,
IS_TURNSTILE_CONFIGURED,
OIDC_DISPLAY_NAME,
OIDC_OAUTH_ENABLED,
PRIVACY_URL,
@@ -47,6 +48,7 @@ const Page = async () => {
defaultOrganizationId={DEFAULT_ORGANIZATION_ID}
defaultOrganizationRole={DEFAULT_ORGANIZATION_ROLE}
isSSOEnabled={isSSOEnabled}
isTurnstileConfigured={IS_TURNSTILE_CONFIGURED}
/>
</div>
);

View File

@@ -3,16 +3,19 @@
import { actionClient } from "@/lib/utils/action-client";
import { createUser } from "@/modules/auth/lib/user";
import { updateUser } from "@/modules/auth/lib/user";
import { captureFailedSignup, verifyTurnstileToken } from "@/modules/auth/signup/lib/utils";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { sendInviteAcceptedEmail, sendVerificationEmail } from "@/modules/email";
import { createTeamMembership } from "@/modules/invite/lib/team";
import { z } from "zod";
import { hashPassword } from "@formbricks/lib/auth";
import { IS_TURNSTILE_CONFIGURED, TURNSTILE_SECRET_KEY } from "@formbricks/lib/constants";
import { getInvite } from "@formbricks/lib/invite/service";
import { deleteInvite } from "@formbricks/lib/invite/service";
import { verifyInviteToken } from "@formbricks/lib/jwt";
import { createMembership } from "@formbricks/lib/membership/service";
import { createOrganization, getOrganization } from "@formbricks/lib/organization/service";
import { UnknownError } from "@formbricks/types/errors";
import { TOrganizationRole, ZOrganizationRole } from "@formbricks/types/memberships";
import { ZUserLocale, ZUserName } from "@formbricks/types/user";
@@ -25,9 +28,29 @@ const ZCreateUserAction = z.object({
defaultOrganizationId: z.string().optional(),
defaultOrganizationRole: ZOrganizationRole.optional(),
emailVerificationDisabled: z.boolean().optional(),
turnstileToken: z
.string()
.optional()
.refine(
(token) => !IS_TURNSTILE_CONFIGURED || (IS_TURNSTILE_CONFIGURED && token),
"CAPTCHA verification required"
),
});
export const createUserAction = actionClient.schema(ZCreateUserAction).action(async ({ parsedInput }) => {
if (IS_TURNSTILE_CONFIGURED) {
if (!parsedInput.turnstileToken || !TURNSTILE_SECRET_KEY) {
captureFailedSignup(parsedInput.email, parsedInput.name);
throw new UnknownError("Server configuration error");
}
const isHuman = await verifyTurnstileToken(TURNSTILE_SECRET_KEY, parsedInput.turnstileToken);
if (!isHuman) {
captureFailedSignup(parsedInput.email, parsedInput.name);
throw new UnknownError("reCAPTCHA verification failed");
}
}
const { inviteToken, emailVerificationDisabled } = parsedInput;
const hashedPassword = await hashPassword(parsedInput.password);
const user = await createUser({

View File

@@ -3,6 +3,7 @@
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { createUserAction } from "@/modules/auth/signup/actions";
import { TermsPrivacyLinks } from "@/modules/auth/signup/components/terms-privacy-links";
import { captureFailedSignup } from "@/modules/auth/signup/lib/utils";
import { SSOOptions } from "@/modules/ee/sso/components/sso-options";
import { Button } from "@/modules/ui/components/button";
import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form";
@@ -16,7 +17,9 @@ import { useSearchParams } from "next/navigation";
import { useMemo, useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import toast from "react-hot-toast";
import Turnstile, { useTurnstile } from "react-turnstile";
import { z } from "zod";
import { env } from "@formbricks/lib/env";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { TUserLocale, ZUserName } from "@formbricks/types/user";
import { createEmailTokenAction } from "../../../auth/actions";
@@ -31,6 +34,8 @@ const ZSignupInput = z.object({
.regex(/^(?=.*[A-Z])(?=.*\d).*$/),
});
const turnstileSiteKey = env.NEXT_PUBLIC_TURNSTILE_SITE_KEY;
type TSignupInput = z.infer<typeof ZSignupInput>;
interface SignupFormProps {
@@ -49,6 +54,7 @@ interface SignupFormProps {
defaultOrganizationId?: string;
defaultOrganizationRole?: TOrganizationRole;
isSSOEnabled: boolean;
isTurnstileConfigured: boolean;
}
export const SignupForm = ({
@@ -67,12 +73,16 @@ export const SignupForm = ({
defaultOrganizationId,
defaultOrganizationRole,
isSSOEnabled,
isTurnstileConfigured,
}: SignupFormProps) => {
const [showLogin, setShowLogin] = useState(false);
const searchParams = useSearchParams();
const t = useTranslations();
const inviteToken = searchParams?.get("inviteToken");
const router = useRouter();
const [turnstileToken, setTurnstileToken] = useState<string>();
const turnstile = useTurnstile();
const callbackUrl = useMemo(() => {
if (inviteToken) {
@@ -93,6 +103,10 @@ export const SignupForm = ({
const handleSubmit = async (data: TSignupInput) => {
try {
if (isTurnstileConfigured && !turnstileToken) {
throw new Error(t("auth.signup.please_verify_captcha"));
}
const createUserResponse = await createUserAction({
name: data.name,
email: data.email,
@@ -102,6 +116,7 @@ export const SignupForm = ({
emailVerificationDisabled,
defaultOrganizationId,
defaultOrganizationRole,
turnstileToken,
});
if (createUserResponse?.data) {
@@ -114,10 +129,20 @@ export const SignupForm = ({
router.push(url);
} else {
if (isTurnstileConfigured) {
setTurnstileToken(undefined);
turnstile.reset();
}
const errorMessage = getFormattedErrorMessage(emailTokenActionResponse);
toast.error(errorMessage);
}
} else {
if (isTurnstileConfigured) {
setTurnstileToken(undefined);
turnstile.reset();
}
const errorMessage = getFormattedErrorMessage(createUserResponse);
toast.error(errorMessage);
}
@@ -204,6 +229,20 @@ export const SignupForm = ({
<PasswordChecks password={form.watch("password")} />
</div>
)}
{isTurnstileConfigured && showLogin && turnstileSiteKey && (
<Turnstile
sitekey={turnstileSiteKey}
onSuccess={(token) => {
setTurnstileToken(token);
}}
onError={() => {
setTurnstileToken(undefined);
toast.error(t("auth.signup.captcha_failed"));
captureFailedSignup(form.getValues("email"), form.getValues("name"));
}}
/>
)}
{showLogin && (
<Button
type="submit"

View File

@@ -0,0 +1,38 @@
import posthog from "posthog-js";
export const verifyTurnstileToken = async (secretKey: string, token: string): Promise<boolean> => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
secret: secretKey,
response: token,
}),
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`Verification failed with status: ${response.status}`);
}
const data = await response.json();
return data.success === true;
} catch (error) {
return false;
} finally {
clearTimeout(timeoutId);
}
};
export const captureFailedSignup = (email: string, name: string) => {
posthog.capture("TELEMETRY_FAILED_SIGNUP", {
email,
name,
});
};

View File

@@ -10,6 +10,7 @@ import {
EMAIL_VERIFICATION_DISABLED,
GITHUB_OAUTH_ENABLED,
GOOGLE_OAUTH_ENABLED,
IS_TURNSTILE_CONFIGURED,
OIDC_DISPLAY_NAME,
OIDC_OAUTH_ENABLED,
PRIVACY_URL,
@@ -53,6 +54,7 @@ export const SignupPage = async ({ searchParams: searchParamsProps }) => {
defaultOrganizationId={DEFAULT_ORGANIZATION_ID}
defaultOrganizationRole={DEFAULT_ORGANIZATION_ROLE}
isSSOEnabled={isSSOEnabled}
isTurnstileConfigured={IS_TURNSTILE_CONFIGURED}
/>
</FormWrapper>
</div>

View File

@@ -105,6 +105,7 @@
"react-hot-toast": "2.4.1",
"react-icons": "5.4.0",
"react-radio-group": "3.0.3",
"react-turnstile": "1.1.4",
"react-use": "17.6.0",
"redis": "4.7.0",
"sharp": "0.33.5",

View File

@@ -89,6 +89,10 @@ x-environment: &environment
############################################# OPTIONAL (OAUTH CONFIGURATION) #############################################
# Set the below from Cloudflare Turnstile if you want to enable turnstile in signups
# NEXT_PUBLIC_TURNSTILE_SITE_KEY:
# TURNSTILE_SECRET_KEY:
# Set the below from GitHub if you want to enable GitHub OAuth
# GITHUB_ID:
# GITHUB_SECRET:

View File

@@ -247,3 +247,7 @@ export const IS_AI_CONFIGURED = Boolean(
export const INTERCOM_SECRET_KEY = env.INTERCOM_SECRET_KEY;
export const IS_INTERCOM_CONFIGURED = Boolean(env.NEXT_PUBLIC_INTERCOM_APP_ID && INTERCOM_SECRET_KEY);
export const TURNSTILE_SECRET_KEY = env.TURNSTILE_SECRET_KEY;
export const IS_TURNSTILE_CONFIGURED = Boolean(env.NEXT_PUBLIC_TURNSTILE_SITE_KEY && TURNSTILE_SECRET_KEY);

View File

@@ -91,6 +91,7 @@ export const env = createEnv({
.url()
.optional()
.or(z.string().refine((str) => str === "")),
TURNSTILE_SECRET_KEY: z.string().optional(),
UPLOADS_DIR: z.string().min(1).optional(),
VERCEL_URL: z.string().optional(),
WEBAPP_URL: z.string().url().optional(),
@@ -117,6 +118,7 @@ export const env = createEnv({
NEXT_PUBLIC_POSTHOG_API_HOST: z.string().optional(),
NEXT_PUBLIC_SENTRY_DSN: z.string().optional(),
NEXT_PUBLIC_INTERCOM_APP_ID: z.string().optional(),
NEXT_PUBLIC_TURNSTILE_SITE_KEY: z.string().optional(),
},
/*
* Due to how Next.js bundles environment variables on Edge and Client,
@@ -173,6 +175,7 @@ export const env = createEnv({
NEXT_PUBLIC_POSTHOG_API_HOST: process.env.NEXT_PUBLIC_POSTHOG_API_HOST,
NEXT_PUBLIC_POSTHOG_API_KEY: process.env.NEXT_PUBLIC_POSTHOG_API_KEY,
NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN,
NEXT_PUBLIC_TURNSTILE_SITE_KEY: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY,
OPENTELEMETRY_LISTENER_URL: process.env.OPENTELEMETRY_LISTENER_URL,
NEXT_PUBLIC_INTERCOM_APP_ID: process.env.NEXT_PUBLIC_INTERCOM_APP_ID,
NOTION_OAUTH_CLIENT_ID: process.env.NOTION_OAUTH_CLIENT_ID,
@@ -206,6 +209,7 @@ export const env = createEnv({
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
TELEMETRY_DISABLED: process.env.TELEMETRY_DISABLED,
TURNSTILE_SECRET_KEY: process.env.TURNSTILE_SECRET_KEY,
TERMS_URL: process.env.TERMS_URL,
UPLOADS_DIR: process.env.UPLOADS_DIR,
VERCEL_URL: process.env.VERCEL_URL,

View File

@@ -56,12 +56,14 @@
"use_a_backup_code": "Einen Backup-Code verwenden"
},
"signup": {
"captcha_failed": "reCAPTCHA fehlgeschlagen",
"error": "Es ist ein Fehler aufgetreten, als Du Dich angemeldet hast",
"have_an_account": "Hast Du ein Konto?",
"log_in": "Einloggen",
"password_validation_contain_at_least_1_number": "Enthält mindestens 1 Zahl",
"password_validation_minimum_8_and_maximum_128_characters": "Mindestens 8 & höchstens 128 Zeichen",
"password_validation_uppercase_and_lowercase": "Mix aus Groß- und Kleinbuchstaben",
"please_verify_captcha": "Bitte bestätige reCAPTCHA",
"privacy_policy": "Datenschutzerklärung",
"terms_of_service": "Nutzungsbedingungen",
"title": "Erstelle dein Formbricks-Konto"

View File

@@ -56,12 +56,14 @@
"use_a_backup_code": "Use a backup code"
},
"signup": {
"captcha_failed": "Captcha failed",
"error": "An error occurred when signing you up",
"have_an_account": "Have an account?",
"log_in": "Log in",
"password_validation_contain_at_least_1_number": "Contain at least 1 number",
"password_validation_minimum_8_and_maximum_128_characters": "Minimum 8 & Maximum 128 characters",
"password_validation_uppercase_and_lowercase": "Mix of uppercase and lowercase",
"please_verify_captcha": "Please verify reCAPTCHA",
"privacy_policy": "Privacy Policy",
"terms_of_service": "Terms of Service",
"title": "Create your Formbricks account"

View File

@@ -56,12 +56,14 @@
"use_a_backup_code": "Utiliser un code de secours"
},
"signup": {
"captcha_failed": "Captcha échoué",
"error": "Une erreur est survenue lors de votre inscription.",
"have_an_account": "Avez-vous un compte ?",
"log_in": "Se connecter",
"password_validation_contain_at_least_1_number": "Contenir au moins 1 chiffre",
"password_validation_minimum_8_and_maximum_128_characters": "Minimum 8 et Maximum 128 caractères",
"password_validation_uppercase_and_lowercase": "Mélange de majuscules et de minuscules",
"please_verify_captcha": "Veuillez vérifier reCAPTCHA",
"privacy_policy": "Politique de confidentialité",
"terms_of_service": "Conditions d'utilisation",
"title": "Créez votre compte Formbricks"

View File

@@ -56,12 +56,14 @@
"use_a_backup_code": "Usar um código de backup"
},
"signup": {
"captcha_failed": "reCAPTCHA falhou",
"error": "Ocorreu um erro ao te cadastrar",
"have_an_account": "Já tem uma conta?",
"log_in": "Fazer login",
"password_validation_contain_at_least_1_number": "Conter pelo menos 1 número",
"password_validation_minimum_8_and_maximum_128_characters": "Mínimo 8 e Máximo 128 caracteres",
"password_validation_uppercase_and_lowercase": "mistura de maiúsculas e minúsculas",
"please_verify_captcha": "Por favor, verifique o reCAPTCHA",
"privacy_policy": "Política de Privacidade",
"terms_of_service": "Termos de Serviço",
"title": "Crie sua conta no Formbricks"

16
pnpm-lock.yaml generated
View File

@@ -622,6 +622,9 @@ importers:
react-radio-group:
specifier: 3.0.3
version: 3.0.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
react-turnstile:
specifier: 1.1.4
version: 1.1.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
react-use:
specifier: 17.6.0
version: 17.6.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@@ -11345,6 +11348,12 @@ packages:
peerDependencies:
react: ^18.0.0
react-turnstile@1.1.4:
resolution: {integrity: sha512-oluyRWADdsufCt5eMqacW4gfw8/csr6Tk+fmuaMx0PWMKP1SX1iCviLvD2D5w92eAzIYDHi/krUWGHhlfzxTpQ==}
peerDependencies:
react: '>= 16.13.1'
react-dom: '>= 16.13.1'
react-universal-interface@0.6.2:
resolution: {integrity: sha512-dg8yXdcQmvgR13RIlZbTRQOoUrDciFVoSBZILwjE2LFISxZZ8loVJKAkuzswl5js8BHda79bIb2b84ehU8IjXw==}
peerDependencies:
@@ -26594,6 +26603,11 @@ snapshots:
prop-types: 15.8.1
react: 19.0.0
react-turnstile@1.1.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
react-universal-interface@0.6.2(react@19.0.0)(tslib@2.8.1):
dependencies:
react: 19.0.0
@@ -28862,4 +28876,4 @@ snapshots:
react: 19.0.0
use-sync-external-store: 1.2.2(react@19.0.0)
zwitch@2.0.4: {}
zwitch@2.0.4: {}

View File

@@ -129,6 +129,7 @@
"NEXT_PUBLIC_FORMBRICKS_COM_API_HOST",
"NEXT_PUBLIC_FORMBRICKS_COM_ENVIRONMENT_ID",
"NEXT_PUBLIC_FORMBRICKS_COM_DOCS_FEEDBACK_SURVEY_ID",
"NEXT_PUBLIC_TURNSTILE_SITE_KEY",
"OPENTELEMETRY_LISTENER_URL",
"NEXT_RUNTIME",
"NEXTAUTH_SECRET",
@@ -168,6 +169,7 @@
"SURVEYS_PACKAGE_MODE",
"SURVEYS_PACKAGE_BUILD",
"TELEMETRY_DISABLED",
"TURNSTILE_SECRET_KEY",
"TERMS_URL",
"UPLOADS_DIR",
"VERCEL",