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:
Piyush Garg
2023-10-16 16:42:32 +05:30
committed by GitHub
parent accd977ddc
commit 5e5723d091
14 changed files with 259 additions and 21 deletions

View File

@@ -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>

View File

@@ -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: ${

View File

@@ -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 };
}
}

View 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;

View File

@@ -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}

View File

@@ -0,0 +1,5 @@
export enum TSurveryPinValidationResponseError {
INCORRECT_PIN = "INCORRECT_PIN",
INTERNAL_SERVER_ERROR = "INTERNAL_SERVER_ERROR",
NOT_FOUND = "NOT_FOUND",
}

View File

@@ -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:*"
}
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Survey" ADD COLUMN "pin" INTEGER;

View File

@@ -268,6 +268,9 @@ model Survey {
/// @zod.custom(imports.ZSurveyVerifyEmail)
/// [SurveyVerifyEmail]
verifyEmail Json?
// PIN Protected Surverys
pin Int?
}
model Event {

View File

@@ -51,6 +51,7 @@ export const selectSurvey = {
productOverwrites: true,
surveyClosedMessage: true,
singleUse: true,
pin: true,
triggers: {
select: {
eventClass: {

View File

@@ -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({

View File

@@ -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>