Add Email Verification option to Link Surveys (#762)

* completed frontend

* Adds email verifaction for Link Surveys

* remove console.log

* run pnpm format

* rename userId to verify

* add loading state

* fix type names

* add types to prisma

---------

Co-authored-by: Dhruwang Jariwala <dhruwang@Dhruwangs-MacBook-Pro.local>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Dhruwang Jariwala
2023-09-02 12:04:00 +05:30
committed by GitHub
parent eae8e2db24
commit b00beadf2e
21 changed files with 346 additions and 10 deletions
-1
View File
@@ -4,7 +4,6 @@
</a>
<h3 align="center">Formbricks</h3>
<p align="center">
The Open Source Survey & Experience Management solution for fast growing companies
<br />
@@ -95,8 +95,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
},
{
name: "Rivet",
description:
"Open-source solution to deploy, scale, and operate your multiplayer game.",
description: "Open-source solution to deploy, scale, and operate your multiplayer game.",
href: "https://rivet.gg",
},
{
+1 -1
View File
@@ -67,4 +67,4 @@ export default {
},
},
plugins: [typographyPlugin, headlessuiPlugin, forms],
} satisfies Config;
} satisfies Config;
@@ -163,6 +163,10 @@ export async function duplicateSurveyAction(environmentId: string, surveyId: str
surveyClosedMessage: existingSurvey.surveyClosedMessage
? JSON.parse(JSON.stringify(existingSurvey.surveyClosedMessage))
: prismaClient.JsonNull,
verifyEmail: existingSurvey.verifyEmail
? JSON.parse(JSON.stringify(existingSurvey.verifyEmail))
: prismaClient.JsonNull,
},
});
return newSurvey;
@@ -290,6 +294,7 @@ export async function copyToOtherEnvironmentAction(
},
},
surveyClosedMessage: existingSurvey.surveyClosedMessage ?? prismaClient.JsonNull,
verifyEmail: existingSurvey.verifyEmail ?? prismaClient.JsonNull,
},
});
return newSurvey;
@@ -20,10 +20,17 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res
const [redirectUrl, setRedirectUrl] = useState<string | null>("");
const [surveyClosedMessageToggle, setSurveyClosedMessageToggle] = useState(false);
const [verifyEmailToggle, setVerifyEmailToggle] = useState(false);
const [surveyClosedMessage, setSurveyClosedMessage] = useState({
heading: "Survey Completed",
subheading: "This free & open-source survey has been closed",
});
const [verifyEmailSurveyDetails, setVerifyEmailSurveyDetails] = useState({
name: "",
subheading: "",
});
const [closeOnDate, setCloseOnDate] = useState<Date>();
const handleRedirectCheckMark = () => {
@@ -63,6 +70,14 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res
}
};
const handleVerifyEmailToogle = () => {
setVerifyEmailToggle((prev) => !prev);
if (verifyEmailToggle && localSurvey.verifyEmail) {
setLocalSurvey({ ...localSurvey, verifyEmail: null });
}
};
const handleCloseOnDateChange = (date: Date) => {
const equivalentDate = date?.getDate();
date?.setUTCHours(0, 0, 0, 0);
@@ -88,6 +103,22 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res
setLocalSurvey({ ...localSurvey, surveyClosedMessage: message });
};
const handleVerifyEmailSurveyDetailsChange = ({
name,
subheading,
}: {
name?: string;
subheading?: string;
}) => {
const message = {
name: name || verifyEmailSurveyDetails.name,
subheading: subheading || verifyEmailSurveyDetails.subheading,
};
setVerifyEmailSurveyDetails(message);
setLocalSurvey({ ...localSurvey, verifyEmail: message });
};
useEffect(() => {
if (localSurvey.redirectUrl) {
setRedirectUrl(localSurvey.redirectUrl);
@@ -102,6 +133,14 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res
setSurveyClosedMessageToggle(true);
}
if (localSurvey.verifyEmail) {
setVerifyEmailSurveyDetails({
name: localSurvey.verifyEmail.name!,
subheading: localSurvey.verifyEmail.subheading!,
});
setVerifyEmailToggle(true);
}
if (localSurvey.closeOnDate) {
setCloseOnDate(localSurvey.closeOnDate);
setSurveyCloseOnDateToggle(true);
@@ -257,6 +296,43 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res
</div>
</div>
</AdvancedOptionToggle>
<AdvancedOptionToggle
htmlId="verifyEmailBeforeSubmission"
isChecked={verifyEmailToggle}
onToggle={handleVerifyEmailToogle}
title="Verify email before submission"
description="Only let people with a real email respond."
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">
<p className="text-md font-semibold">How it works</p>
<p className="mb-4 mt-2 text-sm text-slate-500">
Respondants will receive the survey link via email.
</p>
<Label htmlFor="headline">Survey Name (Public)</Label>
<Input
autoFocus
id="heading"
className="mb-4 mt-2 bg-white"
name="heading"
placeholder="Job Application Form"
defaultValue={verifyEmailSurveyDetails.name}
onChange={(e) => handleVerifyEmailSurveyDetailsChange({ name: e.target.value })}
/>
<Label htmlFor="headline">Subheader (Public)</Label>
<Input
className="mt-2 bg-white"
id="subheading"
name="subheading"
placeholder="Thanks for applying as a full stack engineer"
defaultValue={verifyEmailSurveyDetails.subheading}
onChange={(e) => handleVerifyEmailSurveyDetailsChange({ subheading: e.target.value })}
/>
</div>
</div>
</AdvancedOptionToggle>
</>
)}
</div>
@@ -129,6 +129,7 @@ export default function SurveyMenuBar({
toast.error("Please enter a valid URL for redirecting respondents");
return false;
}
return true;
};
@@ -164,7 +165,8 @@ export default function SurveyMenuBar({
}
}
})
.catch(() => {
.catch((error) => {
console.log(error);
toast.error(`Error saving changes`);
});
};
+37
View File
@@ -13,6 +13,8 @@ import { useEffect, useRef, useState } from "react";
import { TSurvey } from "@formbricks/types/v1/surveys";
import Loading from "@/app/s/[surveyId]/loading";
import { TProduct } from "@formbricks/types/v1/product";
import VerifyEmail from "@/app/s/[surveyId]/VerifyEmail";
import { verifyTokenAction } from "@/app/s/[surveyId]/actions";
interface LinkSurveyProps {
survey: TSurvey;
@@ -40,6 +42,34 @@ export default function LinkSurvey({ survey, product }: LinkSurveyProps) {
// Create a reference to the top element
const topRef = useRef<HTMLDivElement>(null);
const [autoFocus, setAutofocus] = useState(false);
const URLParams = new URLSearchParams(typeof window !== "undefined" ? window.location.search : "");
const [shouldRenderVerifyEmail, setShouldRenderVerifyEmail] = useState(false);
const [isTokenValid, setIsTokenValid] = useState(true);
const checkVerifyToken = async (verifyToken: string): Promise<boolean> => {
try {
const result = await verifyTokenAction(verifyToken, survey.id);
return result;
} catch (error) {
return false;
}
};
useEffect(() => {
if (survey.verifyEmail) {
setShouldRenderVerifyEmail(true);
}
const verifyToken = URLParams.get("verify");
if (verifyToken) {
checkVerifyToken(verifyToken)
.then((result) => {
setIsTokenValid(result);
setShouldRenderVerifyEmail(!result); // Set shouldRenderVerifyEmail based on result
})
.catch((error) => {
console.error("Error checking verify token:", error);
});
}
}, []);
// Not in an iframe, enable autofocus on input fields.
useEffect(() => {
@@ -63,6 +93,13 @@ export default function LinkSurvey({ survey, product }: LinkSurveyProps) {
);
}
if (shouldRenderVerifyEmail) {
if (!isTokenValid) {
return <VerifyEmail survey={survey} isErrorComponent={true} />;
}
return <VerifyEmail survey={survey} />;
}
return (
<>
<div
+118
View File
@@ -0,0 +1,118 @@
"use client";
import React, { useState } from "react";
import { EnvelopeIcon } from "@heroicons/react/24/solid";
import { Button, Input } from "@formbricks/ui";
import { Toaster, toast } from "react-hot-toast";
import { sendLinkSurveyEmailAction } from "@/app/s/[surveyId]/actions";
import { TSurvey } from "@formbricks/types/v1/surveys";
export default function VerifyEmail({
survey,
isErrorComponent,
}: {
survey: TSurvey;
isErrorComponent?: boolean;
}) {
const [showPreviewQuestions, setShowPreviewQuestions] = useState(false);
const [email, setEmail] = useState<string | null>(null);
const [emailSent, setEmailSent] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(false);
const validateEmail = (inputEmail) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(inputEmail);
const submitEmail = async (email) => {
setIsLoading(true);
if (!validateEmail(email)) {
toast.error("Please enter a valid email");
setIsLoading(false);
return;
}
const data = {
surveyId: survey.id,
email: email,
surveyData: survey.verifyEmail,
};
try {
await sendLinkSurveyEmailAction(data);
setEmailSent(true);
} catch (error) {
toast.error(error.message);
}
setIsLoading(false);
};
const handlePreviewClick = () => {
setShowPreviewQuestions(!showPreviewQuestions);
};
const handleGoBackClick = () => {
setShowPreviewQuestions(false);
setEmailSent(false);
};
if (isErrorComponent) {
return (
<div className="flex h-full w-full flex-col items-center justify-center bg-slate-50">
<span className="h-24 w-24 rounded-full bg-slate-300 p-6 text-5xl">🤔</span>
<p className="mt-8 text-4xl font-bold">This looks fishy.</p>
<p className="mt-4 cursor-pointer text-sm text-slate-400" onClick={handleGoBackClick}>
Please try again with the original link
</p>
</div>
);
}
return (
<div className="flex h-full w-full flex-col items-center justify-center bg-slate-50">
<Toaster />
{!emailSent && !showPreviewQuestions && (
<div className="flex flex-col items-center justify-center">
<EnvelopeIcon className="h-24 w-24 rounded-full bg-slate-300 p-6 text-white" />
<p className="mt-8 text-4xl font-bold">Verify your email to respond.</p>
<p className="mt-2 text-slate-400">To respond to this survey please verify your email.</p>
<div className="mt-6 flex w-full space-x-2">
<Input
type="string"
className="h-full"
placeholder="user@gmail.com"
value={email || ""}
onChange={(e) => setEmail(e.target.value)}
/>
<Button variant="darkCTA" onClick={() => submitEmail(email)} loading={isLoading}>
Verify
</Button>
</div>
<p className="mt-6 cursor-pointer text-sm text-slate-400" onClick={handlePreviewClick}>
Just curious? Preview survey questions.
</p>
</div>
)}
{!emailSent && showPreviewQuestions && (
<div className="flex flex-col items-center justify-center">
<p className="text-4xl font-bold">Question Preview</p>
<div className="mt-4 flex w-full flex-col justify-center rounded-lg border border-slate-200 p-8">
{survey.questions.map((question, index) => (
<p key={index} className="my-1">{`${index + 1}. ${question.headline}`}</p>
))}
</div>
<p className="mt-6 cursor-pointer text-sm text-slate-400" onClick={handlePreviewClick}>
Want to respond? Verify email.
</p>
</div>
)}
{emailSent && (
<div className="flex flex-col items-center justify-center">
<h1 className="mt-8 text-4xl font-bold">Survey sent successfully</h1>
<p className="mt-4 text-center text-slate-400">
We sent an email to <span className="font-semibold italic">{email}</span>. Please click the link
in the email to take your survey.
</p>
<p className="mt-6 cursor-pointer text-sm text-slate-400" onClick={handleGoBackClick}>
Go Back
</p>
</div>
)}
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
"use server";
interface LinkSurveyEmailData {
surveyId: string;
email: string;
surveyData?: {
name?: string;
subheading?: string;
} | null;
}
import { sendLinkSurveyToVerifiedEmail } from "@/lib/email";
import { verifyTokenForLinkSurvey } from "@/lib/jwt";
export async function sendLinkSurveyEmailAction(data: LinkSurveyEmailData) {
if (!data.surveyData) {
throw new Error("No survey data provided");
}
return await sendLinkSurveyToVerifiedEmail(data);
}
export async function verifyTokenAction(token: string, surveyId: string): Promise<boolean> {
return await verifyTokenForLinkSurvey(token, surveyId);
}
+21 -1
View File
@@ -4,7 +4,7 @@ import { WEBAPP_URL } from "@formbricks/lib/constants";
import { Question } from "@formbricks/types/questions";
import { TResponse } from "@formbricks/types/v1/responses";
import { withEmailTemplate } from "./email-template";
import { createInviteToken, createToken } from "./jwt";
import { createInviteToken, createToken, createTokenForLinkSurvey } from "./jwt";
const nodemailer = require("nodemailer");
@@ -56,6 +56,26 @@ export const sendVerificationEmail = async (user) => {
});
};
export const sendLinkSurveyToVerifiedEmail = async (data) => {
const surveyId = data.surveyId;
const email = data.email;
const surveyData = data.surveyData;
const token = createTokenForLinkSurvey(surveyId, email);
const surveyLink = `${WEBAPP_URL}/s/${surveyId}?verify=${encodeURIComponent(token)}`;
await sendEmail({
to: data.email,
subject: "Your Formbricks Survey",
html: withEmailTemplate(`<h1>Hey 👋</h1>
Thanks for validating your email. Here is your Survey.<br/><br/>
<strong>${surveyData.name}</strong>
<p>${surveyData.subheading}</p>
<a class="button" href="${surveyLink}">Take survey</a><br/>
<br/>
All the best,<br/>
Your Formbricks Team 🤍`),
});
};
export const sendForgotPasswordEmail = async (user) => {
const token = createToken(user.id, user.email, {
expiresIn: "1d",
+15
View File
@@ -5,6 +5,21 @@ import { env } from "@/env.mjs";
export function createToken(userId, userEmail, options = {}) {
return jwt.sign({ id: userId }, env.NEXTAUTH_SECRET + userEmail, options);
}
export function createTokenForLinkSurvey(surveyId, userEmail) {
return jwt.sign({ email: userEmail }, env.NEXTAUTH_SECRET + surveyId);
}
export function verifyTokenForLinkSurvey(token, surveyId): Promise<boolean> {
return new Promise((resolve) => {
jwt.verify(token, env.NEXTAUTH_SECRET + surveyId, function (err) {
if (err) {
resolve(false);
} else {
resolve(true);
}
});
});
}
export async function verifyToken(token, userEmail = "") {
if (!userEmail) {
@@ -146,6 +146,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
},
},
surveyClosedMessage: existingSurvey.surveyClosedMessage ?? 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,
verifyEmail: existingSurvey.verifyEmail ?? prismaClient.JsonNull,
},
});
@@ -97,6 +97,10 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
if (!body.surveyClosedMessage) {
body.surveyClosedMessage = prismaClient.JsonNull;
}
if (!body.verifyEmail) {
body.verifyEmail = prismaClient.JsonNull;
}
}
if (body.triggers) {
@@ -226,6 +230,10 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
data.surveyClosedMessage = prismaClient.JsonNull;
}
if (data.verifyEmail === null) {
data.verifyEmail = prismaClient.JsonNull;
}
const prismaRes = await prisma.survey.update({
where: { id: surveyId },
data,
+7 -1
View File
@@ -1,6 +1,11 @@
import { TActionClassNoCodeConfig } from "@formbricks/types/v1/actionClasses";
import { TResponsePersonAttributes, TResponseData, TResponseMeta } from "@formbricks/types/v1/responses";
import { TSurveyClosedMessage, TSurveyQuestions, TSurveyThankYouCard } from "@formbricks/types/v1/surveys";
import {
TSurveyClosedMessage,
TSurveyQuestions,
TSurveyThankYouCard,
TSurveyVerifyEmail,
} from "@formbricks/types/v1/surveys";
import { TUserNotificationSettings } from "@formbricks/types/v1/users";
declare global {
@@ -13,6 +18,7 @@ declare global {
export type SurveyQuestions = TSurveyQuestions;
export type SurveyThankYouCard = TSurveyThankYouCard;
export type SurveyClosedMessage = TSurveyClosedMessage;
export type SurveyVerifyEmail = TSurveyVerifyEmail;
export type UserNotificationSettings = TUserNotificationSettings;
}
}
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Survey" ADD COLUMN "verifyEmail" JSONB;
+3 -2
View File
@@ -220,11 +220,9 @@ model Survey {
environmentId String
status SurveyStatus @default(draft)
/// @zod.custom(imports.ZSurveyQuestions)
/// @zod.custom(imports.ZSurveyQuestions)
/// [SurveyQuestions]
questions Json @default("[]")
/// @zod.custom(imports.ZSurveyThankYouCard)
/// @zod.custom(imports.ZSurveyThankYouCard)
/// [SurveyThankYouCard]
thankYouCard Json @default("{\"enabled\": false}")
responses Response[]
@@ -240,6 +238,9 @@ model Survey {
/// @zod.custom(imports.ZSurveyClosedMessage)
/// [SurveyClosedMessage]
surveyClosedMessage Json?
/// @zod.custom(imports.ZSurveyVerifyEmail)
/// [SurveyVerifyEmail]
verifyEmail Json?
}
model Event {
+6 -1
View File
@@ -5,6 +5,11 @@ export { ZActionClassNoCodeConfig } from "@formbricks/types/v1/actionClasses";
export { ZResponseData, ZResponsePersonAttributes, ZResponseMeta } from "@formbricks/types/v1/responses";
export { ZSurveyQuestions, ZSurveyThankYouCard, ZSurveyClosedMessage } from "@formbricks/types/v1/surveys";
export {
ZSurveyQuestions,
ZSurveyThankYouCard,
ZSurveyClosedMessage,
ZSurveyVerifyEmail,
} from "@formbricks/types/v1/surveys";
export { ZUserNotificationSettings } from "@formbricks/types/v1/users";
+2
View File
@@ -77,6 +77,7 @@ export const selectSurvey = {
closeOnDate: true,
delay: true,
autoComplete: true,
verifyEmail: true,
redirectUrl: true,
triggers: {
select: {
@@ -152,6 +153,7 @@ export const getSurveyWithAnalytics = cache(
const survey = ZSurveyWithAnalytics.parse(transformedSurvey);
return survey;
} catch (error) {
console.log(error);
if (error instanceof z.ZodError) {
console.error(JSON.stringify(error.errors, null, 2)); // log the detailed error information
}
+6
View File
@@ -11,6 +11,11 @@ export interface SurveyClosedMessage {
subheading?: string;
}
export interface VerifyEmail {
name?: string;
subheading?: string;
}
export interface Survey {
id: string;
createdAt: string;
@@ -32,6 +37,7 @@ export interface Survey {
delay: number;
autoComplete: number | null;
surveyClosedMessage: SurveyClosedMessage | null;
verifyEmail: VerifyEmail | null;
closeOnDate: Date | null;
_count: { responses: number | null } | null;
}
+10
View File
@@ -15,6 +15,15 @@ export const ZSurveyClosedMessage = z
})
.optional();
export const ZSurveyVerifyEmail = z
.object({
name: z.optional(z.string()),
subheading: z.optional(z.string()),
})
.optional();
export type TSurveyVerifyEmail = z.infer<typeof ZSurveyVerifyEmail>;
export type TSurveyThankYouCard = z.infer<typeof ZSurveyThankYouCard>;
export type TSurveyClosedMessage = z.infer<typeof ZSurveyThankYouCard>;
@@ -239,6 +248,7 @@ export const ZSurvey = z.object({
autoComplete: z.union([z.number(), z.null()]),
closeOnDate: z.date().nullable(),
surveyClosedMessage: ZSurveyClosedMessage,
verifyEmail: ZSurveyVerifyEmail.nullable(),
});
export type TSurvey = z.infer<typeof ZSurvey>;