diff --git a/.env.docker b/.env.docker index 106955bcfb..ba19223174 100644 --- a/.env.docker +++ b/.env.docker @@ -101,3 +101,7 @@ GOOGLE_CLIENT_SECRET= # Cron Secret CRON_SECRET= + +# Encryption key +# You can use: `openssl rand -base64 16` to generate one +FORMBRICKS_ENCRYPTION_KEY= \ No newline at end of file diff --git a/.env.example b/.env.example index 11c21391d3..1a4c51ccf9 100644 --- a/.env.example +++ b/.env.example @@ -104,4 +104,8 @@ NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID= # Cron Secret CRON_SECRET= -*/ \ No newline at end of file +*/ + +# Encryption key +# You can use: `openssl rand -base64 16` to generate one +FORMBRICKS_ENCRYPTION_KEY= \ No newline at end of file diff --git a/apps/formbricks-com/app/docs/self-hosting/from-source/page.mdx b/apps/formbricks-com/app/docs/self-hosting/from-source/page.mdx index 137e14b120..f6209619e7 100644 --- a/apps/formbricks-com/app/docs/self-hosting/from-source/page.mdx +++ b/apps/formbricks-com/app/docs/self-hosting/from-source/page.mdx @@ -57,7 +57,7 @@ These variables must also be provided at runtime. | Variable | Description | Required | Default | | --------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------- | | WEBAPP_URL | Base URL of the site. | required | `http://localhost:3000` | -| SURVEY_BASE_URL | Base URL of the link surveys. | required | `http://localhost:3000/s/` | +| SURVEY_BASE_URL | Base URL of the link surveys. | required | `http://localhost:3000/s/` | | DATABASE_URL | Database URL with credentials. | required | `postgresql://postgres:postgres@postgres:5432/formbricks?schema=public` | | PRISMA_GENERATE_DATAPROXY | Enables a dedicated connection pool for Prisma using Prisma Data Proxy. Uncomment to enable. | optional | | | NEXTAUTH_SECRET | Secret for NextAuth, used for session signing and encryption. | required | (Generated by the user) | diff --git a/apps/web/app/(app)/environments/[environmentId]/actions.ts b/apps/web/app/(app)/environments/[environmentId]/actions.ts index 60ce19b887..dc4caa403c 100644 --- a/apps/web/app/(app)/environments/[environmentId]/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/actions.ts @@ -164,7 +164,9 @@ export async function duplicateSurveyAction(environmentId: string, surveyId: str surveyClosedMessage: existingSurvey.surveyClosedMessage ? JSON.parse(JSON.stringify(existingSurvey.surveyClosedMessage)) : prismaClient.JsonNull, - + singleUse: existingSurvey.singleUse + ? JSON.parse(JSON.stringify(existingSurvey.singleUse)) + : prismaClient.JsonNull, verifyEmail: existingSurvey.verifyEmail ? JSON.parse(JSON.stringify(existingSurvey.verifyEmail)) : prismaClient.JsonNull, @@ -295,6 +297,7 @@ export async function copyToOtherEnvironmentAction( }, }, surveyClosedMessage: existingSurvey.surveyClosedMessage ?? prismaClient.JsonNull, + singleUse: existingSurvey.singleUse ?? prismaClient.JsonNull, verifyEmail: existingSurvey.verifyEmail ?? prismaClient.JsonNull, }, }); diff --git a/apps/web/app/(app)/environments/[environmentId]/people/[personId]/(responseSection)/ResponsesFeed.tsx b/apps/web/app/(app)/environments/[environmentId]/people/[personId]/(responseSection)/ResponsesFeed.tsx index 243a5bb899..2e73a97846 100644 --- a/apps/web/app/(app)/environments/[environmentId]/people/[personId]/(responseSection)/ResponsesFeed.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/people/[personId]/(responseSection)/ResponsesFeed.tsx @@ -3,6 +3,7 @@ import EmptySpaceFiller from "@/components/shared/EmptySpaceFiller"; import SurveyStatusIndicator from "@/components/shared/SurveyStatusIndicator"; import { TResponseWithSurvey } from "@formbricks/types/v1/responses"; import Link from "next/link"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui"; import { TEnvironment } from "@formbricks/types/v1/environment"; export default function ResponseFeed({ @@ -63,6 +64,7 @@ export default function ResponseFeed({ /> +
{response.survey.questions.map((question) => (
@@ -75,6 +77,18 @@ export default function ResponseFeed({
))}
+
+ + + +

{response.singleUseId}

+
+ +

Single Use Id

+
+
+
+
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/SurveyDropDownMenu.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/SurveyDropDownMenu.tsx index d8e52e8df7..4d08626e7c 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/SurveyDropDownMenu.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/SurveyDropDownMenu.tsx @@ -36,6 +36,7 @@ interface SurveyDropDownMenuProps { environment: TEnvironment; otherEnvironment: TEnvironment; surveyBaseUrl: string; + singleUseId?: string; } export default function SurveyDropDownMenu({ @@ -44,6 +45,7 @@ export default function SurveyDropDownMenu({ environment, otherEnvironment, surveyBaseUrl, + singleUseId, }: SurveyDropDownMenuProps) { const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false); const [loading, setLoading] = useState(false); @@ -155,7 +157,11 @@ export default function SurveyDropDownMenu({ Preview Survey @@ -165,8 +171,11 @@ export default function SurveyDropDownMenu({ - - {showLinkModal && ( + {showLinkModal && isSingleUse && singleUseIds ? ( + + ) : ( void; + singleUseIds: string[]; +} + +export default function LinkSingleUseSurveyModal({ + survey, + open, + setOpen, + singleUseIds, +}: LinkSingleUseSurveyModalProps) { + const defaultSurveyUrl = `${window.location.protocol}//${window.location.host}/s/${survey.id}`; + const [selectedSingleUseIds, setSelectedSingleIds] = useState([]); + + const linkTextRef = useRef(null); + const router = useRouter(); + + const handleLinkOnClick = (index: number) => { + setSelectedSingleIds([...selectedSingleUseIds, index]); + const surveyUrl = `${defaultSurveyUrl}?suId=${singleUseIds[index]}`; + navigator.clipboard.writeText(surveyUrl); + toast.success("URL copied to clipboard!"); + }; + + return ( + + +
+
+
+

Your survey is ready!

+
+

+ Here are 5 single use links to let people answer your survey: +

+
+ {singleUseIds.map((singleUseId, index) => { + const isSelected = selectedSingleUseIds.includes(index); + return ( +
{ + if (!isSelected) { + handleLinkOnClick(index); + } + }}> + {truncateMiddle(`${defaultSurveyUrl}?suId=${singleUseId}`, 48)} + {isSelected ? ( + + ) : ( + + )} +
+ ); + })} +
+
+
+ + + +
+
+
+
+ ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage.tsx index 0cf2a083bc..f789d35872 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage.tsx @@ -7,6 +7,7 @@ import { useEffect, useState } from "react"; import toast from "react-hot-toast"; import ShareEmbedSurvey from "./ShareEmbedSurvey"; import { TProduct } from "@formbricks/types/v1/product"; +import LinkSingleUseSurveyModal from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/LinkSingleUseSurveyModal"; import { TEnvironment } from "@formbricks/types/v1/environment"; interface SummaryMetadataProps { @@ -14,6 +15,7 @@ interface SummaryMetadataProps { survey: TSurvey; surveyBaseUrl: string; product: TProduct; + singleUseIds?: string[]; } export default function SuccessMessage({ @@ -21,7 +23,10 @@ export default function SuccessMessage({ survey, surveyBaseUrl, product, + singleUseIds, }: SummaryMetadataProps) { + const isSingleUse = survey.singleUse?.enabled ?? false; + const searchParams = useSearchParams(); const [showLinkModal, setShowLinkModal] = useState(false); const [confetti, setConfetti] = useState(false); @@ -52,7 +57,14 @@ export default function SuccessMessage({ return ( <> - {showLinkModal && ( + {showLinkModal && isSingleUse && singleUseIds ? ( + + ) : ( { @@ -56,6 +58,7 @@ const SummaryPage = ({ survey={survey} surveyId={surveyId} surveyBaseUrl={surveyBaseUrl} + singleUseIds={singleUseIds} product={product} /> { + return Array(5) + .fill(null) + .map(() => { + return generateSurveySingleUseId(isEncrypted); + }); +}; export default async function Page({ params }) { const session = await getServerSession(authOptions); if (!session) { throw new Error("Unauthorized"); } + const [{ responses, survey }, environment] = await Promise.all([ getAnalysisData(params.surveyId, params.environmentId), getEnvironment(params.environmentId), ]); + const isSingleUseSurvey = survey.singleUse?.enabled ?? false; + const singleUseIds = generateSingleUseIds(survey.singleUse?.isEncrypted ?? false); if (!environment) { throw new Error("Environment not found"); } @@ -38,6 +50,7 @@ export default async function Page({ params }) { survey={survey} surveyId={params.surveyId} surveyBaseUrl={SURVEY_BASE_URL} + singleUseIds={isSingleUseSurvey ? singleUseIds : undefined} product={product} environmentTags={tags} /> diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/SummaryHeader.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/SummaryHeader.tsx index 65a54b59ff..5d6d861b1e 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/SummaryHeader.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/SummaryHeader.tsx @@ -31,8 +31,16 @@ interface SummaryHeaderProps { survey: TSurvey; surveyBaseUrl: string; product: TProduct; + singleUseIds?: string[]; } -const SummaryHeader = ({ surveyId, environment, survey, surveyBaseUrl, product }: SummaryHeaderProps) => { +const SummaryHeader = ({ + surveyId, + environment, + survey, + surveyBaseUrl, + product, + singleUseIds, +}: SummaryHeaderProps) => { const router = useRouter(); const isCloseOnDateEnabled = survey.closeOnDate !== null; @@ -47,7 +55,12 @@ const SummaryHeader = ({ surveyId, environment, survey, surveyBaseUrl, product }
{survey.type === "link" && ( - + )} {(environment?.widgetSetupCompleted || survey.type === "link") && survey?.status !== "draft" ? ( @@ -75,6 +88,7 @@ const SummaryHeader = ({ surveyId, environment, survey, surveyBaseUrl, product } survey={survey} surveyBaseUrl={surveyBaseUrl} product={product} + singleUseIds={singleUseIds} /> @@ -157,6 +171,7 @@ const SummaryHeader = ({ surveyId, environment, survey, surveyBaseUrl, product } survey={survey} surveyBaseUrl={surveyBaseUrl} product={product} + singleUseIds={singleUseIds} />
); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/ResponseOptionsCard.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/ResponseOptionsCard.tsx index 1d81e55082..23c05f558b 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/ResponseOptionsCard.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/ResponseOptionsCard.tsx @@ -1,7 +1,17 @@ "use client"; import { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys"; -import { AdvancedOptionToggle, DatePicker, Input, Label } from "@formbricks/ui"; +import { + AdvancedOptionToggle, + DatePicker, + Input, + Label, + Switch, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@formbricks/ui"; import { CheckCircleIcon } from "@heroicons/react/24/solid"; import * as Collapsible from "@radix-ui/react-collapsible"; import { useEffect, useState } from "react"; @@ -10,9 +20,14 @@ import toast from "react-hot-toast"; interface ResponseOptionsCardProps { localSurvey: TSurveyWithAnalytics; setLocalSurvey: (survey: TSurveyWithAnalytics) => void; + isEncryptionKeySet: boolean; } -export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: ResponseOptionsCardProps) { +export default function ResponseOptionsCard({ + localSurvey, + setLocalSurvey, + isEncryptionKeySet, +}: ResponseOptionsCardProps) { const [open, setOpen] = useState(false); const autoComplete = localSurvey.autoComplete !== null; const [redirectToggle, setRedirectToggle] = useState(false); @@ -27,6 +42,12 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res subheading: "This free & open-source survey has been closed", }); + const [singleUseMessage, setSingleUseMessage] = useState({ + heading: "The survey has already been answered.", + subheading: "You can only use this link once.", + }); + + const [singleUseEncryption, setSingleUseEncryption] = useState(isEncryptionKeySet); const [verifyEmailSurveyDetails, setVerifyEmailSurveyDetails] = useState({ name: "", subheading: "", @@ -104,6 +125,53 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res setLocalSurvey({ ...localSurvey, surveyClosedMessage: message }); }; + const handleSingleUseSurveyToggle = () => { + if (!localSurvey.singleUse?.enabled) { + setLocalSurvey({ + ...localSurvey, + singleUse: { enabled: true, ...singleUseMessage, isEncrypted: singleUseEncryption }, + }); + } else { + setLocalSurvey({ ...localSurvey, singleUse: { enabled: false, isEncrypted: false } }); + } + }; + + const handleSingleUseSurveyMessageChange = ({ + heading, + subheading, + }: { + heading?: string; + subheading?: string; + }) => { + const message = { + heading: heading ?? singleUseMessage.heading, + subheading: subheading ?? singleUseMessage.subheading, + }; + + const localSurveySingleUseEnabled = localSurvey.singleUse?.enabled ?? false; + setSingleUseMessage(message); + setLocalSurvey({ + ...localSurvey, + singleUse: { enabled: localSurveySingleUseEnabled, ...message, isEncrypted: singleUseEncryption }, + }); + }; + + const hangleSingleUseEncryptionToggle = () => { + if (!singleUseEncryption) { + setSingleUseEncryption(true); + setLocalSurvey({ + ...localSurvey, + singleUse: { enabled: true, ...singleUseMessage, isEncrypted: true }, + }); + } else { + setSingleUseEncryption(false); + setLocalSurvey({ + ...localSurvey, + singleUse: { enabled: true, ...singleUseMessage, isEncrypted: false }, + }); + } + }; + const handleVerifyEmailSurveyDetailsChange = ({ name, subheading, @@ -134,6 +202,14 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res setSurveyClosedMessageToggle(true); } + if (localSurvey.singleUse?.enabled) { + setSingleUseMessage({ + heading: localSurvey.singleUse.heading ?? singleUseMessage.heading, + subheading: localSurvey.singleUse.subheading ?? singleUseMessage.subheading, + }); + setSingleUseEncryption(localSurvey.singleUse.isEncrypted); + } + if (localSurvey.verifyEmail) { setVerifyEmailSurveyDetails({ name: localSurvey.verifyEmail.name!, @@ -302,6 +378,81 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res + {/* Single User Survey Options */} + +
+
+
+ +
+
    +
  • + Blocks survey if the survey URL has no Single Use Id (suId). +
  • +
  • + Blocks survey if a submission with the Single Use Id (suId) in the URL exists already. +
  • +
+ + handleSingleUseSurveyMessageChange({ heading: e.target.value })} + /> + + + handleSingleUseSurveyMessageChange({ subheading: e.target.value })} + /> + +
+ + + +
+ + +
+
+ {!isEncryptionKeySet && ( + +

+ FORMBRICKS_ENCRYPTION_KEY needs to be set to enable this feature. +

+
+ )} +
+
+
+
+
+
+ + {/* Verify Email Section */} void; actionClasses: TActionClass[]; attributeClasses: TAttributeClass[]; + isEncryptionKeySet: boolean; } export default function SettingsView({ @@ -22,6 +23,7 @@ export default function SettingsView({ setLocalSurvey, actionClasses, attributeClasses, + isEncryptionKeySet, }: SettingsViewProps) { return (
@@ -41,7 +43,11 @@ export default function SettingsView({ actionClasses={actionClasses} /> - + ("questions"); const [activeQuestionId, setActiveQuestionId] = useState(null); @@ -88,6 +90,7 @@ export default function SurveyEditor({ setLocalSurvey={setLocalSurvey} actionClasses={actionClasses} attributeClasses={attributeClasses} + isEncryptionKeySet={isEncryptionKeySet} /> )} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/SurveyMenuBar.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/SurveyMenuBar.tsx index 5fe3abef71..7f3e764e16 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/SurveyMenuBar.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/SurveyMenuBar.tsx @@ -155,6 +155,7 @@ export default function SurveyMenuBar({ } else { router.push(`/environments/${environment.id}/surveys`); } + router.refresh(); } } catch (e) { console.error(e); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/page.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/page.tsx index 1223a5a437..9376c15f6a 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/page.tsx @@ -1,6 +1,6 @@ export const revalidate = REVALIDATION_INTERVAL; import React from "react"; -import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants"; +import { FORMBRICKS_ENCRYPTION_KEY, REVALIDATION_INTERVAL } from "@formbricks/lib/constants"; import SurveyEditor from "./SurveyEditor"; import { getSurveyWithAnalytics } from "@formbricks/lib/services/survey"; import { getProductByEnvironmentId } from "@formbricks/lib/services/product"; @@ -17,6 +17,7 @@ export default async function SurveysEditPage({ params }) { getActionClasses(params.environmentId), getAttributeClasses(params.environmentId), ]); + const isEncryptionKeySet = !!FORMBRICKS_ENCRYPTION_KEY; if (!survey || !environment || !actionClasses || !attributeClasses || !product) { return ; } @@ -29,6 +30,7 @@ export default async function SurveysEditPage({ params }) { environment={environment} actionClasses={actionClasses} attributeClasses={attributeClasses} + isEncryptionKeySet={isEncryptionKeySet} /> ); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/templates/templates.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/templates/templates.ts index 656893783f..ee382b6fcb 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/templates/templates.ts +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/templates/templates.ts @@ -2062,4 +2062,5 @@ export const minimalSurvey: TSurvey = { surveyClosedMessage: { enabled: false, }, + singleUse: null, }; diff --git a/apps/web/app/api/v1/js/surveys.ts b/apps/web/app/api/v1/js/surveys.ts index 21095d928a..4b09a5502e 100644 --- a/apps/web/app/api/v1/js/surveys.ts +++ b/apps/web/app/api/v1/js/surveys.ts @@ -164,6 +164,7 @@ export const getSurveys = async (environmentId: string, person: TPerson): Promis }) .map((survey) => ({ ...survey, + singleUse: survey.singleUse ? JSON.parse(JSON.stringify(survey.singleUse)) : null, triggers: survey.triggers.map((trigger) => trigger.eventClass), attributeFilters: survey.attributeFilters.map((af) => ({ ...af, diff --git a/apps/web/app/s/[surveyId]/LinkSurvey.tsx b/apps/web/app/s/[surveyId]/LinkSurvey.tsx index 3ea44c6f74..242b04d463 100644 --- a/apps/web/app/s/[surveyId]/LinkSurvey.tsx +++ b/apps/web/app/s/[surveyId]/LinkSurvey.tsx @@ -12,7 +12,8 @@ import { useSearchParams } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; import VerifyEmail from "@/app/s/[surveyId]/VerifyEmail"; import { getPrefillResponseData } from "@/app/s/[surveyId]/prefilling"; -import { TResponseData } from "@formbricks/types/v1/responses"; +import { TResponse, TResponseData } from "@formbricks/types/v1/responses"; +import SurveyLinkUsed from "@/app/s/[surveyId]/SurveyLinkUsed"; interface LinkSurveyProps { survey: TSurvey; @@ -20,6 +21,8 @@ interface LinkSurveyProps { personId?: string; emailVerificationStatus?: string; prefillAnswer?: string; + singleUseId?: string; + singleUseResponse?: TResponse; webAppUrl: string; } @@ -29,11 +32,15 @@ export default function LinkSurvey({ personId, emailVerificationStatus, prefillAnswer, + singleUseId, + singleUseResponse, webAppUrl, }: LinkSurveyProps) { + const responseId = singleUseResponse?.id; const searchParams = useSearchParams(); const isPreview = searchParams?.get("preview") === "true"; - const [surveyState, setSurveyState] = useState(new SurveyState(survey.id)); + // pass in the responseId if the survey is a single use survey, ensures survey state is updated with the responseId + const [surveyState, setSurveyState] = useState(new SurveyState(survey.id, singleUseId, responseId)); const [activeQuestionId, setActiveQuestionId] = useState(survey.questions[0].id); const prefillResponseData: TResponseData | undefined = prefillAnswer ? getPrefillResponseData(survey.questions[0], survey, prefillAnswer) @@ -56,6 +63,13 @@ export default function LinkSurvey({ [personId, webAppUrl] ); const [autoFocus, setAutofocus] = useState(false); + const hasFinishedSingleUseResponse = useMemo(() => { + if (singleUseResponse && singleUseResponse.finished) { + return true; + } + return false; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // Not in an iframe, enable autofocus on input fields. useEffect(() => { @@ -68,6 +82,10 @@ export default function LinkSurvey({ responseQueue.updateSurveyState(surveyState); }, [responseQueue, surveyState]); + if (!surveyState.isResponseFinished() && hasFinishedSingleUseResponse) { + return ; + } + if (emailVerificationStatus && emailVerificationStatus !== "verified") { if (emailVerificationStatus === "fishy") { return ; diff --git a/apps/web/app/s/[surveyId]/SurveyInactive.tsx b/apps/web/app/s/[surveyId]/SurveyInactive.tsx index 1796b10665..233fca2be8 100644 --- a/apps/web/app/s/[surveyId]/SurveyInactive.tsx +++ b/apps/web/app/s/[surveyId]/SurveyInactive.tsx @@ -1,6 +1,6 @@ import { TSurveyClosedMessage } from "@formbricks/types/v1/surveys"; import { Button } from "@formbricks/ui"; -import { CheckCircleIcon, PauseCircleIcon } from "@heroicons/react/24/solid"; +import { CheckCircleIcon, PauseCircleIcon, QuestionMarkCircleIcon } from "@heroicons/react/24/solid"; import Image from "next/image"; import Link from "next/link"; import footerLogo from "./footerlogo.svg"; @@ -9,17 +9,19 @@ const SurveyInactive = ({ status, surveyClosedMessage, }: { - status: "paused" | "completed"; + status: "paused" | "completed" | "link invalid"; surveyClosedMessage?: TSurveyClosedMessage | null; }) => { const icons = { paused: , completed: , + "link invalid": , }; const descriptions = { paused: "This free & open-source survey is temporarily paused.", completed: "This free & open-source survey has been closed.", + "link invalid": "This survey can only be taken by invitation.", }; return ( @@ -31,12 +33,11 @@ const SurveyInactive = ({ {status === "completed" && surveyClosedMessage ? surveyClosedMessage.heading : `Survey ${status}.`}

- {" "} {status === "completed" && surveyClosedMessage ? surveyClosedMessage.subheading : descriptions[status]}

- {!(status === "completed" && surveyClosedMessage) && ( + {!(status === "completed" && surveyClosedMessage) && status !== "link invalid" && ( diff --git a/apps/web/app/s/[surveyId]/SurveyLinkUsed.tsx b/apps/web/app/s/[surveyId]/SurveyLinkUsed.tsx new file mode 100644 index 0000000000..c9f694622b --- /dev/null +++ b/apps/web/app/s/[surveyId]/SurveyLinkUsed.tsx @@ -0,0 +1,35 @@ +import { SurveySingleUse } from "@formbricks/types/surveys"; +import { CheckCircleIcon } from "@heroicons/react/24/solid"; +import Image from "next/image"; +import Link from "next/link"; +import footerLogo from "./footerlogo.svg"; + +type SurveyLinkUsedProps = { + singleUseMessage: Omit | null; +}; + +const SurveyLinkUsed = ({ singleUseMessage }: SurveyLinkUsedProps) => { + const defaultHeading = "The survey has already been answered."; + const defaultSubheading = "You can only use this link once."; + return ( +
+
+
+ +

+ {!!singleUseMessage?.heading ? singleUseMessage?.heading : defaultHeading} +

+

+ {!!singleUseMessage?.subheading ? singleUseMessage?.subheading : defaultSubheading} +

+
+
+ + Brand logo + +
+
+ ); +}; + +export default SurveyLinkUsed; diff --git a/apps/web/app/s/[surveyId]/page.tsx b/apps/web/app/s/[surveyId]/page.tsx index c9fba5ec42..1b7ff9bb5c 100644 --- a/apps/web/app/s/[surveyId]/page.tsx +++ b/apps/web/app/s/[surveyId]/page.tsx @@ -9,9 +9,26 @@ import { getSurvey } from "@formbricks/lib/services/survey"; import { getEmailVerificationStatus } from "./helpers"; import { checkValidity } from "@/app/s/[surveyId]/prefilling"; import { notFound } from "next/navigation"; +import { getResponseBySingleUseId } from "@formbricks/lib/response/service"; +import { TResponse } from "@formbricks/types/v1/responses"; +import { validateSurveySingleUseId } from "@/lib/singleUseSurveys"; -export default async function LinkSurveyPage({ params, searchParams }) { +interface LinkSurveyPageProps { + params: { + surveyId: string; + }; + searchParams: { + suId?: string; + userId?: string; + verify?: string; + }; +} + +export default async function LinkSurveyPage({ params, searchParams }: LinkSurveyPageProps) { const survey = await getSurvey(params.surveyId); + const suId = searchParams.suId; + const isSingleUseSurvey = survey?.singleUse?.enabled; + const isSingleUseSurveyEncrypted = survey?.singleUse?.isEncrypted; if (!survey || survey.type !== "link" || survey.status === "draft") { notFound(); @@ -30,14 +47,41 @@ export default async function LinkSurveyPage({ params, searchParams }) { ); } + let singleUseId: string | undefined = undefined; + if (isSingleUseSurvey) { + // check if the single use id is present for single use surveys + if (!suId) { + return ; + } + + // if encryption is enabled, validate the single use id + let validatedSingleUseId: string | undefined = undefined; + if (isSingleUseSurveyEncrypted) { + validatedSingleUseId = validateSurveySingleUseId(suId); + if (!validatedSingleUseId) { + return ; + } + } + // if encryption is disabled, use the suId as is + singleUseId = validatedSingleUseId ?? suId; + } + + let singleUseResponse: TResponse | undefined = undefined; + if (isSingleUseSurvey) { + singleUseResponse = (await getResponseBySingleUseId(survey.id, singleUseId)) ?? undefined; + } + // verify email: Check if the survey requires email verification - let emailVerificationStatus; + let emailVerificationStatus: string | undefined = undefined; if (survey.verifyEmail) { const token = searchParams && Object.keys(searchParams).length !== 0 && searchParams.hasOwnProperty("verify") ? searchParams.verify : undefined; - emailVerificationStatus = await getEmailVerificationStatus(survey.id, token); + + if (token) { + emailVerificationStatus = await getEmailVerificationStatus(survey.id, token); + } } // get product and person @@ -59,6 +103,8 @@ export default async function LinkSurveyPage({ params, searchParams }) { personId={person?.id} emailVerificationStatus={emailVerificationStatus} prefillAnswer={isPrefilledAnswerValid ? prefillAnswer : null} + singleUseId={isSingleUseSurvey ? singleUseId : undefined} + singleUseResponse={singleUseResponse ? singleUseResponse : undefined} webAppUrl={WEBAPP_URL} /> ); diff --git a/apps/web/env.mjs b/apps/web/env.mjs index e944c85b9b..4264843f21 100644 --- a/apps/web/env.mjs +++ b/apps/web/env.mjs @@ -70,6 +70,7 @@ export const env = createEnv({ NEXT_PUBLIC_POSTHOG_API_KEY: z.string().optional(), NEXT_PUBLIC_POSTHOG_API_HOST: z.string().optional(), NEXT_PUBLIC_SENTRY_DSN: z.string().optional(), + FORMBRICKS_ENCRYPTION_KEY: z.string().length(24).or(z.string().length(0)).optional(), }, /* * Due to how Next.js bundles environment variables on Edge and Client, @@ -114,6 +115,7 @@ export const env = createEnv({ IS_FORMBRICKS_CLOUD: process.env.IS_FORMBRICKS_CLOUD, NEXT_PUBLIC_POSTHOG_API_KEY: process.env.NEXT_PUBLIC_POSTHOG_API_KEY, NEXT_PUBLIC_POSTHOG_API_HOST: process.env.NEXT_PUBLIC_POSTHOG_API_HOST, + FORMBRICKS_ENCRYPTION_KEY: process.env.FORMBRICKS_ENCRYPTION_KEY, VERCEL_URL: process.env.VERCEL_URL, SURVEY_BASE_URL: process.env.SURVEY_BASE_URL, NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN, diff --git a/apps/web/lib/singleUseSurveys.ts b/apps/web/lib/singleUseSurveys.ts new file mode 100644 index 0000000000..dff1e89a94 --- /dev/null +++ b/apps/web/lib/singleUseSurveys.ts @@ -0,0 +1,33 @@ +import { FORMBRICKS_ENCRYPTION_KEY } from "@formbricks/lib/constants"; +import { decryptAES128, encryptAES128 } from "@formbricks/lib/crypto"; +import cuid2 from "@paralleldrive/cuid2"; + +// generate encrypted single use id for the survey +export const generateSurveySingleUseId = (isEncrypted: boolean): string => { + const cuid = cuid2.createId(); + if (!isEncrypted) { + return cuid; + } + if (!FORMBRICKS_ENCRYPTION_KEY) { + throw new Error("FORMBRICKS_ENCRYPTION_KEY is not defined"); + } + const encryptedCuid = encryptAES128(FORMBRICKS_ENCRYPTION_KEY, cuid); + return encryptedCuid; +}; + +// validate the survey single use id +export const validateSurveySingleUseId = (surveySingleUseId: string): string | undefined => { + if (!FORMBRICKS_ENCRYPTION_KEY) { + throw new Error("FORMBRICKS_ENCRYPTION_KEY is not defined"); + } + try { + const decryptedCuid = decryptAES128(FORMBRICKS_ENCRYPTION_KEY!, surveySingleUseId); + if (cuid2.isCuid(decryptedCuid)) { + return decryptedCuid; + } else { + return undefined; + } + } catch (error) { + return undefined; + } +}; diff --git a/apps/web/pages/api/v1/client/environments/[environmentId]/responses/[responseId]/index.ts b/apps/web/pages/api/v1/client/environments/[environmentId]/responses/[responseId]/index.ts index 56b184d45c..8f73511b48 100644 --- a/apps/web/pages/api/v1/client/environments/[environmentId]/responses/[responseId]/index.ts +++ b/apps/web/pages/api/v1/client/environments/[environmentId]/responses/[responseId]/index.ts @@ -64,6 +64,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse) data: true, meta: true, personAttributes: true, + singleUseId: true, person: { select: { id: true, diff --git a/apps/web/pages/api/v1/client/environments/[environmentId]/responses/index.ts b/apps/web/pages/api/v1/client/environments/[environmentId]/responses/index.ts index 6cbdbbd020..0245f53184 100644 --- a/apps/web/pages/api/v1/client/environments/[environmentId]/responses/index.ts +++ b/apps/web/pages/api/v1/client/environments/[environmentId]/responses/index.ts @@ -109,6 +109,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse) data: true, meta: true, personAttributes: true, + singleUseId: true, person: { select: { id: true, diff --git a/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/duplicate/[targetEnvironmentId].ts b/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/duplicate/[targetEnvironmentId].ts index dead5819ef..97e77d3695 100644 --- a/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/duplicate/[targetEnvironmentId].ts +++ b/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/duplicate/[targetEnvironmentId].ts @@ -146,6 +146,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse) }, }, surveyClosedMessage: existingSurvey.surveyClosedMessage ?? prismaClient.JsonNull, + singleUse: existingSurvey.singleUse ?? prismaClient.JsonNull, verifyEmail: existingSurvey.verifyEmail ?? prismaClient.JsonNull, }, }); diff --git a/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/duplicate/index.ts b/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/duplicate/index.ts index 36b10ad539..55757e850c 100644 --- a/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/duplicate/index.ts +++ b/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/duplicate/index.ts @@ -66,6 +66,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse) }, }, surveyClosedMessage: existingSurvey.surveyClosedMessage ?? prismaClient.JsonNull, + singleUse: existingSurvey.singleUse ?? prismaClient.JsonNull, verifyEmail: existingSurvey.verifyEmail ?? prismaClient.JsonNull, }, }); diff --git a/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/index.ts b/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/index.ts index 02fde3133d..f5650ff72c 100644 --- a/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/index.ts +++ b/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/index.ts @@ -98,6 +98,10 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse) body.surveyClosedMessage = prismaClient.JsonNull; } + if (!body.singleUse) { + body.singleUse = prismaClient.JsonNull; + } + if (!body.verifyEmail) { body.verifyEmail = prismaClient.JsonNull; } @@ -230,6 +234,10 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse) data.surveyClosedMessage = prismaClient.JsonNull; } + if (data.singleUse === null) { + data.singleUse = prismaClient.JsonNull; + } + if (data.verifyEmail === null) { data.verifyEmail = prismaClient.JsonNull; } diff --git a/packages/database/jsonTypes.ts b/packages/database/jsonTypes.ts index 15cfe1f09c..d0f5b170be 100644 --- a/packages/database/jsonTypes.ts +++ b/packages/database/jsonTypes.ts @@ -4,6 +4,7 @@ import { TResponsePersonAttributes, TResponseData, TResponseMeta } from "@formbr import { TSurveyClosedMessage, TSurveyQuestions, + TSurveySingleUse, TSurveyThankYouCard, TSurveyVerifyEmail, } from "@formbricks/types/v1/surveys"; @@ -20,6 +21,7 @@ declare global { export type SurveyQuestions = TSurveyQuestions; export type SurveyThankYouCard = TSurveyThankYouCard; export type SurveyClosedMessage = TSurveyClosedMessage; + export type SurveySingleUse = TSurveySingleUse; export type SurveyVerifyEmail = TSurveyVerifyEmail; export type UserNotificationSettings = TUserNotificationSettings; } diff --git a/packages/database/migrations/20231003113835_add_single_use_id_to_survey/migration.sql b/packages/database/migrations/20231003113835_add_single_use_id_to_survey/migration.sql new file mode 100644 index 0000000000..f73c2ae2aa --- /dev/null +++ b/packages/database/migrations/20231003113835_add_single_use_id_to_survey/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - A unique constraint covering the columns `[surveyId,singleUseId]` on the table `Response` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "Response" ADD COLUMN "singleUseId" TEXT; + +-- AlterTable +ALTER TABLE "Survey" ADD COLUMN "singleUse" JSONB DEFAULT '{"enabled": false, "isEncrypted": true}'; + +-- CreateIndex +CREATE UNIQUE INDEX "Response_surveyId_singleUseId_key" ON "Response"("surveyId", "singleUseId"); diff --git a/packages/database/schema.prisma b/packages/database/schema.prisma index 0b2305104a..479dd5816d 100644 --- a/packages/database/schema.prisma +++ b/packages/database/schema.prisma @@ -113,6 +113,10 @@ model Response { /// @zod.custom(imports.ZResponsePersonAttributes) /// [ResponsePersonAttributes] personAttributes Json? + // singleUseId, used to prevent multiple responses + singleUseId String? + + @@unique([surveyId, singleUseId]) } model ResponseNote { @@ -245,6 +249,9 @@ model Survey { /// @zod.custom(imports.ZSurveyClosedMessage) /// [SurveyClosedMessage] surveyClosedMessage Json? + /// @zod.custom(imports.ZSurveySingleUse) + /// [SurveySingleUse] + singleUse Json? @default("{\"enabled\": false, \"isEncrypted\": true}") /// @zod.custom(imports.ZSurveyVerifyEmail) /// [SurveyVerifyEmail] verifyEmail Json? diff --git a/packages/database/zod-utils.ts b/packages/database/zod-utils.ts index efdd0bdcba..f349b8042a 100644 --- a/packages/database/zod-utils.ts +++ b/packages/database/zod-utils.ts @@ -11,6 +11,7 @@ export { ZSurveyThankYouCard, ZSurveyClosedMessage, ZSurveyVerifyEmail, + ZSurveySingleUse, } from "@formbricks/types/v1/surveys"; export { ZUserNotificationSettings } from "@formbricks/types/v1/users"; diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index b2c8a7c0a6..10047eb71d 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -15,6 +15,7 @@ export const SURVEY_BASE_URL = env.SURVEY_BASE_URL ? env.SURVEY_BASE_URL + "/" : // Other export const INTERNAL_SECRET = process.env.INTERNAL_SECRET || ""; +export const FORMBRICKS_ENCRYPTION_KEY = env.FORMBRICKS_ENCRYPTION_KEY || undefined; export const CRON_SECRET = env.CRON_SECRET; export const DEFAULT_BRAND_COLOR = "#64748b"; diff --git a/packages/lib/crypto.ts b/packages/lib/crypto.ts index 2984d1dc85..240e7f4fd1 100644 --- a/packages/lib/crypto.ts +++ b/packages/lib/crypto.ts @@ -1,3 +1,18 @@ -import { createHash } from "crypto"; +import { createHash, createCipheriv, createDecipheriv } from "crypto"; export const getHash = (key: string): string => createHash("sha256").update(key).digest("hex"); + +// create an aes128 encryption function +export const encryptAES128 = (encryptionKey: string, data: string): string => { + const cipher = createCipheriv("aes-128-ecb", Buffer.from(encryptionKey, "base64"), ""); + let encrypted = cipher.update(data, "utf-8", "hex"); + encrypted += cipher.final("hex"); + return encrypted; +}; +// create an aes128 decryption function +export const decryptAES128 = (encryptionKey: string, data: string): string => { + const cipher = createDecipheriv("aes-128-ecb", Buffer.from(encryptionKey, "base64"), ""); + let decrypted = cipher.update(data, "hex", "utf-8"); + decrypted += cipher.final("utf-8"); + return decrypted; +}; diff --git a/packages/lib/response/service.ts b/packages/lib/response/service.ts index 4e7f9a820f..188685c653 100644 --- a/packages/lib/response/service.ts +++ b/packages/lib/response/service.ts @@ -12,6 +12,7 @@ import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/v1/error import { TPerson } from "@formbricks/types/v1/people"; import { TTag } from "@formbricks/types/v1/tags"; import { Prisma } from "@prisma/client"; +import { z } from "zod"; import { cache } from "react"; import { getPerson, transformPrismaPerson } from "../services/person"; import { captureTelemetry } from "../telemetry"; @@ -28,6 +29,7 @@ const responseSelection = { data: true, meta: true, personAttributes: true, + singleUseId: true, person: { select: { id: true, @@ -115,6 +117,41 @@ export const getResponsesByPersonId = async (personId: string): Promise => { + validateInputs([surveyId, ZId], [singleUseId, z.string()]); + try { + if (!singleUseId) { + return null; + } + const responsePrisma = await prisma.response.findUnique({ + where: { + surveyId_singleUseId: { surveyId, singleUseId }, + }, + select: responseSelection, + }); + + if (!responsePrisma) { + return null; + } + + const response: TResponse = { + ...responsePrisma, + person: responsePrisma.person ? transformPrismaPerson(responsePrisma.person) : null, + tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag), + }; + + return response; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError("Database operation failed"); + } + + throw error; + } + } +); + export const createResponse = async (responseInput: Partial): Promise => { validateInputs([responseInput, ZResponseInput.partial()]); captureTelemetry("response created"); @@ -143,6 +180,7 @@ export const createResponse = async (responseInput: Partial): Pr personAttributes: person?.attributes, }), ...(responseInput.meta && ({ meta: responseInput?.meta } as Prisma.JsonObject)), + singleUseId: responseInput.singleUseId, }, select: responseSelection, }); diff --git a/packages/lib/responseQueue.ts b/packages/lib/responseQueue.ts index 6358795787..fa1b5ab929 100644 --- a/packages/lib/responseQueue.ts +++ b/packages/lib/responseQueue.ts @@ -72,7 +72,12 @@ export class ResponseQueue { await updateResponse(responseUpdate, this.surveyState.responseId, this.config.apiHost); } else { const response = await createResponse( - { ...responseUpdate, surveyId: this.surveyState.surveyId, personId: this.config.personId || null }, + { + ...responseUpdate, + surveyId: this.surveyState.surveyId, + personId: this.config.personId || null, + singleUseId: this.surveyState.singleUseId || null, + }, this.config.apiHost ); if (this.surveyState.displayId) { diff --git a/packages/lib/services/survey.ts b/packages/lib/services/survey.ts index 6deb20e1e0..b2cd638c03 100644 --- a/packages/lib/services/survey.ts +++ b/packages/lib/services/survey.ts @@ -50,6 +50,7 @@ export const selectSurvey = { verifyEmail: true, redirectUrl: true, surveyClosedMessage: true, + singleUse: true, triggers: { select: { eventClass: { diff --git a/packages/lib/surveyState.ts b/packages/lib/surveyState.ts index 004fca6b0f..ddd874c241 100644 --- a/packages/lib/surveyState.ts +++ b/packages/lib/surveyState.ts @@ -5,9 +5,12 @@ export class SurveyState { displayId: string | null = null; surveyId: string; responseAcc: TResponseUpdate = { finished: false, data: {} }; + singleUseId: string | null; - constructor(surveyId: string) { + constructor(surveyId: string, singleUseId?: string, responseId?: string) { this.surveyId = surveyId; + this.singleUseId = singleUseId ?? null; + this.responseId = responseId ?? null; } /** @@ -22,7 +25,11 @@ export class SurveyState { * Get a copy of the current state */ copy() { - const copyInstance = new SurveyState(this.surveyId); + const copyInstance = new SurveyState( + this.surveyId, + this.singleUseId ?? undefined, + this.responseId ?? undefined + ); copyInstance.responseId = this.responseId; copyInstance.responseAcc = this.responseAcc; return copyInstance; diff --git a/packages/types/surveys.ts b/packages/types/surveys.ts index 10913f00d5..a99a8ca0e5 100644 --- a/packages/types/surveys.ts +++ b/packages/types/surveys.ts @@ -11,6 +11,12 @@ export interface SurveyClosedMessage { subheading?: string; } +export interface SurveySingleUse { + enabled: boolean; + heading?: string; + subheading?: string; +} + export interface VerifyEmail { name?: string; subheading?: string; @@ -39,6 +45,7 @@ export interface Survey { surveyClosedMessage: SurveyClosedMessage | null; verifyEmail: VerifyEmail | null; closeOnDate: Date | null; + singleUse: SurveySingleUse | null; _count: { responses: number | null } | null; } diff --git a/packages/types/v1/responses.ts b/packages/types/v1/responses.ts index 069a9a09b5..9855d3c42c 100644 --- a/packages/types/v1/responses.ts +++ b/packages/types/v1/responses.ts @@ -53,6 +53,7 @@ export const ZResponse = z.object({ notes: z.array(ZResponseNote), tags: z.array(ZTag), meta: ZResponseMeta.nullable(), + singleUseId: z.string().nullable(), }); export type TResponse = z.infer; @@ -60,6 +61,7 @@ export type TResponse = z.infer; export const ZResponseInput = z.object({ surveyId: z.string().cuid2(), personId: z.string().cuid2().nullable(), + singleUseId: z.string().nullable().optional(), finished: z.boolean(), data: ZResponseData, meta: z diff --git a/packages/types/v1/surveys.ts b/packages/types/v1/surveys.ts index 64cae13a7b..41534d9ac5 100644 --- a/packages/types/v1/surveys.ts +++ b/packages/types/v1/surveys.ts @@ -17,6 +17,17 @@ export const ZSurveyClosedMessage = z .nullable() .optional(); +export const ZSurveySingleUse = z + .object({ + enabled: z.boolean(), + heading: z.optional(z.string()), + subheading: z.optional(z.string()), + isEncrypted: z.boolean(), + }) + .nullable(); + +export type TSurveySingleUse = z.infer; + export const ZSurveyVerifyEmail = z .object({ name: z.optional(z.string()), @@ -259,6 +270,7 @@ export const ZSurvey = z.object({ autoComplete: z.number().nullable(), closeOnDate: z.date().nullable(), surveyClosedMessage: ZSurveyClosedMessage.nullable(), + singleUse: ZSurveySingleUse.nullable(), verifyEmail: ZSurveyVerifyEmail.nullable(), }); diff --git a/turbo.json b/turbo.json index c8e2052132..d192f95f70 100644 --- a/turbo.json +++ b/turbo.json @@ -70,6 +70,7 @@ "NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID", "NEXT_PUBLIC_FORMBRICKS_PMF_FORM_ID", "NEXT_PUBLIC_FORMBRICKS_URL", + "FORMBRICKS_ENCRYPTION_KEY", "IMPRINT_URL", "NEXT_PUBLIC_SENTRY_DSN", "SURVEY_BASE_URL",