diff --git a/apps/web/pages/api/trpc/kycVerification/[trpc].ts b/apps/web/pages/api/trpc/kycVerification/[trpc].ts new file mode 100644 index 0000000000..a77ef56160 --- /dev/null +++ b/apps/web/pages/api/trpc/kycVerification/[trpc].ts @@ -0,0 +1,4 @@ +import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler"; +import { kycVerificationRouter } from "@calcom/trpc/server/routers/viewer/kycVerification/_router"; + +export default createNextApiHandler(kycVerificationRouter); diff --git a/apps/web/pages/settings/admin/kycVerification/index.tsx b/apps/web/pages/settings/admin/kycVerification/index.tsx new file mode 100644 index 0000000000..0d4a2b7bb1 --- /dev/null +++ b/apps/web/pages/settings/admin/kycVerification/index.tsx @@ -0,0 +1,11 @@ +import PageWrapper from "@components/PageWrapper"; +import { getLayout } from "@components/auth/layouts/AdminLayout"; + +import KYCVerificationView from "./kycVerificationView"; + +const KYCVerificationPage = () => ; + +KYCVerificationPage.getLayout = getLayout; +KYCVerificationPage.PageWrapper = PageWrapper; + +export default KYCVerificationPage; diff --git a/apps/web/pages/settings/admin/kycVerification/kycVerificationView.tsx b/apps/web/pages/settings/admin/kycVerification/kycVerificationView.tsx new file mode 100644 index 0000000000..3a55ce2538 --- /dev/null +++ b/apps/web/pages/settings/admin/kycVerification/kycVerificationView.tsx @@ -0,0 +1,80 @@ +import { useForm } from "react-hook-form"; + +import { trpc } from "@calcom/trpc"; +import { Meta, Form, Button, TextField, showToast } from "@calcom/ui"; + +type FormValues = { + name?: string | null; +}; + +export default function KYCVerificationView() { + const teamForm = useForm(); + const userForm = useForm(); + + const mutation = trpc.viewer.kycVerification.verify.useMutation({ + onSuccess: async (data) => { + showToast(`Successfully verified ${data.isTeam ? "team" : "user"} ${data.name}`, "success"); + }, + onError: (error) => { + showToast(`Verification failed: ${error.message}`, "error"); + }, + }); + + return ( +
+ +
+
Verify Team
+ +
{ + mutation.mutate({ + name: values.name || "", + isTeam: true, + }); + }}> +
+ + +
+
+
+
+
Verify User
+
{ + mutation.mutate({ + name: values.name || "", + isTeam: false, + }); + }}> +
+ + +
+
+
+
+ ); +} diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index e24213a3a8..066a1ca9ba 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -1981,6 +1981,13 @@ "what_is_this_meeting_about": "What is this meeting about?", "requires_booker_email_verification": "Requires booker email verification", "description_requires_booker_email_verification": "To ensure booker's email verification before scheduling events", + "requires_confirmation_mandatory": "Text messages can only be sent to attendees when event type requires confirmation.", + "kyc_verification_information": "To ensure security, you have to verify your {{teamOrAccount}} before sending text messages to attendees. Please contact us at {{supportEmail}} and provide the following information:", + "kyc_verification_documents": "
  • Your {{teamOrUser}}
  • For businesses: Attach your Business Verification Documentation
  • For individuals: Attach a government-issued ID
", + "verify_team_or_account": "Verify {{teamOrAccount}}", + "verify_account": "Verify Account", + "kyc_verification": "KYC Verification", + "organizations": "Organizations", "org_admin_other_teams": "Other teams", "org_admin_other_teams_description": "Here you can see teams inside your organization but that you are not part of. You can add yourself to them if needed.", "no_other_teams_found": "No other teams found", @@ -1989,5 +1996,6 @@ "attendee_last_name_variable": "Attendee last name", "attendee_first_name_info": "The person booking's first name", "attendee_last_name_info": "The person booking's last name", + "verify_team_tooltip": "Verify your team to enable sending messages to attendees", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/packages/features/bookings/lib/handleCancelBooking.ts b/packages/features/bookings/lib/handleCancelBooking.ts index ed940c9c71..9ca50ec819 100644 --- a/packages/features/bookings/lib/handleCancelBooking.ts +++ b/packages/features/bookings/lib/handleCancelBooking.ts @@ -10,6 +10,7 @@ import { deleteMeeting, updateMeeting } from "@calcom/core/videoClient"; import dayjs from "@calcom/dayjs"; import { sendCancelledEmails, sendCancelledSeatEmails } from "@calcom/emails"; import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses"; +import { isEventTypeOwnerKYCVerified } from "@calcom/features/ee/workflows/lib/isEventTypeOwnerKYCVerified"; import { deleteScheduledEmailReminder } from "@calcom/features/ee/workflows/lib/reminders/emailReminderManager"; import { sendCancelledReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler"; import { deleteScheduledSMSReminder } from "@calcom/features/ee/workflows/lib/reminders/smsReminderManager"; @@ -65,7 +66,23 @@ async function getBookingToDelete(id: number | undefined, uid: string | undefine eventType: { select: { slug: true, - owner: true, + owner: { + select: { + id: true, + hideBranding: true, + metadata: true, + teams: { + select: { + accepted: true, + team: { + select: { + metadata: true, + }, + }, + }, + }, + }, + }, teamId: true, recurringEvent: true, title: true, @@ -275,6 +292,8 @@ async function handler(req: CustomRequest) { ); await Promise.all(promises); + const isKYCVerified = isEventTypeOwnerKYCVerified(bookingToDelete.eventType); + //Workflows - schedule reminders if (bookingToDelete.eventType?.workflows) { await sendCancelledReminders({ @@ -285,6 +304,7 @@ async function handler(req: CustomRequest) { ...{ eventType: { slug: bookingToDelete.eventType.slug } }, }, hideBranding: !!bookingToDelete.eventType.owner?.hideBranding, + isKYCVerified, }); } diff --git a/packages/features/bookings/lib/handleConfirmation.ts b/packages/features/bookings/lib/handleConfirmation.ts index 85302e5a6e..5e384a9c42 100644 --- a/packages/features/bookings/lib/handleConfirmation.ts +++ b/packages/features/bookings/lib/handleConfirmation.ts @@ -4,6 +4,7 @@ import { scheduleTrigger } from "@calcom/app-store/zapier/lib/nodeScheduler"; import type { EventManagerUser } from "@calcom/core/EventManager"; import EventManager from "@calcom/core/EventManager"; import { sendScheduledEmails } from "@calcom/emails"; +import { isEventTypeOwnerKYCVerified } from "@calcom/features/ee/workflows/lib/isEventTypeOwnerKYCVerified"; import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler"; import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks"; import type { EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload"; @@ -85,8 +86,18 @@ export async function handleConfirmation(args: { eventType: { bookingFields: Prisma.JsonValue | null; slug: string; + team: { + metadata: Prisma.JsonValue; + } | null; owner: { hideBranding?: boolean | null; + metadata: Prisma.JsonValue; + teams: { + accepted: boolean; + team: { + metadata: Prisma.JsonValue; + }; + }[]; } | null; workflows: (WorkflowsOnEventTypes & { workflow: Workflow & { @@ -123,9 +134,25 @@ export async function handleConfirmation(args: { select: { slug: true, bookingFields: true, + team: { + select: { + metadata: true, + }, + }, owner: { select: { hideBranding: true, + metadata: true, + teams: { + select: { + accepted: true, + team: { + select: { + metadata: true, + }, + }, + }, + }, }, }, workflows: { @@ -174,9 +201,25 @@ export async function handleConfirmation(args: { select: { slug: true, bookingFields: true, + team: { + select: { + metadata: true, + }, + }, owner: { select: { hideBranding: true, + metadata: true, + teams: { + select: { + accepted: true, + team: { + select: { + metadata: true, + }, + }, + }, + }, }, }, workflows: { @@ -206,6 +249,8 @@ export async function handleConfirmation(args: { updatedBookings.push(updatedBooking); } + const isKYCVerified = isEventTypeOwnerKYCVerified(updatedBookings[0].eventType); + //Workflows - set reminders for confirmed events try { for (let index = 0; index < updatedBookings.length; index++) { @@ -229,6 +274,8 @@ export async function handleConfirmation(args: { }, isFirstRecurringEvent: isFirstBooking, hideBranding: !!updatedBookings[index].eventType?.owner?.hideBranding, + eventTypeRequiresConfirmation: true, + isKYCVerified, }); } } diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 6fa68bfee5..c20f990797 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -39,6 +39,7 @@ import { allowDisablingAttendeeConfirmationEmails, allowDisablingHostConfirmationEmails, } from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails"; +import { isEventTypeOwnerKYCVerified } from "@calcom/features/ee/workflows/lib/isEventTypeOwnerKYCVerified"; import { cancelWorkflowReminders, scheduleWorkflowReminders, @@ -247,6 +248,7 @@ const getEventTypesFromDB = async (eventTypeId: number) => { select: { id: true, name: true, + metadata: true, }, }, bookingFields: true, @@ -277,6 +279,17 @@ const getEventTypesFromDB = async (eventTypeId: number) => { owner: { select: { hideBranding: true, + metadata: true, + teams: { + select: { + accepted: true, + team: { + select: { + metadata: true, + }, + }, + }, + }, }, }, workflows: { @@ -1119,6 +1132,8 @@ async function handler( const subscribersMeetingEnded = await getWebhooks(subscriberOptionsMeetingEnded); + const isKYCVerified = isEventTypeOwnerKYCVerified(eventType); + const handleSeats = async () => { let resultBooking: | (Partial & { @@ -1679,11 +1694,12 @@ async function handler( workflows: eventType.workflows, smsReminderNumber: smsReminderNumber || null, calendarEvent: { ...evt, ...{ metadata, eventType: { slug: eventType.slug } } }, - requiresConfirmation: evt.requiresConfirmation || false, + isNotConfirmed: evt.requiresConfirmation || false, isRescheduleEvent: !!rescheduleUid, isFirstRecurringEvent: true, emailAttendeeSendToOverride: bookerEmail, seatReferenceUid: evt.attendeeSeatId, + isKYCVerified, }); } catch (error) { log.error("Error while scheduling workflow reminders", error); @@ -2322,11 +2338,12 @@ async function handler( ...evt, ...{ metadata: metadataFromEvent, eventType: { slug: eventType.slug } }, }, - requiresConfirmation: evt.requiresConfirmation || false, + isNotConfirmed: evt.requiresConfirmation || false, isRescheduleEvent: !!rescheduleUid, isFirstRecurringEvent: true, hideBranding: !!eventType.owner?.hideBranding, seatReferenceUid: evt.attendeeSeatId, + isKYCVerified, }); } catch (error) { log.error("Error while scheduling workflow reminders", error); diff --git a/packages/features/ee/workflows/api/scheduleSMSReminders.ts b/packages/features/ee/workflows/api/scheduleSMSReminders.ts index 4d831dfb2f..fe88358c64 100644 --- a/packages/features/ee/workflows/api/scheduleSMSReminders.ts +++ b/packages/features/ee/workflows/api/scheduleSMSReminders.ts @@ -134,12 +134,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { ); } - if ( - message?.length && - message?.length > 0 && - sendTo && - reminder.workflowStep.action !== WorkflowActions.SMS_ATTENDEE - ) { + if (message?.length && message?.length > 0 && sendTo) { const scheduledSMS = await twilio.scheduleSMS(sendTo, message, reminder.scheduledDate, senderID); await prisma.workflowReminder.update({ diff --git a/packages/features/ee/workflows/api/scheduleWhatsappReminders.ts b/packages/features/ee/workflows/api/scheduleWhatsappReminders.ts index ff88ace841..8b05d276cc 100644 --- a/packages/features/ee/workflows/api/scheduleWhatsappReminders.ts +++ b/packages/features/ee/workflows/api/scheduleWhatsappReminders.ts @@ -90,12 +90,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { userName ); - if ( - message?.length && - message?.length > 0 && - sendTo && - reminder.workflowStep.action !== WorkflowActions.WHATSAPP_ATTENDEE - ) { + if (message?.length && message?.length > 0 && sendTo) { const scheduledSMS = await twilio.scheduleSMS(sendTo, message, reminder.scheduledDate, "", true); await prisma.workflowReminder.update({ diff --git a/packages/features/ee/workflows/components/AddActionDialog.tsx b/packages/features/ee/workflows/components/AddActionDialog.tsx index 4562b6fc29..2ff2bbd087 100644 --- a/packages/features/ee/workflows/components/AddActionDialog.tsx +++ b/packages/features/ee/workflows/components/AddActionDialog.tsx @@ -173,7 +173,8 @@ export const AddActionDialog = (props: IAddActionDialog) => { label: string; value: WorkflowActions; needsUpgrade: boolean; - }) => option.needsUpgrade} + needsVerification: boolean; + }) => option.needsUpgrade || option.needsVerification} /> ); }} diff --git a/packages/features/ee/workflows/components/EventWorkflowsTab.tsx b/packages/features/ee/workflows/components/EventWorkflowsTab.tsx index 324aa07ab8..8575571bf7 100644 --- a/packages/features/ee/workflows/components/EventWorkflowsTab.tsx +++ b/packages/features/ee/workflows/components/EventWorkflowsTab.tsx @@ -3,6 +3,7 @@ import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager"; +import { isTextMessageToAttendeeAction } from "@calcom/features/ee/workflows/lib/actionHelperFunctions"; import classNames from "@calcom/lib/classNames"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { HttpError } from "@calcom/lib/http-error"; @@ -10,7 +11,7 @@ import { WorkflowActions } from "@calcom/prisma/enums"; import type { RouterOutputs } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react"; import { Button, EmptyScreen, showToast, Switch, Tooltip, Alert } from "@calcom/ui"; -import { ExternalLink, Zap, Lock } from "@calcom/ui/components/icon"; +import { ExternalLink, Zap, Lock, Info } from "@calcom/ui/components/icon"; import LicenseRequired from "../../common/components/LicenseRequired"; import { getActionIcon } from "../lib/getActionIcon"; @@ -22,6 +23,7 @@ type ItemProps = { eventType: { id: number; title: string; + requiresConfirmation: boolean; }; isChildrenManagedEventType: boolean; }; @@ -101,71 +103,90 @@ const WorkflowListItem = (props: ItemProps) => { } }); + const needsRequiresConfirmationWarning = + !eventType.requiresConfirmation && + workflow.steps.find((step) => { + return isTextMessageToAttendeeAction(step.action); + }); + return ( -
-
- {getActionIcon( - workflow.steps, - isActive ? "h-6 w-6 stroke-[1.5px] text-default" : "h-6 w-6 stroke-[1.5px] text-muted" - )} -
-
-
- {workflow.name - ? workflow.name - : "Untitled (" + - `${t(`${workflow.steps[0].action.toLowerCase()}_action`)}`.charAt(0).toUpperCase() + - `${t(`${workflow.steps[0].action.toLowerCase()}_action`)}`.slice(1) + - ")"} -
-
- {t("to")}: - {Array.from(sendTo).map((sendToPerson, index) => { - return {`${index ? ", " : ""}${sendToPerson}`}; - })} -
-
- {!workflow.readOnly && ( -
- - - -
- )} - -
- {workflow.readOnly && props.isChildrenManagedEventType && ( - +
+
+
+ {getActionIcon( + workflow.steps, + isActive ? "h-6 w-6 stroke-[1.5px] text-default" : "h-6 w-6 stroke-[1.5px] text-muted" )} - { - activateEventTypeMutation.mutate({ workflowId: workflow.id, eventTypeId: eventType.id }); - }} - />
- +
+
+ {workflow.name + ? workflow.name + : "Untitled (" + + `${t(`${workflow.steps[0].action.toLowerCase()}_action`)}`.charAt(0).toUpperCase() + + `${t(`${workflow.steps[0].action.toLowerCase()}_action`)}`.slice(1) + + ")"} +
+ <> +
+ {t("to")}: + {Array.from(sendTo).map((sendToPerson, index) => { + return {`${index ? ", " : ""}${sendToPerson}`}; + })} +
+ +
+ {!workflow.readOnly && ( +
+ + + +
+ )} + +
+ {workflow.readOnly && props.isChildrenManagedEventType && ( + + )} + { + activateEventTypeMutation.mutate({ workflowId: workflow.id, eventTypeId: eventType.id }); + }} + /> +
+
+
+ + {needsRequiresConfirmationWarning ? ( +
+ +

{t("requires_confirmation_mandatory")}

+
+ ) : ( + <> + )}
); }; diff --git a/packages/features/ee/workflows/components/KYCVerificationDialog.tsx b/packages/features/ee/workflows/components/KYCVerificationDialog.tsx new file mode 100644 index 0000000000..71d0225416 --- /dev/null +++ b/packages/features/ee/workflows/components/KYCVerificationDialog.tsx @@ -0,0 +1,56 @@ +import { Trans } from "next-i18next"; +import type { Dispatch, SetStateAction } from "react"; + +import { SUPPORT_MAIL_ADDRESS } from "@calcom/lib/constants"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Dialog, DialogContent, DialogClose, DialogFooter } from "@calcom/ui"; + +export const KYCVerificationDialog = (props: { + isOpenDialog: boolean; + setIsOpenDialog: Dispatch>; + isPartOfTeam: boolean; +}) => { + const { isOpenDialog, setIsOpenDialog, isPartOfTeam } = props; + const { t } = useLocale(); + + const isTeamString = isPartOfTeam ? "team" : ""; + + return ( + + +
+
+ + ), + }} + values={{ + supportEmail: + SUPPORT_MAIL_ADDRESS === "help@cal.com" ? "support@cal.com" : SUPPORT_MAIL_ADDRESS, + teamOrAccount: isTeamString || "account", + }} + /> +
+ , ul:
    }} + values={{ teamOrUser: isPartOfTeam ? "team URL" : "user name" }} + /> +
+ + + +
+
+ ); +}; diff --git a/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx b/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx index 2a951fdb5f..57781441e7 100644 --- a/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx +++ b/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx @@ -5,6 +5,7 @@ import type { UseFormReturn } from "react-hook-form"; import { Controller } from "react-hook-form"; import { SENDER_ID, SENDER_NAME } from "@calcom/lib/constants"; +import { useHasTeamPlan } from "@calcom/lib/hooks/useHasPaidPlan"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { WorkflowTemplates } from "@calcom/prisma/enums"; import type { WorkflowActions } from "@calcom/prisma/enums"; @@ -18,6 +19,7 @@ import { isSMSAction, isWhatsappAction } from "../lib/actionHelperFunctions"; import type { FormValues } from "../pages/workflow"; import { AddActionDialog } from "./AddActionDialog"; import { DeleteDialog } from "./DeleteDialog"; +import { KYCVerificationDialog } from "./KYCVerificationDialog"; import WorkflowStepContainer from "./WorkflowStepContainer"; type User = RouterOutputs["viewer"]["me"]; @@ -39,11 +41,15 @@ export default function WorkflowDetailsPage(props: Props) { const router = useRouter(); const [isAddActionDialogOpen, setIsAddActionDialogOpen] = useState(false); + const [isKYCVerificationDialogOpen, setKYCVerificationDialogOpen] = useState(false); + const [reload, setReload] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const { data, isLoading } = trpc.viewer.eventTypes.getByViewer.useQuery(); + const isPartOfTeam = useHasTeamPlan(); + const eventTypeOptions = useMemo( () => data?.eventTypeGroups.reduce((options, group) => { @@ -114,7 +120,7 @@ export default function WorkflowDetailsPage(props: Props) { return ( <> -
+
)} @@ -184,6 +191,7 @@ export default function WorkflowDetailsPage(props: Props) { setReload={setReload} teamId={teamId} readOnly={props.readOnly} + setKYCVerificationDialogOpen={setKYCVerificationDialogOpen} /> ); })} @@ -212,6 +220,11 @@ export default function WorkflowDetailsPage(props: Props) { setIsOpenDialog={setIsAddActionDialogOpen} addAction={addAction} /> + >; teamId?: number; readOnly: boolean; + setKYCVerificationDialogOpen: Dispatch>; }; export default function WorkflowStepContainer(props: WorkflowStepProps) { @@ -118,6 +120,11 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { const [showTimeSectionAfter, setShowTimeSectionAfter] = useState( form.getValues("trigger") === WorkflowTriggerEvents.AFTER_EVENT ); + + const [isRequiresConfirmationNeeded, setIsRequiresConfirmationNeeded] = useState( + isTextMessageToAttendeeAction(step?.action) + ); + const { data: actionOptions } = trpc.viewer.workflows.getWorkflowActionOptions.useQuery(); const triggerOptions = getWorkflowTriggerOptions(t); const templateOptions = getWorkflowTemplateOptions(t, step?.action); @@ -331,6 +338,8 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { label: actionString.charAt(0).toUpperCase() + actionString.slice(1), value: step.action, needsUpgrade: false, + needsVerification: false, + verificationAction: () => props.setKYCVerificationDialogOpen(true), }; const selectedTemplate = { label: t(`${step.template.toLowerCase()}`), value: step.template }; @@ -448,6 +457,12 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { setIsEmailSubjectNeeded(true); } + if (isTextMessageToAttendeeAction(val.value)) { + setIsRequiresConfirmationNeeded(true); + } else { + setIsRequiresConfirmationNeeded(false); + } + if ( form.getValues(`steps.${step.stepNumber - 1}.template`) === WorkflowTemplates.REMINDER @@ -513,16 +528,28 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { } }} defaultValue={selectedAction} - options={actionOptions} + options={actionOptions?.map((option) => ({ + ...option, + verificationAction: () => props.setKYCVerificationDialogOpen(true), + }))} isOptionDisabled={(option: { label: string; value: WorkflowActions; needsUpgrade: boolean; - }) => option.needsUpgrade} + needsVerification: boolean; + }) => option.needsUpgrade || option.needsVerification} /> ); }} /> + {isRequiresConfirmationNeeded ? ( +
+ +

{t("requires_confirmation_mandatory")}

+
+ ) : ( + <> + )}
{isPhoneNumberNeeded && (
diff --git a/packages/features/ee/workflows/lib/actionHelperFunctions.ts b/packages/features/ee/workflows/lib/actionHelperFunctions.ts index fc24562993..401a6b5875 100644 --- a/packages/features/ee/workflows/lib/actionHelperFunctions.ts +++ b/packages/features/ee/workflows/lib/actionHelperFunctions.ts @@ -38,6 +38,10 @@ export function isAttendeeAction(action: WorkflowActions) { ); } +export function isTextMessageToAttendeeAction(action?: WorkflowActions) { + return action === WorkflowActions.SMS_ATTENDEE || action === WorkflowActions.WHATSAPP_ATTENDEE; +} + export function getWhatsappTemplateForTrigger(trigger: WorkflowTriggerEvents): WorkflowTemplates { switch (trigger) { case "NEW_EVENT": diff --git a/packages/features/ee/workflows/lib/getOptions.ts b/packages/features/ee/workflows/lib/getOptions.ts index 7dd3c0ee9f..ccaa39a288 100644 --- a/packages/features/ee/workflows/lib/getOptions.ts +++ b/packages/features/ee/workflows/lib/getOptions.ts @@ -2,7 +2,11 @@ import type { TFunction } from "next-i18next"; import { WorkflowActions } from "@calcom/prisma/enums"; -import { isSMSOrWhatsappAction, isWhatsappAction } from "./actionHelperFunctions"; +import { + isTextMessageToAttendeeAction, + isSMSOrWhatsappAction, + isWhatsappAction, +} from "./actionHelperFunctions"; import { TIME_UNIT, WHATSAPP_WORKFLOW_TEMPLATES, @@ -11,7 +15,7 @@ import { WORKFLOW_TRIGGER_EVENTS, } from "./constants"; -export function getWorkflowActionOptions(t: TFunction, isTeamsPlan?: boolean) { +export function getWorkflowActionOptions(t: TFunction, isTeamsPlan?: boolean, isKYCVerified?: boolean) { return WORKFLOW_ACTIONS.filter((action) => action !== WorkflowActions.EMAIL_ADDRESS) //removing EMAIL_ADDRESS for now due to abuse episode .map((action) => { const actionString = t(`${action.toLowerCase()}_action`); @@ -20,6 +24,7 @@ export function getWorkflowActionOptions(t: TFunction, isTeamsPlan?: boolean) { label: actionString.charAt(0).toUpperCase() + actionString.slice(1), value: action, needsUpgrade: isSMSOrWhatsappAction(action) && !isTeamsPlan, + needsVerification: isTextMessageToAttendeeAction(action) && !isKYCVerified, }; }); } diff --git a/packages/features/ee/workflows/lib/isEventTypeOwnerKYCVerified.ts b/packages/features/ee/workflows/lib/isEventTypeOwnerKYCVerified.ts new file mode 100644 index 0000000000..436494f71f --- /dev/null +++ b/packages/features/ee/workflows/lib/isEventTypeOwnerKYCVerified.ts @@ -0,0 +1,45 @@ +import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata"; +import type { Prisma } from "@calcom/prisma/client"; + +type EventTypeOwnerType = { + team?: { + metadata: Prisma.JsonValue; + } | null; + owner?: { + metadata: Prisma.JsonValue; + teams: { + accepted: boolean; + team: { + metadata: Prisma.JsonValue; + }; + }[]; + } | null; +}; + +export function isEventTypeOwnerKYCVerified(eventType?: EventTypeOwnerType | null) { + if (!eventType) return false; + + if (eventType.team) { + const isKYCVerified = + eventType.team && + hasKeyInMetadata(eventType.team, "kycVerified") && + !!eventType.team.metadata.kycVerified; + return isKYCVerified; + } + + if (eventType.owner) { + const isKYCVerified = + eventType.owner && + hasKeyInMetadata(eventType.owner, "kycVerified") && + !!eventType.owner.metadata.kycVerified; + if (isKYCVerified) return isKYCVerified; + + const isPartOfVerifiedTeam = eventType.owner.teams.find( + (team) => + team.accepted && hasKeyInMetadata(team.team, "kycVerified") && !!team.team.metadata.kycVerified + ); + return !!isPartOfVerifiedTeam; + } + + return false; +} diff --git a/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts b/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts index 746ea7423f..d3dffc9bdf 100644 --- a/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts +++ b/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts @@ -1,6 +1,9 @@ import type { Workflow, WorkflowsOnEventTypes, WorkflowStep } from "@prisma/client"; -import { isWhatsappAction } from "@calcom/features/ee/workflows/lib/actionHelperFunctions"; +import { + isTextMessageToAttendeeAction, + isWhatsappAction, +} from "@calcom/features/ee/workflows/lib/actionHelperFunctions"; import { SENDER_ID, SENDER_NAME } from "@calcom/lib/constants"; import { WorkflowActions, WorkflowMethods, WorkflowTriggerEvents } from "@calcom/prisma/enums"; import type { CalendarEvent } from "@calcom/types/Calendar"; @@ -20,6 +23,8 @@ type ProcessWorkflowStepParams = { emailAttendeeSendToOverride?: string; hideBranding?: boolean; seatReferenceUid?: string; + isKYCVerified: boolean; + eventTypeRequiresConfirmation?: boolean; }; export interface ScheduleWorkflowRemindersArgs extends ProcessWorkflowStepParams { @@ -28,7 +33,7 @@ export interface ScheduleWorkflowRemindersArgs extends ProcessWorkflowStepParams steps: WorkflowStep[]; }; })[]; - requiresConfirmation?: boolean; + isNotConfirmed?: boolean; isRescheduleEvent?: boolean; isFirstRecurringEvent?: boolean; } @@ -42,8 +47,13 @@ const processWorkflowStep = async ( emailAttendeeSendToOverride, hideBranding, seatReferenceUid, + eventTypeRequiresConfirmation, + isKYCVerified, }: ProcessWorkflowStepParams ) => { + if (isTextMessageToAttendeeAction(step.action) && (!isKYCVerified || !eventTypeRequiresConfirmation)) + return; + if (step.action === WorkflowActions.SMS_ATTENDEE || step.action === WorkflowActions.SMS_NUMBER) { const sendTo = step.action === WorkflowActions.SMS_ATTENDEE ? smsReminderNumber : step.sendTo; await scheduleSMSReminder( @@ -125,15 +135,16 @@ export const scheduleWorkflowReminders = async (args: ScheduleWorkflowRemindersA workflows, smsReminderNumber, calendarEvent: evt, - requiresConfirmation = false, + isNotConfirmed = false, isRescheduleEvent = false, isFirstRecurringEvent = false, emailAttendeeSendToOverride = "", hideBranding, seatReferenceUid, + eventTypeRequiresConfirmation = false, + isKYCVerified, } = args; - - if (requiresConfirmation || !workflows.length) return; + if (isNotConfirmed || !workflows.length) return; for (const workflowReference of workflows) { if (workflowReference.workflow.steps.length === 0) continue; @@ -164,6 +175,8 @@ export const scheduleWorkflowReminders = async (args: ScheduleWorkflowRemindersA smsReminderNumber, hideBranding, seatReferenceUid, + eventTypeRequiresConfirmation, + isKYCVerified, }); } } @@ -194,10 +207,13 @@ export interface SendCancelledRemindersArgs { smsReminderNumber: string | null; evt: ExtendedCalendarEvent; hideBranding?: boolean; + isKYCVerified: boolean; + eventTypeRequiresConfirmation?: boolean; } export const sendCancelledReminders = async (args: SendCancelledRemindersArgs) => { - const { workflows, smsReminderNumber, evt, hideBranding } = args; + const { workflows, smsReminderNumber, evt, hideBranding, isKYCVerified, eventTypeRequiresConfirmation } = + args; if (!workflows.length) return; for (const workflowRef of workflows) { @@ -210,6 +226,8 @@ export const sendCancelledReminders = async (args: SendCancelledRemindersArgs) = smsReminderNumber, hideBranding, calendarEvent: evt, + eventTypeRequiresConfirmation, + isKYCVerified, }); } } diff --git a/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts b/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts index fbfb2c0356..faf3ff58d5 100644 --- a/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts +++ b/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts @@ -162,7 +162,7 @@ export const scheduleSMSReminder = async ( // Allows debugging generated email content without waiting for sendgrid to send emails log.debug(`Sending sms for trigger ${triggerEvent}`, message); - if (message.length > 0 && reminderPhone && isNumberVerified && action !== WorkflowActions.SMS_ATTENDEE) { + 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/whatsappReminderManager.ts b/packages/features/ee/workflows/lib/reminders/whatsappReminderManager.ts index acb8f9f61f..51f28be26a 100644 --- a/packages/features/ee/workflows/lib/reminders/whatsappReminderManager.ts +++ b/packages/features/ee/workflows/lib/reminders/whatsappReminderManager.ts @@ -141,12 +141,7 @@ export const scheduleWhatsappReminder = async ( // Allows debugging generated whatsapp content without waiting for twilio to send whatsapp messages log.debug(`Sending Whatsapp for trigger ${triggerEvent}`, message); - if ( - message.length > 0 && - reminderPhone && - isNumberVerified && - action !== WorkflowActions.WHATSAPP_ATTENDEE - ) { + if (message.length > 0 && reminderPhone && isNumberVerified) { //send WHATSAPP when event is booked/cancelled/rescheduled if ( triggerEvent === WorkflowTriggerEvents.NEW_EVENT || diff --git a/packages/features/settings/layouts/SettingsLayout.tsx b/packages/features/settings/layouts/SettingsLayout.tsx index 57857ee354..325b26d73b 100644 --- a/packages/features/settings/layouts/SettingsLayout.tsx +++ b/packages/features/settings/layouts/SettingsLayout.tsx @@ -118,6 +118,7 @@ const tabs: VerticalTabItemProps[] = [ { name: "apps", href: "/settings/admin/apps/calendar" }, { name: "users", href: "/settings/admin/users" }, { name: "organizations", href: "/settings/admin/organizations" }, + { name: "kyc_verification", href: "/settings/admin/kycVerification" }, ], }, ]; diff --git a/packages/trpc/react/trpc.ts b/packages/trpc/react/trpc.ts index 044bbb44d8..5382c9c3fd 100644 --- a/packages/trpc/react/trpc.ts +++ b/packages/trpc/react/trpc.ts @@ -40,6 +40,7 @@ const ENDPOINTS = [ "workflows", "appsRouter", "googleWorkspace", + "kycVerification", ] as const; export type Endpoint = (typeof ENDPOINTS)[number]; diff --git a/packages/trpc/server/routers/viewer/_router.tsx b/packages/trpc/server/routers/viewer/_router.tsx index 1152175223..332d85d62c 100644 --- a/packages/trpc/server/routers/viewer/_router.tsx +++ b/packages/trpc/server/routers/viewer/_router.tsx @@ -15,6 +15,7 @@ import { bookingsRouter } from "./bookings/_router"; import { deploymentSetupRouter } from "./deploymentSetup/_router"; import { eventTypesRouter } from "./eventTypes/_router"; import { googleWorkspaceRouter } from "./googleWorkspace/_router"; +import { kycVerificationRouter } from "./kycVerification/_router"; import { viewerOrganizationsRouter } from "./organizations/_router"; import { paymentsRouter } from "./payments/_router"; import { slotsRouter } from "./slots/_router"; @@ -50,5 +51,6 @@ export const viewerRouter = mergeRouters( users: userAdminRouter, googleWorkspace: googleWorkspaceRouter, admin: adminRouter, + kycVerification: kycVerificationRouter, }) ); diff --git a/packages/trpc/server/routers/viewer/kycVerification/_router.tsx b/packages/trpc/server/routers/viewer/kycVerification/_router.tsx new file mode 100644 index 0000000000..f867ae5cc5 --- /dev/null +++ b/packages/trpc/server/routers/viewer/kycVerification/_router.tsx @@ -0,0 +1,42 @@ +import { authedAdminProcedure } from "../../../procedures/authedProcedure"; +import { router } from "../../../trpc"; +import { ZVerifyInputSchema } from "./verify.schema"; + +type KYCVerificationRouterHandlerCache = { + isVerified?: typeof import("./isVerified.handler").isVerifiedHandler; + verify?: typeof import("./verify.handler").verifyHandler; +}; + +const UNSTABLE_HANDLER_CACHE: KYCVerificationRouterHandlerCache = {}; + +export const kycVerificationRouter = router({ + isVerified: authedAdminProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.isVerified) { + UNSTABLE_HANDLER_CACHE.isVerified = await import("./isVerified.handler").then( + (mod) => mod.isVerifiedHandler + ); + } + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.isVerified) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.isVerified({ + ctx, + }); + }), + verify: authedAdminProcedure.input(ZVerifyInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.verify) { + UNSTABLE_HANDLER_CACHE.verify = await import("./verify.handler").then((mod) => mod.verifyHandler); + } + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.verify) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.verify({ + ctx, + input, + }); + }), +}); diff --git a/packages/trpc/server/routers/viewer/kycVerification/isVerified.handler.ts b/packages/trpc/server/routers/viewer/kycVerification/isVerified.handler.ts new file mode 100644 index 0000000000..8cd87833ca --- /dev/null +++ b/packages/trpc/server/routers/viewer/kycVerification/isVerified.handler.ts @@ -0,0 +1,44 @@ +import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata"; +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +type IsKYCVerifiedOptions = { + ctx: { + user: NonNullable; + }; +}; + +export const isVerifiedHandler = async ({ ctx }: IsKYCVerifiedOptions) => { + const user = ctx.user; + + const memberships = await prisma.membership.findMany({ + where: { + accepted: true, + userId: user.id, + team: { + slug: { + not: null, + }, + }, + }, + select: { + team: { + select: { + metadata: true, + }, + }, + }, + }); + + let isKYCVerified = user && hasKeyInMetadata(user, "kycVerified") ? !!user.metadata.kycVerified : false; + + if (!isKYCVerified) { + //check if user is part of a team that is KYC verified + isKYCVerified = !!memberships.find( + (membership) => + hasKeyInMetadata(membership.team, "kycVerified") && !!membership.team.metadata.kycVerified + ); + } + + return { isKYCVerified }; +}; diff --git a/packages/trpc/server/routers/viewer/kycVerification/isVerified.schema.ts b/packages/trpc/server/routers/viewer/kycVerification/isVerified.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/viewer/kycVerification/isVerified.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/viewer/kycVerification/verify.handler.ts b/packages/trpc/server/routers/viewer/kycVerification/verify.handler.ts new file mode 100644 index 0000000000..65fdbe9f3f --- /dev/null +++ b/packages/trpc/server/routers/viewer/kycVerification/verify.handler.ts @@ -0,0 +1,76 @@ +import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata"; +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import type { TVerifyInputSchema } from "./verify.schema"; + +type VerifyOptions = { + ctx: { + user: NonNullable; + }; + input: TVerifyInputSchema; +}; + +export const verifyHandler = async ({ ctx, input }: VerifyOptions) => { + const { name, isTeam } = input; + if (isTeam) { + const team = await prisma.team.findFirst({ + where: { + slug: name, + }, + }); + if (!team) { + throw new Error("Team not found"); + } + + if (hasKeyInMetadata(team, "kycVerified") && !!team.metadata.kycVerified) { + throw new Error("Team already verified"); + } + + const metadata = typeof team.metadata === "object" ? team.metadata : {}; + const updatedMetadata = { ...metadata, kycVerified: true }; + + const updatedTeam = await prisma.team.update({ + where: { + id: team.id, + }, + data: { + metadata: updatedMetadata, + }, + }); + return { + name: updatedTeam.slug, + isTeam: true, + }; + } + + const user = await prisma.user.findFirst({ + where: { + username: name, + }, + }); + + if (!user) { + throw new Error("User not found"); + } + + if (hasKeyInMetadata(user, "kycVerified") && !!user.metadata.kycVerified) { + throw new Error("User already verified"); + } + + const metadata = typeof user.metadata === "object" ? user.metadata : {}; + const updatedMetadata = { ...metadata, kycVerified: true }; + + const updatedUser = await prisma.user.update({ + where: { + id: user.id, + }, + data: { + metadata: updatedMetadata, + }, + }); + return { + name: updatedUser.username, + isTeam: false, + }; +}; diff --git a/packages/trpc/server/routers/viewer/kycVerification/verify.schema.ts b/packages/trpc/server/routers/viewer/kycVerification/verify.schema.ts new file mode 100644 index 0000000000..dde3d65234 --- /dev/null +++ b/packages/trpc/server/routers/viewer/kycVerification/verify.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ZVerifyInputSchema = z.object({ + name: z.string(), + isTeam: z.boolean(), +}); + +export type TVerifyInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/workflows/getWorkflowActionOptions.handler.ts b/packages/trpc/server/routers/viewer/workflows/getWorkflowActionOptions.handler.ts index a4159afcf3..0c113999ac 100644 --- a/packages/trpc/server/routers/viewer/workflows/getWorkflowActionOptions.handler.ts +++ b/packages/trpc/server/routers/viewer/workflows/getWorkflowActionOptions.handler.ts @@ -4,6 +4,7 @@ import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata"; import { getTranslation } from "@calcom/lib/server/i18n"; import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; +import { isVerifiedHandler } from "../kycVerification/isVerified.handler"; import { hasTeamPlanHandler } from "../teams/hasTeamPlan.handler"; type GetWorkflowActionOptionsOptions = { @@ -25,6 +26,13 @@ export const getWorkflowActionOptionsHandler = async ({ ctx }: GetWorkflowAction const { hasTeamPlan } = await hasTeamPlanHandler({ ctx }); isTeamsPlan = !!hasTeamPlan; } + + const { isKYCVerified } = await isVerifiedHandler({ ctx }); + const t = await getTranslation(ctx.user.locale, "common"); - return getWorkflowActionOptions(t, IS_SELF_HOSTED || isCurrentUsernamePremium || isTeamsPlan); + return getWorkflowActionOptions( + t, + IS_SELF_HOSTED || isCurrentUsernamePremium || isTeamsPlan, + isKYCVerified + ); }; diff --git a/packages/trpc/server/routers/viewer/workflows/update.handler.ts b/packages/trpc/server/routers/viewer/workflows/update.handler.ts index af4f0bb5d2..2ee1a70d96 100644 --- a/packages/trpc/server/routers/viewer/workflows/update.handler.ts +++ b/packages/trpc/server/routers/viewer/workflows/update.handler.ts @@ -1,6 +1,9 @@ import type { Prisma } from "@prisma/client"; -import { isSMSOrWhatsappAction } from "@calcom/features/ee/workflows/lib/actionHelperFunctions"; +import { + isSMSOrWhatsappAction, + isTextMessageToAttendeeAction, +} from "@calcom/features/ee/workflows/lib/actionHelperFunctions"; import { deleteScheduledEmailReminder, scheduleEmailReminder, @@ -22,6 +25,7 @@ import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; import { TRPCError } from "@trpc/server"; +import { isVerifiedHandler } from "../kycVerification/isVerified.handler"; import { hasTeamPlanHandler } from "../teams/hasTeamPlan.handler"; import type { TUpdateInputSchema } from "./update.schema"; import { @@ -80,6 +84,9 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { } const hasPaidPlan = IS_SELF_HOSTED || isCurrentUsernamePremium || isTeamsPlan; + const kycVerified = await isVerifiedHandler({ ctx }); + const isKYCVerified = kycVerified.isKYCVerified; + const activeOnEventTypes = await ctx.prisma.eventType.findMany({ where: { id: { @@ -421,6 +428,9 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { if (!hasPaidPlan && !isSMSOrWhatsappAction(oldStep.action) && isSMSOrWhatsappAction(newStep.action)) { throw new TRPCError({ code: "UNAUTHORIZED" }); } + if (!isKYCVerified && isTextMessageToAttendeeAction(newStep.action)) { + throw new TRPCError({ code: "UNAUTHORIZED", message: "Account needs to be verified" }); + } const requiresSender = newStep.action === WorkflowActions.SMS_NUMBER || newStep.action === WorkflowActions.WHATSAPP_NUMBER; await ctx.prisma.workflowStep.update({ @@ -593,6 +603,9 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { if (isSMSOrWhatsappAction(s.action) && !hasPaidPlan) { throw new TRPCError({ code: "UNAUTHORIZED" }); } + if (isTextMessageToAttendeeAction(s.action)) { + throw new TRPCError({ code: "UNAUTHORIZED", message: "Account needs to be verified" }); + } const { id: _stepId, ...stepToAdd } = s; return stepToAdd; } diff --git a/packages/ui/components/badge/KYCVerificationBadge.tsx b/packages/ui/components/badge/KYCVerificationBadge.tsx new file mode 100644 index 0000000000..fbbcdf8bd2 --- /dev/null +++ b/packages/ui/components/badge/KYCVerificationBadge.tsx @@ -0,0 +1,20 @@ +import { useLocale } from "@calcom/lib/hooks/useLocale"; + +import { Tooltip } from "../tooltip"; +import { Badge } from "./Badge"; + +export const KYCVerificationBadge = function KYCVerificationBadge(props: { verifyTeamAction?: () => void }) { + const { verifyTeamAction } = props; + const { t } = useLocale(); + if (!verifyTeamAction) return <>; + + return ( + <> + + verifyTeamAction()}> + {t("verify_account")} + + + + ); +}; diff --git a/packages/ui/components/badge/index.ts b/packages/ui/components/badge/index.ts index bd0decbf3d..4c6fc4ac3a 100644 --- a/packages/ui/components/badge/index.ts +++ b/packages/ui/components/badge/index.ts @@ -1,4 +1,5 @@ export { Badge } from "./Badge"; export { UpgradeTeamsBadge } from "./UpgradeTeamsBadge"; +export { KYCVerificationBadge } from "./KYCVerificationBadge"; export type { BadgeProps } from "./Badge"; diff --git a/packages/ui/components/form/select/components.tsx b/packages/ui/components/form/select/components.tsx index f555f36b69..3f91edc91e 100644 --- a/packages/ui/components/form/select/components.tsx +++ b/packages/ui/components/form/select/components.tsx @@ -3,7 +3,7 @@ import { components as reactSelectComponents } from "react-select"; import { classNames } from "@calcom/lib"; -import { UpgradeTeamsBadge } from "../../badge"; +import { UpgradeTeamsBadge, KYCVerificationBadge } from "../../badge"; import { Check } from "../../icon"; export const InputComponent = < @@ -30,6 +30,8 @@ type ExtendedOption = { value: string | number; label: string; needsUpgrade?: boolean; + needsVerification?: boolean; + verificationAction?: () => void; }; export const OptionComponent = < @@ -46,7 +48,15 @@ export const OptionComponent = < {props.label || <> } - {(props.data as unknown as ExtendedOption).needsUpgrade && } + {(props.data as unknown as ExtendedOption).needsUpgrade ? ( + + ) : (props.data as unknown as ExtendedOption).needsVerification ? ( + + ) : ( + <> + )} {props.isSelected && }