mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-13 01:58:40 -05:00
synced with main
This commit is contained in:
@@ -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=
|
||||
@@ -104,4 +104,8 @@ NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID=
|
||||
|
||||
# Cron Secret
|
||||
CRON_SECRET=
|
||||
*/
|
||||
*/
|
||||
|
||||
# Encryption key
|
||||
# You can use: `openssl rand -base64 16` to generate one
|
||||
FORMBRICKS_ENCRYPTION_KEY=
|
||||
@@ -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) |
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-3">
|
||||
{response.survey.questions.map((question) => (
|
||||
<div key={question.id}>
|
||||
@@ -75,6 +77,18 @@ export default function ResponseFeed({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex w-full justify-end">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<p className="text-sm text-slate-500">{response.singleUseId}</p>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="text-sm text-slate-500">Single Use Id</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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({
|
||||
<DropdownMenuItem>
|
||||
<Link
|
||||
className="flex w-full items-center"
|
||||
href={`/s/${survey.id}?preview=true`}
|
||||
href={
|
||||
singleUseId
|
||||
? `/s/${survey.id}?suId=${singleUseId}&preview=true`
|
||||
: `/s/${survey.id}?preview=true`
|
||||
}
|
||||
target="_blank">
|
||||
<EyeIcon className="mr-2 h-4 w-4" />
|
||||
Preview Survey
|
||||
@@ -165,8 +171,11 @@ export default function SurveyDropDownMenu({
|
||||
<button
|
||||
className="flex w-full items-center"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(surveyUrl);
|
||||
navigator.clipboard.writeText(
|
||||
singleUseId ? `${surveyUrl}?suId=${singleUseId}` : surveyUrl
|
||||
);
|
||||
toast.success("Copied link to clipboard");
|
||||
router.refresh();
|
||||
}}>
|
||||
<LinkIcon className="mr-2 h-4 w-4" />
|
||||
Copy Link
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { TEnvironment } from "@formbricks/types/v1/environment";
|
||||
import { Badge } from "@formbricks/ui";
|
||||
import { ComputerDesktopIcon, LinkIcon, PlusIcon } from "@heroicons/react/24/solid";
|
||||
import Link from "next/link";
|
||||
import { generateSurveySingleUseId } from "@/lib/singleUseSurveys";
|
||||
|
||||
export default async function SurveysList({ environmentId }: { environmentId: string }) {
|
||||
const product = await getProductByEnvironmentId(environmentId);
|
||||
@@ -45,57 +46,63 @@ export default async function SurveysList({ environmentId }: { environmentId: st
|
||||
</Link>
|
||||
{surveys
|
||||
.sort((a, b) => b.updatedAt?.getTime() - a.updatedAt?.getTime())
|
||||
.map((survey) => (
|
||||
<li key={survey.id} className="relative col-span-1 h-56">
|
||||
<div className="delay-50 flex h-full flex-col justify-between rounded-md bg-white shadow transition ease-in-out hover:scale-105">
|
||||
<div className="px-6 py-4">
|
||||
<Badge
|
||||
StartIcon={survey.type === "link" ? LinkIcon : ComputerDesktopIcon}
|
||||
startIconClassName="mr-2"
|
||||
text={
|
||||
survey.type === "link"
|
||||
? "Link Survey"
|
||||
: survey.type === "web"
|
||||
? "In-Product Survey"
|
||||
: ""
|
||||
.map((survey) => {
|
||||
const isSingleUse = survey.singleUse?.enabled ?? false;
|
||||
const isEncrypted = survey.singleUse?.isEncrypted ?? false;
|
||||
const singleUseId = isSingleUse ? generateSurveySingleUseId(isEncrypted) : undefined;
|
||||
return (
|
||||
<li key={survey.id} className="relative col-span-1 h-56">
|
||||
<div className="delay-50 flex h-full flex-col justify-between rounded-md bg-white shadow transition ease-in-out hover:scale-105">
|
||||
<div className="px-6 py-4">
|
||||
<Badge
|
||||
StartIcon={survey.type === "link" ? LinkIcon : ComputerDesktopIcon}
|
||||
startIconClassName="mr-2"
|
||||
text={
|
||||
survey.type === "link"
|
||||
? "Link Survey"
|
||||
: survey.type === "web"
|
||||
? "In-Product Survey"
|
||||
: ""
|
||||
}
|
||||
type="gray"
|
||||
size={"tiny"}
|
||||
className="font-base"></Badge>
|
||||
<p className="my-2 line-clamp-3 text-lg">{survey.name}</p>
|
||||
</div>
|
||||
<Link
|
||||
href={
|
||||
survey.status === "draft"
|
||||
? `/environments/${environmentId}/surveys/${survey.id}/edit`
|
||||
: `/environments/${environmentId}/surveys/${survey.id}/summary`
|
||||
}
|
||||
type="gray"
|
||||
size={"tiny"}
|
||||
className="font-base"></Badge>
|
||||
<p className="my-2 line-clamp-3 text-lg">{survey.name}</p>
|
||||
</div>
|
||||
<Link
|
||||
href={
|
||||
survey.status === "draft"
|
||||
? `/environments/${environmentId}/surveys/${survey.id}/edit`
|
||||
: `/environments/${environmentId}/surveys/${survey.id}/summary`
|
||||
}
|
||||
className="absolute h-full w-full"></Link>
|
||||
<div className="divide-y divide-slate-100">
|
||||
<div className="flex justify-between px-4 py-2 text-right sm:px-6">
|
||||
<div className="flex items-center">
|
||||
{survey.status !== "draft" && (
|
||||
<>
|
||||
<SurveyStatusIndicator status={survey.status} tooltip environment={environment} />
|
||||
</>
|
||||
)}
|
||||
{survey.status === "draft" && (
|
||||
<span className="text-xs italic text-slate-400">Draft</span>
|
||||
)}
|
||||
className="absolute h-full w-full"></Link>
|
||||
<div className="divide-y divide-slate-100">
|
||||
<div className="flex justify-between px-4 py-2 text-right sm:px-6">
|
||||
<div className="flex items-center">
|
||||
{survey.status !== "draft" && (
|
||||
<>
|
||||
<SurveyStatusIndicator status={survey.status} tooltip environment={environment} />
|
||||
</>
|
||||
)}
|
||||
{survey.status === "draft" && (
|
||||
<span className="text-xs italic text-slate-400">Draft</span>
|
||||
)}
|
||||
</div>
|
||||
<SurveyDropDownMenu
|
||||
survey={survey}
|
||||
key={`surveys-${survey.id}`}
|
||||
environmentId={environmentId}
|
||||
environment={environment}
|
||||
otherEnvironment={otherEnvironment!}
|
||||
surveyBaseUrl={SURVEY_BASE_URL}
|
||||
singleUseId={singleUseId}
|
||||
/>
|
||||
</div>
|
||||
<SurveyDropDownMenu
|
||||
survey={survey}
|
||||
key={`surveys-${survey.id}`}
|
||||
environmentId={environmentId}
|
||||
environment={environment}
|
||||
otherEnvironment={otherEnvironment!}
|
||||
surveyBaseUrl={SURVEY_BASE_URL}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<UsageAttributesUpdater numSurveys={surveys.length} />
|
||||
</>
|
||||
|
||||
@@ -147,6 +147,11 @@ export default function SingleResponse({
|
||||
)}
|
||||
|
||||
<div className="flex space-x-4 text-sm">
|
||||
{data.singleUseId && (
|
||||
<span className="flex items-center rounded-full bg-slate-100 px-3 text-slate-600">
|
||||
{data.singleUseId}
|
||||
</span>
|
||||
)}
|
||||
{data.finished && (
|
||||
<span className="flex items-center rounded-full bg-slate-100 px-3 text-slate-600">
|
||||
Completed <CheckCircleIcon className="ml-1 h-5 w-5 text-green-400" />
|
||||
|
||||
@@ -7,12 +7,14 @@ import { useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { TProduct } from "@formbricks/types/v1/product";
|
||||
import ShareEmbedSurvey from "./ShareEmbedSurvey";
|
||||
import LinkSingleUseSurveyModal from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/LinkSingleUseSurveyModal";
|
||||
|
||||
interface LinkSurveyShareButtonProps {
|
||||
survey: TSurvey;
|
||||
className?: string;
|
||||
surveyBaseUrl: string;
|
||||
product: TProduct;
|
||||
singleUseIds?: string[];
|
||||
}
|
||||
|
||||
export default function LinkSurveyShareButton({
|
||||
@@ -20,8 +22,10 @@ export default function LinkSurveyShareButton({
|
||||
className,
|
||||
surveyBaseUrl,
|
||||
product,
|
||||
singleUseIds,
|
||||
}: LinkSurveyShareButtonProps) {
|
||||
const [showLinkModal, setShowLinkModal] = useState(false);
|
||||
const isSingleUse = survey.singleUse?.enabled ?? false;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -34,8 +38,14 @@ export default function LinkSurveyShareButton({
|
||||
onClick={() => setShowLinkModal(true)}>
|
||||
<ShareIcon className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
{showLinkModal && (
|
||||
{showLinkModal && isSingleUse && singleUseIds ? (
|
||||
<LinkSingleUseSurveyModal
|
||||
survey={survey}
|
||||
open={showLinkModal}
|
||||
setOpen={setShowLinkModal}
|
||||
singleUseIds={singleUseIds}
|
||||
/>
|
||||
) : (
|
||||
<ShareEmbedSurvey
|
||||
survey={survey}
|
||||
open={showLinkModal}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Dialog, DialogContent } from "@formbricks/ui";
|
||||
import { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
import { ArrowPathIcon, CheckIcon } from "@heroicons/react/24/outline";
|
||||
import { CheckCircleIcon, DocumentDuplicateIcon, EyeIcon } from "@heroicons/react/24/solid";
|
||||
import { useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { truncateMiddle } from "@/lib/utils";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface LinkSingleUseSurveyModalProps {
|
||||
survey: TSurvey;
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => 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<number[]>([]);
|
||||
|
||||
const linkTextRef = useRef<HTMLDivElement>(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 (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="bottom-0 max-w-sm bg-white p-4 sm:bottom-auto sm:max-w-xl sm:p-6">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-teal-100">
|
||||
<CheckIcon className="h-6 w-6 text-teal-600" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-5">
|
||||
<h3 className="text-lg font-semibold leading-6 text-gray-900">Your survey is ready!</h3>
|
||||
<div className="mt-4">
|
||||
<p className="text-sm text-gray-500">
|
||||
Here are 5 single use links to let people answer your survey:
|
||||
</p>
|
||||
<div ref={linkTextRef}>
|
||||
{singleUseIds.map((singleUseId, index) => {
|
||||
const isSelected = selectedSingleUseIds.includes(index);
|
||||
return (
|
||||
<div
|
||||
key={singleUseId}
|
||||
className={cn(
|
||||
"row relative mt-3 flex max-w-full cursor-pointer items-center justify-between overflow-auto rounded-lg border border-slate-300 bg-slate-50 px-8 py-4 text-left text-slate-800 transition-all duration-200 ease-in-out hover:border-slate-500",
|
||||
isSelected && "border-slate-200 text-slate-400 hover:border-slate-200"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!isSelected) {
|
||||
handleLinkOnClick(index);
|
||||
}
|
||||
}}>
|
||||
<span>{truncateMiddle(`${defaultSurveyUrl}?suId=${singleUseId}`, 48)}</span>
|
||||
{isSelected ? (
|
||||
<CheckCircleIcon className="ml-4 h-4 w-4" />
|
||||
) : (
|
||||
<DocumentDuplicateIcon className="ml-4 h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-col justify-center gap-2 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
title="Generate new single-use survey link"
|
||||
aria-label="Generate new single-use survey link"
|
||||
className="flex justify-center"
|
||||
onClick={() => {
|
||||
router.refresh();
|
||||
setSelectedSingleIds([]);
|
||||
toast.success("New survey links generated!");
|
||||
}}
|
||||
EndIcon={ArrowPathIcon}>
|
||||
Regenerate
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setSelectedSingleIds(Array.from(singleUseIds.keys()));
|
||||
const allSurveyUrls = singleUseIds
|
||||
.map((singleUseId) => `${defaultSurveyUrl}?suId=${singleUseId}`)
|
||||
.join("\n");
|
||||
navigator.clipboard.writeText(allSurveyUrls);
|
||||
toast.success("All URLs copied to clipboard!");
|
||||
}}
|
||||
title="Copy all survey links to clipboard"
|
||||
aria-label="Copy all survey links to clipboard"
|
||||
className="flex justify-center"
|
||||
EndIcon={DocumentDuplicateIcon}>
|
||||
Copy 5 URLs
|
||||
</Button>
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
title="Preview survey"
|
||||
aria-label="Preview survey"
|
||||
className="flex justify-center"
|
||||
href={`${defaultSurveyUrl}?suId=${singleUseIds[0]}preview=true`}
|
||||
target="_blank"
|
||||
EndIcon={EyeIcon}>
|
||||
Preview
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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 ? (
|
||||
<LinkSingleUseSurveyModal
|
||||
survey={survey}
|
||||
open={showLinkModal}
|
||||
setOpen={setShowLinkModal}
|
||||
singleUseIds={singleUseIds}
|
||||
/>
|
||||
) : (
|
||||
<ShareEmbedSurvey
|
||||
survey={survey}
|
||||
open={showLinkModal}
|
||||
|
||||
@@ -22,6 +22,7 @@ interface SummaryPageProps {
|
||||
surveyId: string;
|
||||
responses: TResponse[];
|
||||
surveyBaseUrl: string;
|
||||
singleUseIds?: string[];
|
||||
product: TProduct;
|
||||
environmentTags: TTag[];
|
||||
}
|
||||
@@ -32,6 +33,7 @@ const SummaryPage = ({
|
||||
surveyId,
|
||||
responses,
|
||||
surveyBaseUrl,
|
||||
singleUseIds,
|
||||
product,
|
||||
environmentTags,
|
||||
}: SummaryPageProps) => {
|
||||
@@ -56,6 +58,7 @@ const SummaryPage = ({
|
||||
survey={survey}
|
||||
surveyId={surveyId}
|
||||
surveyBaseUrl={surveyBaseUrl}
|
||||
singleUseIds={singleUseIds}
|
||||
product={product}
|
||||
/>
|
||||
<CustomFilter
|
||||
|
||||
@@ -9,16 +9,28 @@ import { getEnvironment } from "@formbricks/lib/services/environment";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
|
||||
import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { generateSurveySingleUseId } from "@/lib/singleUseSurveys";
|
||||
|
||||
const generateSingleUseIds = (isEncrypted: boolean) => {
|
||||
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}
|
||||
/>
|
||||
|
||||
@@ -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 }
|
||||
</div>
|
||||
<div className="hidden justify-end gap-x-1.5 sm:flex">
|
||||
{survey.type === "link" && (
|
||||
<LinkSurveyShareButton survey={survey} surveyBaseUrl={surveyBaseUrl} product={product} />
|
||||
<LinkSurveyShareButton
|
||||
survey={survey}
|
||||
surveyBaseUrl={surveyBaseUrl}
|
||||
singleUseIds={singleUseIds}
|
||||
product={product}
|
||||
/>
|
||||
)}
|
||||
{(environment?.widgetSetupCompleted || survey.type === "link") && survey?.status !== "draft" ? (
|
||||
<SurveyStatusDropdown environment={environment} survey={survey} />
|
||||
@@ -75,6 +88,7 @@ const SummaryHeader = ({ surveyId, environment, survey, surveyBaseUrl, product }
|
||||
survey={survey}
|
||||
surveyBaseUrl={surveyBaseUrl}
|
||||
product={product}
|
||||
singleUseIds={singleUseIds}
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
@@ -157,6 +171,7 @@ const SummaryHeader = ({ surveyId, environment, survey, surveyBaseUrl, product }
|
||||
survey={survey}
|
||||
surveyBaseUrl={surveyBaseUrl}
|
||||
product={product}
|
||||
singleUseIds={singleUseIds}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
</div>
|
||||
</AdvancedOptionToggle>
|
||||
|
||||
{/* Single User Survey Options */}
|
||||
<AdvancedOptionToggle
|
||||
htmlId="singleUserSurveyOptions"
|
||||
isChecked={!!localSurvey.singleUse?.enabled}
|
||||
onToggle={handleSingleUseSurveyToggle}
|
||||
title="Single-Use Survey Links"
|
||||
description="Allow only 1 response per survey link."
|
||||
childBorder={true}>
|
||||
<div className="flex w-full items-center space-x-1 p-4 pb-4">
|
||||
<div className="w-full cursor-pointer items-center bg-slate-50">
|
||||
<div className="row mb-2 flex cursor-default items-center space-x-2">
|
||||
<Label htmlFor="howItWorks">How it works</Label>
|
||||
</div>
|
||||
<ul className="mb-3 ml-4 cursor-default list-inside list-disc space-y-1">
|
||||
<li className="text-sm text-slate-600">
|
||||
Blocks survey if the survey URL has no Single Use Id (suId).
|
||||
</li>
|
||||
<li className="text-sm text-slate-600">
|
||||
Blocks survey if a submission with the Single Use Id (suId) in the URL exists already.
|
||||
</li>
|
||||
</ul>
|
||||
<Label htmlFor="headline">‘Link Used’ Message</Label>
|
||||
<Input
|
||||
autoFocus
|
||||
id="heading"
|
||||
className="mb-4 mt-2 bg-white"
|
||||
name="heading"
|
||||
defaultValue={singleUseMessage.heading}
|
||||
onChange={(e) => handleSingleUseSurveyMessageChange({ heading: e.target.value })}
|
||||
/>
|
||||
|
||||
<Label htmlFor="headline">Subheading</Label>
|
||||
<Input
|
||||
className="mb-4 mt-2 bg-white"
|
||||
id="subheading"
|
||||
name="subheading"
|
||||
defaultValue={singleUseMessage.subheading}
|
||||
onChange={(e) => handleSingleUseSurveyMessageChange({ subheading: e.target.value })}
|
||||
/>
|
||||
<Label htmlFor="headline">URL Encryption</Label>
|
||||
<div>
|
||||
<TooltipProvider delayDuration={50}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="mt-2 flex items-center space-x-1 ">
|
||||
<Switch
|
||||
id="encryption-switch"
|
||||
checked={singleUseEncryption}
|
||||
onCheckedChange={hangleSingleUseEncryptionToggle}
|
||||
disabled={!isEncryptionKeySet}
|
||||
/>
|
||||
<Label htmlFor="encryption-label">
|
||||
<div className="ml-2">
|
||||
<p className="text-sm font-normal text-slate-600">
|
||||
Enable encryption of Single Use Id (suId) in survey URL.
|
||||
</p>
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{!isEncryptionKeySet && (
|
||||
<TooltipContent side={"top"}>
|
||||
<p className="py-2 text-center text-xs text-slate-500 dark:text-slate-400">
|
||||
FORMBRICKS_ENCRYPTION_KEY needs to be set to enable this feature.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AdvancedOptionToggle>
|
||||
|
||||
{/* Verify Email Section */}
|
||||
<AdvancedOptionToggle
|
||||
htmlId="verifyEmailBeforeSubmission"
|
||||
isChecked={verifyEmailToggle}
|
||||
|
||||
@@ -14,6 +14,7 @@ interface SettingsViewProps {
|
||||
setLocalSurvey: (survey: TSurveyWithAnalytics) => 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 (
|
||||
<div className="mt-12 space-y-3 p-5">
|
||||
@@ -41,7 +43,11 @@ export default function SettingsView({
|
||||
actionClasses={actionClasses}
|
||||
/>
|
||||
|
||||
<ResponseOptionsCard localSurvey={localSurvey} setLocalSurvey={setLocalSurvey} />
|
||||
<ResponseOptionsCard
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
isEncryptionKeySet={isEncryptionKeySet}
|
||||
/>
|
||||
|
||||
<RecontactOptionsCard
|
||||
localSurvey={localSurvey}
|
||||
|
||||
@@ -20,6 +20,7 @@ interface SurveyEditorProps {
|
||||
environment: TEnvironment;
|
||||
actionClasses: TActionClass[];
|
||||
attributeClasses: TAttributeClass[];
|
||||
isEncryptionKeySet: boolean;
|
||||
}
|
||||
|
||||
export default function SurveyEditor({
|
||||
@@ -28,6 +29,7 @@ export default function SurveyEditor({
|
||||
environment,
|
||||
actionClasses,
|
||||
attributeClasses,
|
||||
isEncryptionKeySet,
|
||||
}: SurveyEditorProps): JSX.Element {
|
||||
const [activeView, setActiveView] = useState<"questions" | "settings">("questions");
|
||||
const [activeQuestionId, setActiveQuestionId] = useState<string | null>(null);
|
||||
@@ -88,6 +90,7 @@ export default function SurveyEditor({
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
isEncryptionKeySet={isEncryptionKeySet}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
||||
@@ -155,6 +155,7 @@ export default function SurveyMenuBar({
|
||||
} else {
|
||||
router.push(`/environments/${environment.id}/surveys`);
|
||||
}
|
||||
router.refresh();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
|
||||
@@ -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 <ErrorComponent />;
|
||||
}
|
||||
@@ -29,6 +30,7 @@ export default async function SurveysEditPage({ params }) {
|
||||
environment={environment}
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
isEncryptionKeySet={isEncryptionKeySet}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -2062,4 +2062,5 @@ export const minimalSurvey: TSurvey = {
|
||||
surveyClosedMessage: {
|
||||
enabled: false,
|
||||
},
|
||||
singleUse: null,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string>(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 <SurveyLinkUsed singleUseMessage={survey.singleUse} />;
|
||||
}
|
||||
|
||||
if (emailVerificationStatus && emailVerificationStatus !== "verified") {
|
||||
if (emailVerificationStatus === "fishy") {
|
||||
return <VerifyEmail survey={survey} isErrorComponent={true} />;
|
||||
|
||||
@@ -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: <PauseCircleIcon className="h-20 w-20" />,
|
||||
completed: <CheckCircleIcon className="h-20 w-20" />,
|
||||
"link invalid": <QuestionMarkCircleIcon className="h-20 w-20" />,
|
||||
};
|
||||
|
||||
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}.`}
|
||||
</h1>
|
||||
<p className="text-lg leading-10 text-gray-500">
|
||||
{" "}
|
||||
{status === "completed" && surveyClosedMessage
|
||||
? surveyClosedMessage.subheading
|
||||
: descriptions[status]}
|
||||
</p>
|
||||
{!(status === "completed" && surveyClosedMessage) && (
|
||||
{!(status === "completed" && surveyClosedMessage) && status !== "link invalid" && (
|
||||
<Button variant="darkCTA" className="mt-2" href="https://formbricks.com">
|
||||
Create your own
|
||||
</Button>
|
||||
|
||||
35
apps/web/app/s/[surveyId]/SurveyLinkUsed.tsx
Normal file
35
apps/web/app/s/[surveyId]/SurveyLinkUsed.tsx
Normal file
@@ -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<SurveySingleUse, "enabled"> | null;
|
||||
};
|
||||
|
||||
const SurveyLinkUsed = ({ singleUseMessage }: SurveyLinkUsedProps) => {
|
||||
const defaultHeading = "The survey has already been answered.";
|
||||
const defaultSubheading = "You can only use this link once.";
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-between bg-gradient-to-tr from-slate-200 to-slate-50 py-8 text-center">
|
||||
<div></div>
|
||||
<div className="flex flex-col items-center space-y-3 text-slate-300">
|
||||
<CheckCircleIcon className="h-20 w-20" />
|
||||
<h1 className="text-4xl font-bold text-slate-800">
|
||||
{!!singleUseMessage?.heading ? singleUseMessage?.heading : defaultHeading}
|
||||
</h1>
|
||||
<p className="text-lg leading-10 text-gray-500">
|
||||
{!!singleUseMessage?.subheading ? singleUseMessage?.subheading : defaultSubheading}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Link href="https://formbricks.com">
|
||||
<Image src={footerLogo} alt="Brand logo" className="mx-auto w-40" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SurveyLinkUsed;
|
||||
@@ -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 <SurveyInactive status="link invalid" />;
|
||||
}
|
||||
|
||||
// if encryption is enabled, validate the single use id
|
||||
let validatedSingleUseId: string | undefined = undefined;
|
||||
if (isSingleUseSurveyEncrypted) {
|
||||
validatedSingleUseId = validateSurveySingleUseId(suId);
|
||||
if (!validatedSingleUseId) {
|
||||
return <SurveyInactive status="link invalid" />;
|
||||
}
|
||||
}
|
||||
// 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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
33
apps/web/lib/singleUseSurveys.ts
Normal file
33
apps/web/lib/singleUseSurveys.ts
Normal file
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
@@ -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?
|
||||
|
||||
@@ -11,6 +11,7 @@ export {
|
||||
ZSurveyThankYouCard,
|
||||
ZSurveyClosedMessage,
|
||||
ZSurveyVerifyEmail,
|
||||
ZSurveySingleUse,
|
||||
} from "@formbricks/types/v1/surveys";
|
||||
|
||||
export { ZUserNotificationSettings } from "@formbricks/types/v1/users";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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<Array<TR
|
||||
}
|
||||
};
|
||||
|
||||
export const getResponseBySingleUseId = cache(
|
||||
async (surveyId: string, singleUseId?: string): Promise<TResponse | null> => {
|
||||
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<TResponseInput>): Promise<TResponse> => {
|
||||
validateInputs([responseInput, ZResponseInput.partial()]);
|
||||
captureTelemetry("response created");
|
||||
@@ -143,6 +180,7 @@ export const createResponse = async (responseInput: Partial<TResponseInput>): Pr
|
||||
personAttributes: person?.attributes,
|
||||
}),
|
||||
...(responseInput.meta && ({ meta: responseInput?.meta } as Prisma.JsonObject)),
|
||||
singleUseId: responseInput.singleUseId,
|
||||
},
|
||||
select: responseSelection,
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -50,6 +50,7 @@ export const selectSurvey = {
|
||||
verifyEmail: true,
|
||||
redirectUrl: true,
|
||||
surveyClosedMessage: true,
|
||||
singleUse: true,
|
||||
triggers: {
|
||||
select: {
|
||||
eventClass: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<typeof ZResponse>;
|
||||
@@ -60,6 +61,7 @@ export type TResponse = z.infer<typeof ZResponse>;
|
||||
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
|
||||
|
||||
@@ -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<typeof ZSurveySingleUse>;
|
||||
|
||||
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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user