mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-19 02:10:33 -05:00
feat: Added Pin Protection to forms (#1142)
Signed-off-by: aryabyte21 <arya2001bhosale@gmail.com> Co-authored-by: Matti Nannt <mail@matthiasnannt.com> Co-authored-by: Vaibhav Bhardwaj <43564765+impolska742@users.noreply.github.com> Co-authored-by: Johannes <johannes@formbricks.com> Co-authored-by: Nikolay Bonev <DonKoko@users.noreply.github.com> Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com> Co-authored-by: Abhinav Arya <95561280+itzabhinavarya@users.noreply.github.com> Co-authored-by: Shaikh Adnan <117739824+Sha1kh4@users.noreply.github.com> Co-authored-by: Akash Sharan <129506339+asharan2511@users.noreply.github.com> Co-authored-by: Arya Bhosale <60646682+aryabyte21@users.noreply.github.com> Co-authored-by: Ratish jain <43003421+ratishjain12@users.noreply.github.com> Co-authored-by: Sundaram Kumar Jha <jhasundaram@outlook.com> Co-authored-by: Soham Tembhurne <82658685+sohamtembhurne@users.noreply.github.com> Co-authored-by: Digvijay Gupta <65729055+3xp10it3r@users.noreply.github.com> Co-authored-by: Digvijay Gupta <digvijaygupta@Digvijays-MacBook-Pro.local> Co-authored-by: Ronit Panda <72537293+rtpa25@users.noreply.github.com> Co-authored-by: Bilal Mirza <84387676+bilalmirza74@users.noreply.github.com>
This commit is contained in:
@@ -9,7 +9,7 @@ import { Label } from "@formbricks/ui/Label";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { CheckCircleIcon } from "@heroicons/react/24/solid";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { useEffect, useState } from "react";
|
||||
import { KeyboardEventHandler, useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
interface ResponseOptionsCardProps {
|
||||
@@ -49,6 +49,10 @@ export default function ResponseOptionsCard({
|
||||
});
|
||||
const [closeOnDate, setCloseOnDate] = useState<Date>();
|
||||
|
||||
const [verifyProtectWithPinToggle, setVerifyProtectWithPinToggle] = useState(false);
|
||||
|
||||
const [verifyProtectWithPinError, setverifyProtectWithPinError] = useState<string | null>(null);
|
||||
|
||||
const handleRedirectCheckMark = () => {
|
||||
setRedirectToggle((prev) => !prev);
|
||||
|
||||
@@ -73,6 +77,34 @@ export default function ResponseOptionsCard({
|
||||
setSurveyCloseOnDateToggle(true);
|
||||
};
|
||||
|
||||
const handleProtectSurveyWithPinToggle = () => {
|
||||
const currentValue = verifyProtectWithPinToggle;
|
||||
if (currentValue === false) setLocalSurvey({ ...localSurvey, pin: null });
|
||||
setVerifyProtectWithPinToggle(!currentValue);
|
||||
};
|
||||
|
||||
const handleProtectSurveryPinChange = (pin: string) => {
|
||||
const pinAsNumber = Number(pin);
|
||||
|
||||
if (isNaN(pinAsNumber)) return toast.error("PIN can only contain numbers");
|
||||
setLocalSurvey({ ...localSurvey, pin: pinAsNumber });
|
||||
};
|
||||
|
||||
const handleProtectSurveyPinBlurEvent = () => {
|
||||
if (!localSurvey.pin) return setverifyProtectWithPinError(null);
|
||||
|
||||
const regexPattern = /^\d{4}$/;
|
||||
const isValidPin = regexPattern.test(`${localSurvey.pin}`);
|
||||
|
||||
if (!isValidPin) return setverifyProtectWithPinError("PIN must be a four digit number.");
|
||||
setverifyProtectWithPinError(null);
|
||||
};
|
||||
|
||||
const handleSurveyPinInputKeyDown: KeyboardEventHandler<HTMLInputElement> = (e) => {
|
||||
const exceptThisSymbols = ["e", "E", "+", "-", "."];
|
||||
if (exceptThisSymbols.includes(e.key)) e.preventDefault();
|
||||
};
|
||||
|
||||
const handleRedirectUrlChange = (link: string) => {
|
||||
setRedirectUrl(link);
|
||||
setLocalSurvey({ ...localSurvey, redirectUrl: link });
|
||||
@@ -189,6 +221,10 @@ export default function ResponseOptionsCard({
|
||||
setRedirectToggle(true);
|
||||
}
|
||||
|
||||
if (!!localSurvey.pin) {
|
||||
setVerifyProtectWithPinToggle(true);
|
||||
}
|
||||
|
||||
if (!!localSurvey.surveyClosedMessage) {
|
||||
setSurveyClosedMessage({
|
||||
heading: localSurvey.surveyClosedMessage.heading ?? surveyClosedMessage.heading,
|
||||
@@ -484,6 +520,35 @@ export default function ResponseOptionsCard({
|
||||
</div>
|
||||
</div>
|
||||
</AdvancedOptionToggle>
|
||||
<AdvancedOptionToggle
|
||||
htmlId="protectSurveryWithPin"
|
||||
isChecked={verifyProtectWithPinToggle}
|
||||
onToggle={handleProtectSurveyWithPinToggle}
|
||||
title="Protect Survery with a PIN"
|
||||
description="Only users who have the PIN can access the survey."
|
||||
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">
|
||||
<Label htmlFor="headline">Add PIN</Label>
|
||||
<Input
|
||||
autoFocus
|
||||
type="number"
|
||||
id="heading"
|
||||
isInvalid={Boolean(verifyProtectWithPinError)}
|
||||
className="mb-4 mt-2 bg-white"
|
||||
name="heading"
|
||||
placeholder="1234"
|
||||
onBlur={handleProtectSurveyPinBlurEvent}
|
||||
defaultValue={localSurvey.pin ? localSurvey.pin : undefined}
|
||||
onKeyDown={handleSurveyPinInputKeyDown}
|
||||
onChange={(e) => handleProtectSurveryPinChange(e.target.value)}
|
||||
/>
|
||||
{verifyProtectWithPinError && (
|
||||
<p className="text-sm text-red-700">{verifyProtectWithPinError}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AdvancedOptionToggle>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -103,8 +103,8 @@ const notificationLiveSurveys = (surveys: Survey[], environmentId: string) => {
|
||||
return `
|
||||
<div style="display: block; margin-top:3em;">
|
||||
<a href="${WEBAPP_URL}/environments/${environmentId}/surveys/${
|
||||
survey.id
|
||||
}/responses?utm_source=weekly&utm_medium=email&utm_content=ViewResponsesCTA" style="color:#1e293b;">
|
||||
survey.id
|
||||
}/responses?utm_source=weekly&utm_medium=email&utm_content=ViewResponsesCTA" style="color:#1e293b;">
|
||||
<h2 style="text-decoration: underline; display:inline;">${survey.name}</h2>
|
||||
</a>
|
||||
<span style="display: inline; margin-left: 10px; background-color: ${
|
||||
|
||||
@@ -9,8 +9,16 @@ interface LinkSurveyEmailData {
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface ISurveryPinValidationResponse {
|
||||
error?: TSurveryPinValidationResponseError;
|
||||
survey?: TSurvey;
|
||||
}
|
||||
|
||||
import { TSurveryPinValidationResponseError } from "@/app/s/[surveyId]/types";
|
||||
import { sendLinkSurveyToVerifiedEmail } from "@/app/lib/email";
|
||||
import { verifyTokenForLinkSurvey } from "@formbricks/lib/jwt";
|
||||
import { getSurvey } from "@formbricks/lib/survey/service";
|
||||
import { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
|
||||
export async function sendLinkSurveyEmailAction(data: LinkSurveyEmailData) {
|
||||
if (!data.surveyData) {
|
||||
@@ -21,3 +29,23 @@ export async function sendLinkSurveyEmailAction(data: LinkSurveyEmailData) {
|
||||
export async function verifyTokenAction(token: string, surveyId: string): Promise<boolean> {
|
||||
return await verifyTokenForLinkSurvey(token, surveyId);
|
||||
}
|
||||
|
||||
export async function validateSurveyPin(
|
||||
surveyId: string,
|
||||
pin: number
|
||||
): Promise<ISurveryPinValidationResponse> {
|
||||
try {
|
||||
const survey = await getSurvey(surveyId);
|
||||
if (!survey) return { error: TSurveryPinValidationResponseError.NOT_FOUND };
|
||||
|
||||
const originalPin = survey.pin;
|
||||
|
||||
if (!originalPin) return { survey };
|
||||
|
||||
if (originalPin !== pin) return { error: TSurveryPinValidationResponseError.INCORRECT_PIN };
|
||||
|
||||
return { survey };
|
||||
} catch (error) {
|
||||
return { error: TSurveryPinValidationResponseError.INTERNAL_SERVER_ERROR };
|
||||
}
|
||||
}
|
||||
|
||||
118
apps/web/app/s/[surveyId]/components/PinScreen.tsx
Normal file
118
apps/web/app/s/[surveyId]/components/PinScreen.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import type { NextPage } from "next";
|
||||
import { TProduct } from "@/../../packages/types/v1/product";
|
||||
import { TResponse } from "@/../../packages/types/v1/responses";
|
||||
import { OTPInput } from "@formbricks/ui/OTPInput";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { validateSurveyPin } from "@/app/s/[surveyId]/actions";
|
||||
import { TSurvey } from "@/../../packages/types/v1/surveys";
|
||||
import { TSurveryPinValidationResponseError } from "@/app/s/[surveyId]/types";
|
||||
import LinkSurvey from "@/app/s/[surveyId]/components/LinkSurvey";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
|
||||
interface LinkSurveyPinScreenProps {
|
||||
surveyId: string;
|
||||
product: TProduct;
|
||||
personId?: string;
|
||||
emailVerificationStatus?: string;
|
||||
prefillAnswer?: string;
|
||||
singleUseId?: string;
|
||||
singleUseResponse?: TResponse;
|
||||
webAppUrl: string;
|
||||
}
|
||||
|
||||
const LinkSurveyPinScreen: NextPage<LinkSurveyPinScreenProps> = (props) => {
|
||||
const {
|
||||
surveyId,
|
||||
product,
|
||||
webAppUrl,
|
||||
emailVerificationStatus,
|
||||
personId,
|
||||
prefillAnswer,
|
||||
singleUseId,
|
||||
singleUseResponse,
|
||||
} = props;
|
||||
|
||||
const [localPinEntry, setLocalPinEntry] = useState<string>("");
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const [error, setError] = useState<TSurveryPinValidationResponseError>();
|
||||
const [survey, setSurvey] = useState<TSurvey>();
|
||||
|
||||
const _validateSurveyPinAsync = useCallback(async (surveyId: string, pin: number) => {
|
||||
const response = await validateSurveyPin(surveyId, pin);
|
||||
if (response.error) {
|
||||
setError(response.error);
|
||||
} else if (response.survey) {
|
||||
setSurvey(response.survey);
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setError(undefined);
|
||||
setLoading(false);
|
||||
setLocalPinEntry("");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
const timeout = setTimeout(() => resetState(), 2 * 1000);
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}
|
||||
}, [error, resetState]);
|
||||
|
||||
useEffect(() => {
|
||||
const validPinRegex = /^\d{4}$/;
|
||||
const isValidPin = validPinRegex.test(localPinEntry);
|
||||
|
||||
const pinAsNumber = Number(localPinEntry);
|
||||
|
||||
if (isValidPin) {
|
||||
// Show loading and check against the server
|
||||
setLoading(true);
|
||||
_validateSurveyPinAsync(surveyId, pinAsNumber);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(undefined);
|
||||
setLoading(false);
|
||||
}, [_validateSurveyPinAsync, localPinEntry, surveyId]);
|
||||
|
||||
if (!survey) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<div className="my-4 font-semibold">
|
||||
<h4>This survey is protected. Enter the PIN below</h4>
|
||||
</div>
|
||||
<OTPInput
|
||||
disabled={Boolean(error) || loading}
|
||||
value={localPinEntry}
|
||||
onChange={(value) => setLocalPinEntry(value)}
|
||||
valueLength={4}
|
||||
inputBoxClassName={cn({ "border-red-400": Boolean(error) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LinkSurvey
|
||||
survey={survey}
|
||||
product={product}
|
||||
personId={personId}
|
||||
emailVerificationStatus={emailVerificationStatus}
|
||||
prefillAnswer={prefillAnswer}
|
||||
singleUseId={singleUseId}
|
||||
singleUseResponse={singleUseResponse}
|
||||
webAppUrl={webAppUrl}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default LinkSurveyPinScreen;
|
||||
@@ -12,6 +12,7 @@ import { notFound } from "next/navigation";
|
||||
import { getResponseBySingleUseId } from "@formbricks/lib/response/service";
|
||||
import { TResponse } from "@formbricks/types/v1/responses";
|
||||
import { validateSurveySingleUseId } from "@/app/lib/singleUseSurveys";
|
||||
import PinScreen from "@/app/s/[surveyId]/components/PinScreen";
|
||||
|
||||
interface LinkSurveyPageProps {
|
||||
params: {
|
||||
@@ -96,6 +97,23 @@ export default async function LinkSurveyPage({ params, searchParams }: LinkSurve
|
||||
person = await getOrCreatePersonByUserId(userId, survey.environmentId);
|
||||
}
|
||||
|
||||
const isSurveyPinProtected = Boolean(!!survey && survey.pin);
|
||||
|
||||
if (isSurveyPinProtected) {
|
||||
return (
|
||||
<PinScreen
|
||||
surveyId={survey.id}
|
||||
product={product}
|
||||
personId={person?.id}
|
||||
emailVerificationStatus={emailVerificationStatus}
|
||||
prefillAnswer={isPrefilledAnswerValid ? prefillAnswer : null}
|
||||
singleUseId={isSingleUseSurvey ? singleUseId : undefined}
|
||||
singleUseResponse={singleUseResponse ? singleUseResponse : undefined}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LinkSurvey
|
||||
survey={survey}
|
||||
|
||||
5
apps/web/app/s/[surveyId]/types.ts
Normal file
5
apps/web/app/s/[surveyId]/types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export enum TSurveryPinValidationResponseError {
|
||||
INCORRECT_PIN = "INCORRECT_PIN",
|
||||
INTERNAL_SERVER_ERROR = "INTERNAL_SERVER_ERROR",
|
||||
NOT_FOUND = "NOT_FOUND",
|
||||
}
|
||||
@@ -24,8 +24,8 @@
|
||||
"@json2csv/node": "^7.0.3",
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"@radix-ui/react-collapsible": "^1.0.3",
|
||||
"@react-email/components": "^0.0.7",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@react-email/components": "^0.0.7",
|
||||
"@sentry/nextjs": "^7.73.0",
|
||||
"@t3-oss/env-nextjs": "^0.7.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
@@ -34,11 +34,11 @@
|
||||
"googleapis": "^126.0.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lodash": "^4.17.21",
|
||||
"otplib": "^12.0.1",
|
||||
"mime": "^3.0.0",
|
||||
"lucide-react": "^0.284.0",
|
||||
"mime": "^3.0.0",
|
||||
"next": "13.5.4",
|
||||
"nodemailer": "^6.9.5",
|
||||
"otplib": "^12.0.1",
|
||||
"posthog-js": "^1.82.1",
|
||||
"prismjs": "^1.29.0",
|
||||
"qrcode": "^1.5.3",
|
||||
@@ -55,10 +55,10 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formbricks/tsconfig": "workspace:*",
|
||||
"@types/qrcode": "^1.5.2",
|
||||
"@types/bcryptjs": "^2.4.4",
|
||||
"@types/lodash": "^4.14.199",
|
||||
"@types/markdown-it": "^13.0.2",
|
||||
"@types/qrcode": "^1.5.2",
|
||||
"eslint-config-formbricks": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,13 +115,10 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
|
||||
});
|
||||
|
||||
const transformPrismaPerson = (person): TPerson => {
|
||||
const attributes = person.attributes.reduce(
|
||||
(acc, attr) => {
|
||||
acc[attr.attributeClass.name] = attr.value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string | number>
|
||||
);
|
||||
const attributes = person.attributes.reduce((acc, attr) => {
|
||||
acc[attr.attributeClass.name] = attr.value;
|
||||
return acc;
|
||||
}, {} as Record<string, string | number>);
|
||||
|
||||
return {
|
||||
id: person.id,
|
||||
|
||||
@@ -160,13 +160,10 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
|
||||
});
|
||||
|
||||
const transformPrismaPerson = (person): TPerson => {
|
||||
const attributes = person.attributes.reduce(
|
||||
(acc, attr) => {
|
||||
acc[attr.attributeClass.name] = attr.value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string | number>
|
||||
);
|
||||
const attributes = person.attributes.reduce((acc, attr) => {
|
||||
acc[attr.attributeClass.name] = attr.value;
|
||||
return acc;
|
||||
}, {} as Record<string, string | number>);
|
||||
|
||||
return {
|
||||
id: person.id,
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Survey" ADD COLUMN "pin" INTEGER;
|
||||
@@ -268,6 +268,9 @@ model Survey {
|
||||
/// @zod.custom(imports.ZSurveyVerifyEmail)
|
||||
/// [SurveyVerifyEmail]
|
||||
verifyEmail Json?
|
||||
|
||||
// PIN Protected Surverys
|
||||
pin Int?
|
||||
}
|
||||
|
||||
model Event {
|
||||
|
||||
@@ -51,6 +51,7 @@ export const selectSurvey = {
|
||||
productOverwrites: true,
|
||||
surveyClosedMessage: true,
|
||||
singleUse: true,
|
||||
pin: true,
|
||||
triggers: {
|
||||
select: {
|
||||
eventClass: {
|
||||
|
||||
@@ -295,6 +295,7 @@ export const ZSurvey = z.object({
|
||||
surveyClosedMessage: ZSurveyClosedMessage.nullable(),
|
||||
singleUse: ZSurveySingleUse.nullable(),
|
||||
verifyEmail: ZSurveyVerifyEmail.nullable(),
|
||||
pin: z.number().nullable().optional(),
|
||||
});
|
||||
|
||||
export const ZSurveyInput = z.object({
|
||||
|
||||
@@ -8,6 +8,7 @@ export type OTPInputProps = {
|
||||
onChange: (value: string) => void;
|
||||
containerClassName?: string;
|
||||
inputBoxClassName?: string;
|
||||
disabled?: boolean
|
||||
};
|
||||
|
||||
const RE_DIGIT = /^\d+$/;
|
||||
@@ -18,6 +19,7 @@ export function OTPInput({
|
||||
onChange,
|
||||
containerClassName,
|
||||
inputBoxClassName,
|
||||
disabled,
|
||||
}: OTPInputProps) {
|
||||
const valueItems = useMemo(() => {
|
||||
const valueArray = value.split("");
|
||||
@@ -144,6 +146,7 @@ export function OTPInput({
|
||||
onChange={(e) => inputOnChange(e, idx)}
|
||||
onKeyDown={inputOnKeyDown}
|
||||
onFocus={inputOnFocus}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user