fix: add extra security layers for sending messages to attendees (#10636)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
This commit is contained in:
co-authored by
CarinaWolli
parent
633d85e81b
commit
c7dfa7bc89
@@ -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);
|
||||
@@ -0,0 +1,11 @@
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
import { getLayout } from "@components/auth/layouts/AdminLayout";
|
||||
|
||||
import KYCVerificationView from "./kycVerificationView";
|
||||
|
||||
const KYCVerificationPage = () => <KYCVerificationView />;
|
||||
|
||||
KYCVerificationPage.getLayout = getLayout;
|
||||
KYCVerificationPage.PageWrapper = PageWrapper;
|
||||
|
||||
export default KYCVerificationPage;
|
||||
@@ -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<FormValues>();
|
||||
const userForm = useForm<FormValues>();
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<Meta
|
||||
title="KYC Verification"
|
||||
description="Here you can verify users and teams. This verification is needed for sending sms/whatsapp messages to attendees."
|
||||
/>
|
||||
<div>
|
||||
<div className="mb-2 font-medium">Verify Team</div>
|
||||
|
||||
<Form
|
||||
form={teamForm}
|
||||
handleSubmit={(values) => {
|
||||
mutation.mutate({
|
||||
name: values.name || "",
|
||||
isTeam: true,
|
||||
});
|
||||
}}>
|
||||
<div className="flex space-x-2">
|
||||
<TextField
|
||||
{...teamForm.register("name")}
|
||||
label=""
|
||||
type="text"
|
||||
id="name"
|
||||
placeholder="team slug"
|
||||
className="-mt-2 "
|
||||
required
|
||||
/>
|
||||
<Button type="submit">Verify</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 mt-6 font-medium">Verify User</div>
|
||||
<Form
|
||||
form={userForm}
|
||||
handleSubmit={(values) => {
|
||||
mutation.mutate({
|
||||
name: values.name || "",
|
||||
isTeam: false,
|
||||
});
|
||||
}}>
|
||||
<div className="flex space-x-2">
|
||||
<TextField
|
||||
{...userForm.register("name")}
|
||||
label=""
|
||||
type="text"
|
||||
id="name"
|
||||
placeholder="user name"
|
||||
className="-mt-2"
|
||||
required
|
||||
/>
|
||||
<Button type="submit">Verify</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 <a>{{supportEmail}}</a> and provide the following information:",
|
||||
"kyc_verification_documents": "<ul><li>Your {{teamOrUser}}</li><li>For businesses: Attach your Business Verification Documentation</li><li>For individuals: Attach a government-issued ID</li></ul>",
|
||||
"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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Booking> & {
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -173,7 +173,8 @@ export const AddActionDialog = (props: IAddActionDialog) => {
|
||||
label: string;
|
||||
value: WorkflowActions;
|
||||
needsUpgrade: boolean;
|
||||
}) => option.needsUpgrade}
|
||||
needsVerification: boolean;
|
||||
}) => option.needsUpgrade || option.needsVerification}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -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 (
|
||||
<div className="border-subtle flex w-full items-center overflow-hidden rounded-md border p-6 px-3 md:p-6">
|
||||
<div className="bg-subtle mr-4 flex h-10 w-10 items-center justify-center rounded-full text-xs font-medium">
|
||||
{getActionIcon(
|
||||
workflow.steps,
|
||||
isActive ? "h-6 w-6 stroke-[1.5px] text-default" : "h-6 w-6 stroke-[1.5px] text-muted"
|
||||
)}
|
||||
</div>
|
||||
<div className=" grow">
|
||||
<div
|
||||
className={classNames(
|
||||
"text-emphasis mb-1 w-full truncate text-base font-medium leading-4 md:max-w-max",
|
||||
workflow.name && isActive ? "text-emphasis" : "text-subtle"
|
||||
)}>
|
||||
{workflow.name
|
||||
? workflow.name
|
||||
: "Untitled (" +
|
||||
`${t(`${workflow.steps[0].action.toLowerCase()}_action`)}`.charAt(0).toUpperCase() +
|
||||
`${t(`${workflow.steps[0].action.toLowerCase()}_action`)}`.slice(1) +
|
||||
")"}
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
" flex w-fit items-center whitespace-nowrap rounded-sm text-sm leading-4",
|
||||
isActive ? "text-default" : "text-muted"
|
||||
)}>
|
||||
<span className="mr-1">{t("to")}:</span>
|
||||
{Array.from(sendTo).map((sendToPerson, index) => {
|
||||
return <span key={index}>{`${index ? ", " : ""}${sendToPerson}`}</span>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{!workflow.readOnly && (
|
||||
<div className="flex-none">
|
||||
<Link href={`/workflows/${workflow.id}`} passHref={true} target="_blank">
|
||||
<Button type="button" color="minimal" className="mr-4">
|
||||
<div className="hidden ltr:mr-2 rtl:ml-2 sm:block">{t("edit")}</div>
|
||||
<ExternalLink className="text-default -mt-[2px] h-4 w-4 stroke-2" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<Tooltip
|
||||
content={
|
||||
t(
|
||||
workflow.readOnly && props.isChildrenManagedEventType
|
||||
? "locked_by_admin"
|
||||
: isActive
|
||||
? "turn_off"
|
||||
: "turn_on"
|
||||
) as string
|
||||
}>
|
||||
<div className="flex items-center ltr:mr-2 rtl:ml-2">
|
||||
{workflow.readOnly && props.isChildrenManagedEventType && (
|
||||
<Lock className="text-subtle h-4 w-4 ltr:mr-2 rtl:ml-2" />
|
||||
<div className="border-subtle w-full overflow-hidden rounded-md border p-6 px-3 md:p-6">
|
||||
<div className="flex items-center ">
|
||||
<div className="bg-subtle mr-4 flex h-10 w-10 items-center justify-center rounded-full text-xs font-medium">
|
||||
{getActionIcon(
|
||||
workflow.steps,
|
||||
isActive ? "h-6 w-6 stroke-[1.5px] text-default" : "h-6 w-6 stroke-[1.5px] text-muted"
|
||||
)}
|
||||
<Switch
|
||||
checked={isActive}
|
||||
disabled={workflow.readOnly}
|
||||
onCheckedChange={() => {
|
||||
activateEventTypeMutation.mutate({ workflowId: workflow.id, eventTypeId: eventType.id });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div className=" grow">
|
||||
<div
|
||||
className={classNames(
|
||||
"text-emphasis mb-1 w-full truncate text-base font-medium leading-4 md:max-w-max",
|
||||
workflow.name && isActive ? "text-emphasis" : "text-subtle"
|
||||
)}>
|
||||
{workflow.name
|
||||
? workflow.name
|
||||
: "Untitled (" +
|
||||
`${t(`${workflow.steps[0].action.toLowerCase()}_action`)}`.charAt(0).toUpperCase() +
|
||||
`${t(`${workflow.steps[0].action.toLowerCase()}_action`)}`.slice(1) +
|
||||
")"}
|
||||
</div>
|
||||
<>
|
||||
<div
|
||||
className={classNames(
|
||||
" flex w-fit items-center whitespace-nowrap rounded-sm text-sm leading-4",
|
||||
isActive ? "text-default" : "text-muted"
|
||||
)}>
|
||||
<span className="mr-1">{t("to")}:</span>
|
||||
{Array.from(sendTo).map((sendToPerson, index) => {
|
||||
return <span key={index}>{`${index ? ", " : ""}${sendToPerson}`}</span>;
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
{!workflow.readOnly && (
|
||||
<div className="flex-none">
|
||||
<Link href={`/workflows/${workflow.id}`} passHref={true} target="_blank">
|
||||
<Button type="button" color="minimal" className="mr-4">
|
||||
<div className="hidden ltr:mr-2 rtl:ml-2 sm:block">{t("edit")}</div>
|
||||
<ExternalLink className="text-default -mt-[2px] h-4 w-4 stroke-2" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<Tooltip
|
||||
content={
|
||||
t(
|
||||
workflow.readOnly && props.isChildrenManagedEventType
|
||||
? "locked_by_admin"
|
||||
: isActive
|
||||
? "turn_off"
|
||||
: "turn_on"
|
||||
) as string
|
||||
}>
|
||||
<div className="flex items-center ltr:mr-2 rtl:ml-2">
|
||||
{workflow.readOnly && props.isChildrenManagedEventType && (
|
||||
<Lock className="text-subtle h-4 w-4 ltr:mr-2 rtl:ml-2" />
|
||||
)}
|
||||
<Switch
|
||||
checked={isActive}
|
||||
disabled={workflow.readOnly}
|
||||
onCheckedChange={() => {
|
||||
activateEventTypeMutation.mutate({ workflowId: workflow.id, eventTypeId: eventType.id });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{needsRequiresConfirmationWarning ? (
|
||||
<div className="text-attention -mb-2 mt-3 flex">
|
||||
<Info className="mr-1 mt-0.5 h-4 w-4" />
|
||||
<p className="text-sm">{t("requires_confirmation_mandatory")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<SetStateAction<boolean>>;
|
||||
isPartOfTeam: boolean;
|
||||
}) => {
|
||||
const { isOpenDialog, setIsOpenDialog, isPartOfTeam } = props;
|
||||
const { t } = useLocale();
|
||||
|
||||
const isTeamString = isPartOfTeam ? "team" : "";
|
||||
|
||||
return (
|
||||
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
||||
<DialogContent title={t("verify_team_or_account", { teamOrAccount: isTeamString || "account" })}>
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<Trans
|
||||
i18nKey="kyc_verification_information"
|
||||
components={{
|
||||
a: (
|
||||
<a
|
||||
href={`mailto:support@cal.com?subject=${
|
||||
isPartOfTeam ? "Team%20Verification" : "Account%20Verification"
|
||||
}`}
|
||||
style={{ color: "#3E3E3E" }}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
values={{
|
||||
supportEmail:
|
||||
SUPPORT_MAIL_ADDRESS === "help@cal.com" ? "support@cal.com" : SUPPORT_MAIL_ADDRESS,
|
||||
teamOrAccount: isTeamString || "account",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Trans
|
||||
i18nKey="kyc_verification_documents"
|
||||
components={{ li: <li />, ul: <ul className="ml-8 list-disc" /> }}
|
||||
values={{ teamOrUser: isPartOfTeam ? "team URL" : "user name" }}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose />
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<>
|
||||
<div className="my-8 sm:my-0 md:flex">
|
||||
<div className="z-1 my-8 sm:my-0 md:flex">
|
||||
<div className="pl-2 pr-3 md:sticky md:top-6 md:h-0 md:pl-0">
|
||||
<div className="mb-5">
|
||||
<TextField
|
||||
@@ -168,6 +174,7 @@ export default function WorkflowDetailsPage(props: Props) {
|
||||
user={props.user}
|
||||
teamId={teamId}
|
||||
readOnly={props.readOnly}
|
||||
setKYCVerificationDialogOpen={setKYCVerificationDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -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}
|
||||
/>
|
||||
<KYCVerificationDialog
|
||||
isOpenDialog={isKYCVerificationDialogOpen}
|
||||
setIsOpenDialog={setKYCVerificationDialogOpen}
|
||||
isPartOfTeam={!!isPartOfTeam.hasTeamPlan}
|
||||
/>
|
||||
<DeleteDialog
|
||||
isOpenDialog={deleteDialogOpen}
|
||||
setIsOpenDialog={setDeleteDialogOpen}
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
isSMSOrWhatsappAction,
|
||||
isWhatsappAction,
|
||||
getWhatsappTemplateForAction,
|
||||
isTextMessageToAttendeeAction,
|
||||
} from "../lib/actionHelperFunctions";
|
||||
import { DYNAMIC_TEXT_VARIABLES } from "../lib/constants";
|
||||
import { getWorkflowTemplateOptions, getWorkflowTriggerOptions } from "../lib/getOptions";
|
||||
@@ -66,6 +67,7 @@ type WorkflowStepProps = {
|
||||
setReload?: Dispatch<SetStateAction<boolean>>;
|
||||
teamId?: number;
|
||||
readOnly: boolean;
|
||||
setKYCVerificationDialogOpen: Dispatch<SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
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 ? (
|
||||
<div className="text-attention mb-3 mt-2 flex">
|
||||
<Info className="mr-1 mt-0.5 h-4 w-4" />
|
||||
<p className="text-sm">{t("requires_confirmation_mandatory")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
{isPhoneNumberNeeded && (
|
||||
<div className="bg-muted mt-2 rounded-md p-4 pt-0">
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -40,6 +40,7 @@ const ENDPOINTS = [
|
||||
"workflows",
|
||||
"appsRouter",
|
||||
"googleWorkspace",
|
||||
"kycVerification",
|
||||
] as const;
|
||||
export type Endpoint = (typeof ENDPOINTS)[number];
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -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<TrpcSessionUser>;
|
||||
};
|
||||
};
|
||||
|
||||
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 };
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -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<TrpcSessionUser>;
|
||||
};
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZVerifyInputSchema = z.object({
|
||||
name: z.string(),
|
||||
isTeam: z.boolean(),
|
||||
});
|
||||
|
||||
export type TVerifyInputSchema = z.infer<typeof ZVerifyInputSchema>;
|
||||
@@ -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
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<Tooltip content={t("verify_team_tooltip")}>
|
||||
<Badge variant="gray" onClick={() => verifyTeamAction()}>
|
||||
{t("verify_account")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
export { Badge } from "./Badge";
|
||||
export { UpgradeTeamsBadge } from "./UpgradeTeamsBadge";
|
||||
export { KYCVerificationBadge } from "./KYCVerificationBadge";
|
||||
|
||||
export type { BadgeProps } from "./Badge";
|
||||
|
||||
@@ -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 = <
|
||||
<span className="mr-auto" data-testid={`select-option-${(props as unknown as ExtendedOption).value}`}>
|
||||
{props.label || <> </>}
|
||||
</span>
|
||||
{(props.data as unknown as ExtendedOption).needsUpgrade && <UpgradeTeamsBadge />}
|
||||
{(props.data as unknown as ExtendedOption).needsUpgrade ? (
|
||||
<UpgradeTeamsBadge />
|
||||
) : (props.data as unknown as ExtendedOption).needsVerification ? (
|
||||
<KYCVerificationBadge
|
||||
verifyTeamAction={(props.data as unknown as ExtendedOption).verificationAction}
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
{props.isSelected && <Check className="ml-2 h-4 w-4" />}
|
||||
</div>
|
||||
</reactSelectComponents.Option>
|
||||
|
||||
Reference in New Issue
Block a user