Feat/verify phone number (#6035)

* first implementation of verifying phone number

* add UI + logic for verifying phone number flow

* check if all phone numbers are verified before submit

* add numberVerification pending

* only send SMS to verified numbers

* fix design for mobile view

* check if phone number is verified before testing action

* add back message service id check

* add TWILIO_VERIFY_SID to .env.example

* code clean up

* add TWILIO_VERIFY_SID to README.md instructions

* save new verified numbers

* fix wrongly thrown error for new verified numbers

* use false as default value for isVerificationPending paramater

* add translations

* add migration file

* code clean up

* don't throw error if phone number is not needed

* Feedback

* Shows error when Twillio isn't properly setup

* Type fixes

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: zomars <zomars@me.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
This commit is contained in:
Carina Wollendorfer 2022-12-15 22:54:40 +01:00 committed by GitHub
parent 688541923b
commit c2f150dbab
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 316 additions and 43 deletions

View File

@ -87,6 +87,7 @@ TWILIO_TOKEN=
TWILIO_MESSAGING_SID=
TWILIO_PHONE_NUMBER=
NEXT_PUBLIC_SENDER_ID=
TWILIO_VERIFY_SID=
# This is used so we can bypass emails in auth flows for E2E testing
# Set it to "1" if you need to run E2E tests locally

View File

@ -429,6 +429,8 @@ following
12. Leave all other fields as they are
13. Complete setup and click View my new Messaging Service
14. Copy Messaging Service SID to your .env file into the TWILIO_MESSAGING_SID field
15. Create a verify service
16. Copy Verify Service SID to your .env file into the TWILIO_VERIFY_SID field
<!-- LICENSE -->

View File

@ -1455,6 +1455,12 @@
"event_type_duplicate_copy_text": "{{slug}}-copy",
"set_as_default": "Set as default",
"hide_eventtype_details": "Hide EventType Details",
"verification_code_sent": "Verification code sent",
"verified_successfully": "Verified successfully",
"wrong_code": "Wong verification code",
"not_verified": "Not yet verified",
"no_availability_in_month": "No availability in {{month}}",
"view_next_month": "View next month"
"view_next_month": "View next month",
"send_code" : "Send code",
"number_verified": "Number Verified"
}

View File

@ -6,10 +6,8 @@ import { Controller, UseFormReturn } from "react-hook-form";
import { SENDER_ID } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Icon } from "@calcom/ui";
import { Button, Label, TextField } from "@calcom/ui";
import { MultiSelectCheckboxes } from "@calcom/ui";
import type { MultiSelectCheckboxesOptionType as Option } from "@calcom/ui";
import { Button, Icon, Label, MultiSelectCheckboxes, TextField } from "@calcom/ui";
import type { FormValues } from "../pages/workflow";
import { AddActionDialog } from "./AddActionDialog";
@ -74,6 +72,7 @@ export default function WorkflowDetailsPage(props: Props) {
template: WorkflowTemplates.CUSTOM,
numberRequired: numberRequired || false,
sender: sender || SENDER_ID,
numberVerificationPending: false,
};
steps?.push(step);
form.setValue("steps", steps);

View File

@ -9,11 +9,13 @@ import { Dispatch, SetStateAction, useRef, useState } from "react";
import { Controller, UseFormReturn } from "react-hook-form";
import "react-phone-number-input/style.css";
import { classNames } from "@calcom/lib";
import { SENDER_ID } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error";
import { trpc, TRPCClientError } from "@calcom/trpc/react";
import {
Badge,
Button,
Checkbox,
ConfirmationDialogContent,
@ -35,11 +37,7 @@ import {
} from "@calcom/ui";
import { AddVariablesDropdown } from "../components/AddVariablesDropdown";
import {
getWorkflowActionOptions,
getWorkflowTemplateOptions,
getWorkflowTriggerOptions,
} from "../lib/getOptions";
import { getWorkflowTemplateOptions, getWorkflowTriggerOptions } from "../lib/getOptions";
import { translateVariablesToEnglish } from "../lib/variableTranslations";
import type { FormValues } from "../pages/workflow";
import Editor from "./TextEditor/Editor";
@ -54,10 +52,15 @@ type WorkflowStepProps = {
export default function WorkflowStepContainer(props: WorkflowStepProps) {
const { t, i18n } = useLocale();
const utils = trpc.useContext();
const { step, form, reload, setReload } = props;
const { data: _verifiedNumbers } = trpc.viewer.workflows.getVerifiedNumbers.useQuery();
const verifiedNumbers = _verifiedNumbers?.map((number) => number.phoneNumber);
const [isAdditionalInputsDialogOpen, setIsAdditionalInputsDialogOpen] = useState(false);
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
const [verificationCode, setVerificationCode] = useState("");
const [isPhoneNumberNeeded, setIsPhoneNumberNeeded] = useState(
step?.action === WorkflowActions.SMS_NUMBER ? true : false
);
@ -108,6 +111,12 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
const refReminderBody = useRef<HTMLTextAreaElement | null>(null);
const [numberVerified, setNumberVerified] = useState(
verifiedNumbers && step
? !!verifiedNumbers.find((number) => number === form.getValues(`steps.${step.stepNumber - 1}.sendTo`))
: false
);
const addVariable = (variable: string, isEmailSubject?: boolean) => {
if (step) {
if (isEmailSubject) {
@ -128,6 +137,30 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
}
};
const sendVerificationCodeMutation = trpc.viewer.workflows.sendVerificationCode.useMutation({
onSuccess: async () => {
showToast(t("verification_code_sent"), "success");
},
onError: async (error) => {
showToast(error.message, "error");
},
});
const verifyPhoneNumberMutation = trpc.viewer.workflows.verifyPhoneNumber.useMutation({
onSuccess: async (isVerified) => {
showToast(isVerified ? t("verified_successfully") : t("wrong_code"), "success");
setNumberVerified(isVerified);
utils.viewer.workflows.getVerifiedNumbers.invalidate();
},
onError: (err) => {
if (err instanceof HttpError) {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
setNumberVerified(false);
}
},
});
const testActionMutation = trpc.viewer.workflows.testAction.useMutation({
onSuccess: async () => {
showToast(t("notification_sent"), "success");
@ -137,6 +170,8 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
if (err instanceof TRPCClientError) {
if (err.message === "rate-limit-exceeded") {
message = t("rate_limit_exceeded");
} else {
message = err.message;
}
}
if (err instanceof HttpError) {
@ -311,7 +346,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
setIsSenderIdNeeded(true);
setIsEmailAddressNeeded(false);
setIsPhoneNumberNeeded(val.value === WorkflowActions.SMS_NUMBER);
setNumberVerified(false);
if (!wasSMSAction) {
form.setValue(`steps.${step.stepNumber - 1}.reminderBody`, "");
}
@ -356,20 +391,76 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
{isPhoneNumberNeeded && (
<>
<Label className="pt-4">{t("custom_phone_number")}</Label>
<PhoneInput<FormValues>
control={form.control}
name={`steps.${step.stepNumber - 1}.sendTo`}
placeholder={t("phone_number")}
id={`steps.${step.stepNumber - 1}.sendTo`}
className="w-full rounded-md"
required
/>
<div className="block sm:flex">
<PhoneInput<FormValues>
control={form.control}
name={`steps.${step.stepNumber - 1}.sendTo`}
placeholder={t("phone_number")}
id={`steps.${step.stepNumber - 1}.sendTo`}
className="min-w-fit sm:rounded-tl-md sm:rounded-bl-md sm:border-r-transparent"
required
onChange={() => {
const isAlreadyVerified = !!verifiedNumbers
?.concat([])
.find(
(number) => number === form.getValues(`steps.${step.stepNumber - 1}.sendTo`)
);
setNumberVerified(isAlreadyVerified);
}}
/>
<Button
color="secondary"
disabled={numberVerified || false}
className={classNames(
"-ml-[3px] h-[40px] min-w-fit sm:block sm:rounded-tl-none sm:rounded-bl-none ",
numberVerified ? "hidden" : "mt-3 sm:mt-0"
)}
onClick={() =>
sendVerificationCodeMutation.mutate({
phoneNumber: form.getValues(`steps.${step.stepNumber - 1}.sendTo`) || "",
})
}>
{t("send_code")}
</Button>
</div>
{form.formState.errors.steps &&
form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo && (
<p className="mt-1 text-xs text-red-500">
{form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo?.message || ""}
</p>
)}
{numberVerified ? (
<div className="mt-1">
<Badge variant="green">{t("number_verified")}</Badge>
</div>
) : (
<>
<div className="mt-3 flex">
<TextField
className=" border-r-transparent"
placeholder="Verification code"
value={verificationCode}
onChange={(e) => {
setVerificationCode(e.target.value);
}}
required
/>
<Button
color="secondary"
className="-ml-[3px] rounded-tl-none rounded-bl-none "
disabled={verifyPhoneNumberMutation.isLoading}
onClick={() => {
verifyPhoneNumberMutation.mutate({
phoneNumber: form.getValues(`steps.${step.stepNumber - 1}.sendTo`) || "",
code: verificationCode,
});
}}>
Verify
</Button>
</div>
</>
)}
</>
)}
{isSenderIdNeeded && (
@ -525,6 +616,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
className="mt-7 w-full"
onClick={() => {
let isEmpty = false;
if (!form.getValues(`steps.${step.stepNumber - 1}.sendTo`) && isPhoneNumberNeeded) {
form.setError(`steps.${step.stepNumber - 1}.sendTo`, {
type: "custom",
@ -532,6 +624,13 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
});
isEmpty = true;
}
if (!numberVerified && isPhoneNumberNeeded) {
form.setError(`steps.${step.stepNumber - 1}.sendTo`, {
type: "custom",
message: t("not_verified"),
});
}
if (
form.getValues(`steps.${step.stepNumber - 1}.template`) === WorkflowTemplates.CUSTOM
) {
@ -576,7 +675,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
? false
: true;
if (isPhoneNumberNeeded && isNumberValid && !isEmpty) {
if (isPhoneNumberNeeded && isNumberValid && !isEmpty && numberVerified) {
setConfirmationDialogOpen(true);
}
}

View File

@ -52,7 +52,9 @@ export const scheduleWorkflowReminders = async (
step.reminderBody || "",
step.id,
step.template,
step.sender || SENDER_ID
step.sender || SENDER_ID,
workflow.userId,
step.numberVerificationPending
);
} else if (
step.action === WorkflowActions.EMAIL_ATTENDEE ||
@ -121,7 +123,9 @@ export const sendCancelledReminders = async (
step.reminderBody || "",
step.id,
step.template,
step.sender || SENDER_ID
step.sender || SENDER_ID,
workflow.userId,
step.numberVerificationPending
);
} else if (
step.action === WorkflowActions.EMAIL_ATTENDEE ||

View File

@ -49,3 +49,26 @@ export const cancelSMS = async (referenceId: string) => {
assertTwilio(twilio);
await twilio.messages(referenceId).update({ status: "canceled" });
};
export const sendVerificationCode = async (phoneNumber: string) => {
assertTwilio(twilio);
if (process.env.TWILIO_VERIFY_SID) {
await twilio.verify
.services(process.env.TWILIO_VERIFY_SID)
.verifications.create({ to: phoneNumber, channel: "sms" });
}
};
export const verifyNumber = async (phoneNumber: string, code: string) => {
assertTwilio(twilio);
if (process.env.TWILIO_VERIFY_SID) {
try {
const verification_check = await twilio.verify.v2
.services(process.env.TWILIO_VERIFY_SID)
.verificationChecks.create({ to: phoneNumber, code: code });
return verification_check.status;
} catch (e) {
return "failed";
}
}
};

View File

@ -50,7 +50,9 @@ export const scheduleSMSReminder = async (
message: string,
workflowStepId: number,
template: WorkflowTemplates,
sender: string
sender: string,
userId: number,
isVerificationPending = false
) => {
const { startTime, endTime } = evt;
const uid = evt.uid as string;
@ -60,6 +62,18 @@ export const scheduleSMSReminder = async (
const senderID = getSenderId(reminderPhone, sender);
//SMS_ATTENDEE action does not need to be verified
//isVerificationPending is from all already existing workflows (once they edit their workflow, they will also have to verify the number)
async function getIsNumberVerified() {
if (action === WorkflowActions.SMS_ATTENDEE) return true;
const verifiedNumber = await prisma.verifiedNumber.findFirst({
where: { userId, phoneNumber: reminderPhone || "" },
});
if (!!verifiedNumber) return true;
return isVerificationPending;
}
const isNumberVerified = await getIsNumberVerified();
if (triggerEvent === WorkflowTriggerEvents.BEFORE_EVENT) {
scheduledDate = timeSpan.time && timeUnit ? dayjs(startTime).subtract(timeSpan.time, timeUnit) : null;
} else if (triggerEvent === WorkflowTriggerEvents.AFTER_EVENT) {
@ -93,7 +107,7 @@ export const scheduleSMSReminder = async (
break;
}
if (message.length > 0 && reminderPhone) {
if (message.length > 0 && reminderPhone && isNumberVerified) {
//send SMS when event is booked/cancelled/rescheduled
if (
triggerEvent === WorkflowTriggerEvents.NEW_EVENT ||

View File

@ -0,0 +1,22 @@
import prisma from "@calcom/prisma";
import * as twilio from "./smsProviders/twilioProvider";
export const sendVerificationCode = async (phoneNumber: string) => {
return twilio.sendVerificationCode(phoneNumber);
};
export const verifyPhoneNumber = async (phoneNumber: string, code: string, userId: number) => {
const verificationStatus = await twilio.verifyNumber(phoneNumber, code);
if (verificationStatus === "approved") {
await prisma.verifiedNumber.create({
data: {
userId,
phoneNumber,
},
});
return true;
}
return false;
};

View File

@ -104,6 +104,8 @@ function WorkflowPage() {
}
);
const { data: verifiedNumbers } = trpc.viewer.workflows.getVerifiedNumbers.useQuery();
useEffect(() => {
if (workflow && !isLoading) {
setSelectedEventTypes(
@ -175,6 +177,7 @@ function WorkflowPage() {
handleSubmit={async (values) => {
let activeOnEventTypeIds: number[] = [];
let isEmpty = false;
let isVerified = true;
values.steps.forEach((step) => {
const isSMSAction =
@ -199,9 +202,22 @@ function WorkflowPage() {
step.emailSubject = translateVariablesToEnglish(step.emailSubject, { locale: i18n.language, t });
}
isEmpty = !isEmpty ? isBodyEmpty : isEmpty;
//check if phone number is verified
if (
step.action === WorkflowActions.SMS_NUMBER &&
!verifiedNumbers?.find((verifiedNumber) => verifiedNumber.phoneNumber === step.sendTo)
) {
isVerified = false;
form.setError(`steps.${step.stepNumber - 1}.sendTo`, {
type: "custom",
message: t("not_verified"),
});
}
});
if (!isEmpty) {
if (!isEmpty && isVerified) {
if (values.activeOn) {
activeOnEventTypeIds = values.activeOn.map((option) => {
return parseInt(option.value, 10);
@ -216,6 +232,7 @@ function WorkflowPage() {
time: values.time || null,
timeUnit: values.timeUnit || null,
});
utils.viewer.workflows.getVerifiedNumbers.invalidate();
}
}}>
<Shell

View File

@ -0,0 +1,14 @@
-- AlterTable
ALTER TABLE "WorkflowStep" ADD COLUMN "numberVerificationPending" BOOLEAN NOT NULL DEFAULT true;
-- CreateTable
CREATE TABLE "VerifiedNumber" (
"id" SERIAL NOT NULL,
"userId" INTEGER NOT NULL,
"phoneNumber" TEXT NOT NULL,
CONSTRAINT "VerifiedNumber_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "VerifiedNumber" ADD CONSTRAINT "VerifiedNumber_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -186,6 +186,7 @@ model User {
ownedEventTypes EventType[] @relation("owner")
workflows Workflow[]
routingForms App_RoutingForms_Form[] @relation("routing-form")
verifiedNumbers VerifiedNumber[]
@@map(name: "users")
}
@ -561,18 +562,19 @@ enum WorkflowActions {
}
model WorkflowStep {
id Int @id @default(autoincrement())
stepNumber Int
action WorkflowActions
workflowId Int
workflow Workflow @relation(fields: [workflowId], references: [id], onDelete: Cascade)
sendTo String?
reminderBody String?
emailSubject String?
template WorkflowTemplates @default(REMINDER)
workflowReminders WorkflowReminder[]
numberRequired Boolean?
sender String?
id Int @id @default(autoincrement())
stepNumber Int
action WorkflowActions
workflowId Int
workflow Workflow @relation(fields: [workflowId], references: [id], onDelete: Cascade)
sendTo String?
reminderBody String?
emailSubject String?
template WorkflowTemplates @default(REMINDER)
workflowReminders WorkflowReminder[]
numberRequired Boolean?
sender String?
numberVerificationPending Boolean @default(true)
}
model Workflow {
@ -622,3 +624,10 @@ enum WorkflowMethods {
EMAIL
SMS
}
model VerifiedNumber {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
phoneNumber String
}

View File

@ -27,6 +27,10 @@ import {
deleteScheduledSMSReminder,
scheduleSMSReminder,
} from "@calcom/features/ee/workflows/lib/reminders/smsReminderManager";
import {
verifyPhoneNumber,
sendVerificationCode,
} from "@calcom/features/ee/workflows/lib/reminders/verifyPhoneNumber";
import { SENDER_ID } from "@calcom/lib/constants";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import { getTranslation } from "@calcom/lib/server/i18n";
@ -162,6 +166,7 @@ export const workflowsRouter = router({
template: WorkflowTemplates.REMINDER,
workflowId: workflow.id,
sender: SENDER_ID,
numberVerificationPending: false,
},
});
return { workflow };
@ -481,7 +486,8 @@ export const workflowsRouter = router({
step.reminderBody || "",
step.id,
step.template,
step.sender || SENDER_ID
step.sender || SENDER_ID,
user.id
);
}
});
@ -555,6 +561,7 @@ export const workflowsRouter = router({
template: newStep.template,
numberRequired: newStep.numberRequired,
sender: newStep.sender || SENDER_ID,
numberVerificationPending: false,
},
});
//cancel all reminders of step and create new ones (not for newEventTypes)
@ -666,7 +673,8 @@ export const workflowsRouter = router({
newStep.reminderBody || "",
newStep.id || 0,
newStep.template,
newStep.sender || SENDER_ID
newStep.sender || SENDER_ID,
user.id
);
}
});
@ -695,7 +703,7 @@ export const workflowsRouter = router({
const newStep = step;
newStep.sender = step.sender || SENDER_ID;
const createdStep = await ctx.prisma.workflowStep.create({
data: step,
data: { ...step, numberVerificationPending: false },
});
if (
(trigger === WorkflowTriggerEvents.BEFORE_EVENT ||
@ -782,7 +790,8 @@ export const workflowsRouter = router({
step.reminderBody || "",
createdStep.id,
step.template,
step.sender || SENDER_ID
step.sender || SENDER_ID,
user.id
);
}
});
@ -848,6 +857,18 @@ export const workflowsRouter = router({
const senderID = sender || SENDER_ID;
if (action === WorkflowActions.SMS_NUMBER) {
if (!sendTo) throw new TRPCError({ code: "BAD_REQUEST", message: "Missing sendTo" });
const verifiedNumbers = await ctx.prisma.verifiedNumber.findFirst({
where: {
userId: ctx.user.id,
phoneNumber: sendTo,
},
});
if (!verifiedNumbers)
throw new TRPCError({ code: "UNAUTHORIZED", message: "Phone number is not verified" });
}
try {
const userWorkflow = await ctx.prisma.workflow.findUnique({
where: {
@ -953,7 +974,8 @@ export const workflowsRouter = router({
reminderBody,
0,
template,
senderID
senderID,
ctx.user.id
);
return { message: "Notification sent" };
}
@ -1031,6 +1053,39 @@ export const workflowsRouter = router({
});
}
}),
sendVerificationCode: authedProcedure
.input(
z.object({
phoneNumber: z.string(),
})
)
.mutation(async ({ input }) => {
const { phoneNumber } = input;
return sendVerificationCode(phoneNumber);
}),
verifyPhoneNumber: authedProcedure
.input(
z.object({
phoneNumber: z.string(),
code: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const { phoneNumber, code } = input;
const { user } = ctx;
const verifyStatus = await verifyPhoneNumber(phoneNumber, code, user.id);
return verifyStatus;
}),
getVerifiedNumbers: authedProcedure.query(async ({ ctx }) => {
const { user } = ctx;
const verifiedNumbers = await ctx.prisma.verifiedNumber.findMany({
where: {
userId: user.id,
},
});
return verifiedNumbers;
}),
getWorkflowActionOptions: authedProcedure.query(async ({ ctx }) => {
const userId = ctx.user.id;
const hasTeamPlan = (await ctx.prisma.membership.count({ where: { userId } })) > 0;

View File

@ -9,15 +9,22 @@ export type PhoneInputProps<FormValues> = Props<
required: boolean;
},
FormValues
>;
> & { onChange?: (e: any) => void };
function PhoneInput<FormValues>({ control, name, className, ...rest }: PhoneInputProps<FormValues>) {
function PhoneInput<FormValues>({
control,
name,
className,
onChange,
...rest
}: PhoneInputProps<FormValues>) {
return (
<BasePhoneInput
{...rest}
international
name={name}
control={control}
onChange={onChange}
countrySelectProps={{ className: "text-black" }}
numberInputProps={{
className: "border-0 text-sm focus:ring-0 dark:bg-darkgray-100 dark:placeholder:text-darkgray-600",

View File

@ -184,6 +184,7 @@
"$TWILIO_SID",
"$TWILIO_MESSAGING_SID",
"$TWILIO_PHONE_NUMBER",
"$TWILIO_VERIFY_SID",
"$CRON_API_KEY",
"$DAILY_API_KEY",
"$DAILY_SCALE_PLAN",