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 (