From c2f150dbab12e4a1bf4a258e023503a806fa95a0 Mon Sep 17 00:00:00 2001 From: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> Date: Thu, 15 Dec 2022 22:54:40 +0100 Subject: [PATCH] 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 Co-authored-by: zomars Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> --- .env.example | 1 + README.md | 2 + apps/web/public/static/locales/en/common.json | 8 +- .../components/WorkflowDetailsPage.tsx | 5 +- .../components/WorkflowStepContainer.tsx | 129 ++++++++++++++++-- .../lib/reminders/reminderScheduler.ts | 8 +- .../reminders/smsProviders/twilioProvider.ts | 23 ++++ .../lib/reminders/smsReminderManager.ts | 18 ++- .../lib/reminders/verifyPhoneNumber.ts | 22 +++ .../features/ee/workflows/pages/workflow.tsx | 19 ++- .../migration.sql | 14 ++ packages/prisma/schema.prisma | 33 +++-- .../trpc/server/routers/viewer/workflows.tsx | 65 ++++++++- packages/ui/form/PhoneInput.tsx | 11 +- turbo.json | 1 + 15 files changed, 316 insertions(+), 43 deletions(-) create mode 100644 packages/features/ee/workflows/lib/reminders/verifyPhoneNumber.ts create mode 100644 packages/prisma/migrations/20221215120525_add_verified_numbers/migration.sql diff --git a/.env.example b/.env.example index 02d1fca941..294cc6168c 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 79f7edb80b..7956b1f424 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 4a09c31c4e..b9bdbeefe3 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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" } diff --git a/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx b/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx index de13871f15..b658e2a364 100644 --- a/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx +++ b/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx @@ -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); diff --git a/packages/features/ee/workflows/components/WorkflowStepContainer.tsx b/packages/features/ee/workflows/components/WorkflowStepContainer.tsx index aa8c2547bb..8561fef7f6 100644 --- a/packages/features/ee/workflows/components/WorkflowStepContainer.tsx +++ b/packages/features/ee/workflows/components/WorkflowStepContainer.tsx @@ -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(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 && ( <> - - 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 - /> +
+ + 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); + }} + /> + +
+ {form.formState.errors.steps && form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo && (

{form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo?.message || ""}

)} + {numberVerified ? ( +
+ {t("number_verified")} +
+ ) : ( + <> +
+ { + setVerificationCode(e.target.value); + }} + required + /> + +
+ + )} )} {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); } } diff --git a/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts b/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts index e104b41c88..8639f4a6db 100644 --- a/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts +++ b/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts @@ -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 || diff --git a/packages/features/ee/workflows/lib/reminders/smsProviders/twilioProvider.ts b/packages/features/ee/workflows/lib/reminders/smsProviders/twilioProvider.ts index 6119550974..35cf2616f5 100644 --- a/packages/features/ee/workflows/lib/reminders/smsProviders/twilioProvider.ts +++ b/packages/features/ee/workflows/lib/reminders/smsProviders/twilioProvider.ts @@ -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"; + } + } +}; diff --git a/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts b/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts index ffea491ba2..611d70782b 100644 --- a/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts +++ b/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts @@ -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 || diff --git a/packages/features/ee/workflows/lib/reminders/verifyPhoneNumber.ts b/packages/features/ee/workflows/lib/reminders/verifyPhoneNumber.ts new file mode 100644 index 0000000000..4f911cd59a --- /dev/null +++ b/packages/features/ee/workflows/lib/reminders/verifyPhoneNumber.ts @@ -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; +}; diff --git a/packages/features/ee/workflows/pages/workflow.tsx b/packages/features/ee/workflows/pages/workflow.tsx index 0ddb707368..67f1d9df7e 100644 --- a/packages/features/ee/workflows/pages/workflow.tsx +++ b/packages/features/ee/workflows/pages/workflow.tsx @@ -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(); } }}> { + 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; diff --git a/packages/ui/form/PhoneInput.tsx b/packages/ui/form/PhoneInput.tsx index b02656a3a2..e8e00f5867 100644 --- a/packages/ui/form/PhoneInput.tsx +++ b/packages/ui/form/PhoneInput.tsx @@ -9,15 +9,22 @@ export type PhoneInputProps = Props< required: boolean; }, FormValues ->; +> & { onChange?: (e: any) => void }; -function PhoneInput({ control, name, className, ...rest }: PhoneInputProps) { +function PhoneInput({ + control, + name, + className, + onChange, + ...rest +}: PhoneInputProps) { return (