Merge branch 'main' into zomars/cal-2724-implement-csrf-protection-for-public-trpc-endpoints

This commit is contained in:
Morgan 2023-11-27 15:32:40 +02:00 committed by GitHub
commit dcff308392
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 936 additions and 542 deletions

View File

@ -155,6 +155,7 @@ NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE=
STRIPE_TEAM_MONTHLY_PRICE_ID=
STRIPE_ORG_MONTHLY_PRICE_ID=
STRIPE_WEBHOOK_SECRET=
STRIPE_WEBHOOK_SECRET_APPS=
STRIPE_PRIVATE_KEY=
STRIPE_CLIENT_ID=
PAYMENT_FEE_FIXED=

View File

@ -299,7 +299,7 @@ async function postHandler(req: NextApiRequest) {
data.hosts = { createMany: { data: hosts } };
}
const eventType = await prisma.eventType.create({ data });
const eventType = await prisma.eventType.create({ data, include: { hosts: true } });
return {
event_type: schemaEventTypeReadPublic.parse(eventType),

View File

@ -1,3 +1,4 @@
import { CAL_URL } from "@calcom/lib/constants";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import type { User } from "@calcom/prisma/client";
import { AvatarGroup } from "@calcom/ui";
@ -11,6 +12,7 @@ export function UserAvatarGroup(props: UserAvatarProps) {
<AvatarGroup
{...rest}
items={users.map((user) => ({
href: `${CAL_URL}/${user.username}?redirect=false`,
alt: user.name || "",
title: user.name || "",
image: getUserAvatarUrl(user),

View File

@ -1,4 +1,5 @@
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import { CAL_URL, WEBAPP_URL } from "@calcom/lib/constants";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import type { Team, User } from "@calcom/prisma/client";
import { AvatarGroup } from "@calcom/ui";
@ -10,8 +11,11 @@ type UserAvatarProps = Omit<React.ComponentProps<typeof AvatarGroup>, "items"> &
export function UserAvatarGroupWithOrg(props: UserAvatarProps) {
const { users, organization, ...rest } = props;
const orgBranding = useOrgBranding();
const baseUrl = `${orgBranding?.fullDomain ?? CAL_URL}`;
const items = [
{
href: baseUrl,
image: `${WEBAPP_URL}/team/${organization.slug}/avatar.png`,
alt: organization.name || undefined,
title: organization.name,
@ -19,6 +23,7 @@ export function UserAvatarGroupWithOrg(props: UserAvatarProps) {
].concat(
users.map((user) => {
return {
href: `${baseUrl}/${user.username}/?redirect=false`,
image: getUserAvatarUrl(user),
alt: user.name || undefined,
title: user.name || user.username || "",

View File

@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "3.5.0",
"version": "3.5.2",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",

View File

@ -55,6 +55,7 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
// So it doesn't display in the Link (and make tests fail)
user: _user,
orgSlug: _orgSlug,
redirect: _redirect,
...query
} = useRouterQuery();
@ -117,7 +118,7 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
: null
}
/>
<h1 className="font-cal text-emphasis mb-1 text-3xl" data-testid="name-title">
<h1 className="font-cal text-emphasis my-1 text-3xl" data-testid="name-title">
{profile.name}
{user.verified && (
<Verified className=" mx-1 -mt-1 inline h-6 w-6 fill-blue-500 text-white dark:text-black" />
@ -390,6 +391,16 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
descriptionAsSafeHTML: markdownToSafeHTML(eventType.description),
}));
// if profile only has one public event-type, redirect to it
if (eventTypes.length === 1 && context.query.redirect !== "false") {
return {
redirect: {
permanent: false,
destination: `/${user.username}/${eventTypes[0].slug}`,
},
};
}
const safeBio = markdownToSafeHTML(user.bio) || "";
const markdownStrippedBio = stripMarkdown(user?.bio || "");

View File

@ -83,13 +83,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
throw new HttpCode({ statusCode: 400, message: "Missing stripe-signature" });
}
if (!process.env.STRIPE_WEBHOOK_SECRET) {
throw new HttpCode({ statusCode: 500, message: "Missing process.env.STRIPE_WEBHOOK_SECRET" });
if (!process.env.STRIPE_WEBHOOK_SECRET_APPS) {
throw new HttpCode({ statusCode: 500, message: "Missing process.env.STRIPE_WEBHOOK_SECRET_APPS" });
}
const requestBuffer = await buffer(req);
const payload = requestBuffer.toString();
const event = stripe.webhooks.constructEvent(payload, sig, process.env.STRIPE_WEBHOOK_SECRET);
const event = stripe.webhooks.constructEvent(payload, sig, process.env.STRIPE_WEBHOOK_SECRET_APPS);
const handler = webhookHandlers[event.type];
if (handler) {

View File

@ -105,8 +105,6 @@ async function getIdentityData(req: NextApiRequest) {
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const identity = await getIdentityData(req);
const img = identity?.avatar;
// We cache for one day
res.setHeader("Cache-Control", "s-maxage=86400, stale-while-revalidate=60");
// If image isn't set or links to this route itself, use default avatar
if (!img) {
if (identity?.org) {

View File

@ -97,7 +97,7 @@ export default function Bookings() {
}
);
// Animate page (tab) tranistions to look smoothing
// Animate page (tab) transitions to look smoothing
const buttonInView = useInViewObserver(() => {
if (!query.isFetching && query.hasNextPage && query.status === "success") {

View File

@ -13,7 +13,7 @@ import { getLayout } from "@calcom/features/MainLayout";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import useIntercom from "@calcom/features/ee/support/lib/intercom/useIntercom";
import { EventTypeEmbedButton, EventTypeEmbedDialog } from "@calcom/features/embed/EventTypeEmbed";
import { EventTypeDescriptionLazy as EventTypeDescription } from "@calcom/features/eventtypes/components";
import { EventTypeDescription } from "@calcom/features/eventtypes/components";
import CreateEventTypeDialog from "@calcom/features/eventtypes/components/CreateEventTypeDialog";
import { DuplicateDialog } from "@calcom/features/eventtypes/components/DuplicateDialog";
import { TeamsFilter } from "@calcom/features/filters/components/TeamsFilter";

View File

@ -69,17 +69,17 @@ export default function InsightsPage() {
<FiltersProvider>
<Filters />
<div className="mb-4 space-y-6">
<div className="mb-4 space-y-4">
<BookingKPICards />
<BookingStatusLineChart />
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<PopularEventsTable />
<AverageEventDurationChart />
</div>
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<MostBookedTeamMembersTable />
<LeastBookedTeamMembersTable />
</div>

View File

@ -26,7 +26,7 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
if (team) {
return GSSTeamPage({ ...ctx, query: { slug: ctx.query.user } });
}
return GSSUserPage({ ...ctx, query: { user: ctx.query.user } });
return GSSUserPage({ ...ctx, query: { user: ctx.query.user, redirect: ctx.query.redirect } });
};
type Props = UserPageProps | TeamPageProps;

View File

@ -73,10 +73,10 @@ const fillQuestion = async (eventTypePage: Page, questionType: string, customLoc
},
multiselect: async () => {
if (customLocators.shouldChangeMultiSelectLocator) {
await eventTypePage.locator("form svg").nth(1).click();
await eventTypePage.getByLabel("multi-select-dropdown").click();
await eventTypePage.getByTestId("select-option-Option 1").click();
} else {
await eventTypePage.locator("form svg").last().click();
await eventTypePage.getByLabel("multi-select-dropdown").last().click();
await eventTypePage.getByTestId("select-option-Option 1").click();
}
},
@ -88,10 +88,10 @@ const fillQuestion = async (eventTypePage: Page, questionType: string, customLoc
},
select: async () => {
if (customLocators.shouldChangeSelectLocator) {
await eventTypePage.locator("form svg").nth(1).click();
await eventTypePage.getByLabel("select-dropdown").first().click();
await eventTypePage.getByTestId("select-option-Option 1").click();
} else {
await eventTypePage.locator("form svg").last().click();
await eventTypePage.getByLabel("select-dropdown").last().click();
await eventTypePage.getByTestId("select-option-Option 1").click();
}
},
@ -138,11 +138,12 @@ const fillAllQuestions = async (eventTypePage: Page, questions: string[], option
await eventTypePage.getByPlaceholder("Textarea test").fill("This is a sample text for textarea.");
break;
case "select":
await eventTypePage.locator("form svg").last().click();
await eventTypePage.getByLabel("select-dropdown").last().click();
await eventTypePage.getByTestId("select-option-Option 1").click();
break;
case "multiselect":
await eventTypePage.locator("form svg").nth(4).click();
// select-dropdown
await eventTypePage.getByLabel("multi-select-dropdown").click();
await eventTypePage.getByTestId("select-option-Option 1").click();
break;
case "number":

View File

@ -351,7 +351,7 @@ test.describe("Teams - Org", () => {
await page.goto(`/team/${team.slug}/${teamEventSlug}`);
await expect(page.locator('[data-testid="404-page"]')).toBeVisible();
await expect(page.locator("text=This page could not be found")).toBeVisible();
await doOnOrgDomain(
{
orgSlug: org.slug,

View File

@ -155,7 +155,7 @@
"webhook_updated_successfully": "Webhook erfolgreich aktualisiert!",
"webhook_removed_successfully": "Webhook erfolgreich entfernt!",
"payload_template": "Payload Vorlage",
"dismiss": "Abbrechen",
"dismiss": "Ignorieren",
"no_data_yet": "Noch keine Daten",
"ping_test": "Pingtest",
"add_to_homescreen": "Fügen Sie diese App Ihrem Startbildschirm für schnelleren Zugriff hinzu.",
@ -295,7 +295,7 @@
"other": "Sonstige",
"email_sign_in_subject": "Ihr Anmelde-Link für {{appName}}",
"emailed_you_and_attendees": "Wir haben eine E-Mail mit einer Kalendereinladung mit den Details an alle gesendet.",
"emailed_you_and_attendees_recurring": "Wir haben eine E-Mail mit einer Kalendereinladung mit den Details für das erste dieser wiederkehrenden Ereignisse an alle gesendet.",
"emailed_you_and_attendees_recurring": "Wir haben eine E-Mail mit einer Kalendereinladung mit den Details für das erste dieser wiederkehrenden Termine an alle gesendet.",
"emailed_you_and_any_other_attendees": "Wir haben eine E-Mail mit diesen Informationen an alle gesendet.",
"needs_to_be_confirmed_or_rejected": "Ihr Termin muss noch bestätigt oder abgelehnt werden.",
"needs_to_be_confirmed_or_rejected_recurring": "Ihr wiederkehrender Termin muss noch bestätigt oder abgelehnt werden.",
@ -683,7 +683,7 @@
"plan_upgrade_teams": "Du musst deinen Plan upgraden, um ein Team zu erstellen.",
"plan_upgrade_instructions": "Sie können <1>hier upgraden</1>.",
"event_types_page_title": "Ereignistypen",
"event_types_page_subtitle": "Erstellen Sie teilbare Ereignisse, die andere Personen buchen können.",
"event_types_page_subtitle": "Erstellen Sie teilbare Termine, die andere Personen buchen können.",
"new": "Neu",
"new_event_type_btn": "Neuer Ereignistyp",
"new_event_type_heading": "Erstellen Sie Ihren ersten Ereignistyp",
@ -703,7 +703,7 @@
"repeats_up_to_other": "Wiederholt sich bis zu {{count}} mal",
"every_for_freq": "Alle {{freq}} für",
"event_remaining_one": "{{count}} Ereignis übrig",
"event_remaining_other": "{{count}} Ereignisse übrig",
"event_remaining_other": "{{count}} Termine übrig",
"repeats_every": "Wiederholt sich alle",
"occurrence_one": "Vorkommnis",
"occurrence_other": "Vorkommnisse",
@ -1110,7 +1110,7 @@
"zapier_invite_link": "Zapier Einladungs-Link",
"meeting_url_provided_after_confirmed": "Eine Termin-URL wird angelegt, sobald der Termin bestätigt wurde.",
"dynamically_display_attendee_or_organizer": "Zeigt dynamisch entweder Ihnen den Namen Ihres Teilnehmers bzw. Ihrer Teilnehmerin an oder zeigt Ihrem/Ihrer Teilnehmerin Ihren Namen an",
"event_location": "Ort des Ereignisses",
"event_location": "Ort des Termins",
"reschedule_optional": "Grund für die Verschiebung (optional)",
"reschedule_placeholder": "Lassen Sie andere wissen, warum Sie den Termin verschieben müssen",
"event_cancelled": "Dieser Termin ist abgesagt",
@ -1277,7 +1277,7 @@
"2fa_required": "Zwei-Faktor-Authentifizierung erforderlich",
"incorrect_2fa": "Falscher Zwei-Faktor-Authentifizierungscode",
"which_event_type_apply": "Auf welchen Termintyp wird dies angewandt?",
"no_workflows_description": "Workflows ermöglichen die einfache Automatisierung des Versands von Benachrichtigungen und Erinnerungen, so dass Sie Prozesse rund um Ihre Ereignisse erstellen können.",
"no_workflows_description": "Workflows ermöglichen die einfache Automatisierung des Versands von Benachrichtigungen und Erinnerungen, so dass Sie Prozesse rund um Ihre Termine erstellen können.",
"timeformat_profile_hint": "Dies ist eine interne Einstellung, die keinen Einfluss darauf hat, wie die Zeiten auf den öffentlichen Buchungsseiten für Sie oder jemanden, der Sie buchen möchte, angezeigt werden.",
"create_workflow": "Einen Workflow erstellen",
"do_this": "Mache dies",
@ -1362,7 +1362,7 @@
"add_dynamic_variables": "Dynamische Textvariablen hinzufügen",
"event_name_info": "Name des Ereignistyps",
"event_date_info": "Das Datum der Veranstaltung",
"event_time_info": "Die Startzeit des Ereignisses",
"event_time_info": "Die Startzeit des Termins",
"location_info": "Der Ort des Events",
"additional_notes_info": "Die zusätzlichen Anmerkungen der Buchung",
"attendee_name_info": "Name der buchenden Person",
@ -1380,7 +1380,7 @@
"create_your_first_webhook_description": "Mit Webhooks können Sie Meetingdaten in Echtzeit erhalten, sobald etwas in {{appName}} passiert.",
"for_a_maximum_of": "Für maximal",
"event_one": "Ereignis",
"event_other": "Ereignisse",
"event_other": "Termine",
"profile_team_description": "Einstellungen für Ihr Teamprofil verwalten",
"profile_org_description": "Einstellungen für Ihr Organization-Profil verwalten",
"members_team_description": "Benutzer in der Gruppe",
@ -1502,13 +1502,13 @@
"require_additional_notes": "Zusätzliche Notizen erforderlich",
"require_additional_notes_description": "Zusätzliche Notizen bei der Buchung erforderlich machen",
"email_address_action": "eine E-Mail an eine bestimmte E-Mail-Adresse senden",
"after_event_trigger": "nach Ende des Ereignisses",
"how_long_after": "Wie lange nach Ende des Ereignisses?",
"after_event_trigger": "nach Ende des Termins",
"how_long_after": "Wie lange nach Ende des Termins?",
"no_available_slots": "Keine verfügbaren Plätze",
"time_available": "Zeit verfügbar",
"cant_find_the_right_video_app_visit_our_app_store": "Sie können die richtige Video-App nicht finden? Besuchen Sie unseren <1>App Store</1>.",
"install_new_calendar_app": "Neue Kalender-App installieren",
"make_phone_number_required": "Telefonnummer für die Buchung des Ereignisses erforderlich machen",
"make_phone_number_required": "Telefonnummer für die Buchung des Termins erforderlich machen",
"new_event_type_availability": "{{eventTypeTitle}} | Verfügbarkeit",
"error_editing_availability": "Fehler beim Bearbeiten der Verfügbarkeit",
"dont_have_permission": "Ihnen fehlt die Berechtigung, auf diese Ressource zuzugreifen.",
@ -1813,7 +1813,7 @@
"can_you_try_again": "Können Sie es zu einem anderen Zeitpunkt erneut versuchen?",
"verify": "Bestätigen",
"timezone_info": "Die Zeitzone der empfangenden Person",
"event_end_time_variable": "Endzeitpunkt des Ereignisses",
"event_end_time_variable": "Endzeitpunkt des Termins",
"event_end_time_info": "Der Endzeitpunkt des Termins",
"cancel_url_variable": "Absage-URL",
"cancel_url_info": "Die URL, um die Buchung abzusagen",
@ -1849,15 +1849,15 @@
"select_user": "Benutzer auswählen",
"select_event_type": "Ereignistyp auswählen",
"select_date_range": "Datumsbereich auswählen",
"popular_events": "Beliebte Ereignisse",
"popular_events": "Beliebte Termine",
"no_event_types_found": "Keine Ereignistypen gefunden",
"average_event_duration": "Durchschnittliche Ereignisdauer",
"most_booked_members": "Meistgebuchte Mitglieder",
"least_booked_members": "Am wenigsten gebuchte Mitglieder",
"events_created": "Ereignisse erstellt",
"events_completed": "Ereignisse abgeschlossen",
"events_created": "Termine erstellt",
"events_completed": "Termine abgeschlossen",
"events_cancelled": "Termine abgesagt",
"events_rescheduled": "Ereignisse neu geplant",
"events_rescheduled": "Termine neu geplant",
"from_last_period": "seit dem letzten Zeitraum",
"from_to_date_period": "Von: {{startDate}} Bis: {{endDate}}",
"redirect_url_warning": "Das Hinzufügen einer Umleitung wird die Erfolgsseite deaktivieren. Erwähnen Sie \"Buchung bestätigt\" auf Ihrer benutzerdefinierten Erfolgsseite.",
@ -1996,7 +1996,7 @@
"insights_all_org_filter": "Alle Apps",
"insights_team_filter": "Team: {{teamName}}",
"insights_user_filter": "Benutzer: {{userName}}",
"insights_subtitle": "Sehen Sie sich Buchungs-Insights zu Ihren Termine an",
"insights_subtitle": "Erfahren Sie mehr über Ihre Termine und Ihr Team",
"location_options": "{{locationCount}} Veranstaltungsort-Optionen",
"custom_plan": "Maßgeschneiderter Tarif",
"email_embed": "E-Mail Einbettung",

View File

@ -2121,6 +2121,7 @@
"manage_availability_schedules":"Manage availability schedules",
"lock_timezone_toggle_on_booking_page": "Lock timezone on booking page",
"description_lock_timezone_toggle_on_booking_page" : "To lock the timezone on booking page, useful for in-person events.",
"number_in_international_format": "Please enter number in international format.",
"install_calendar":"Install Calendar",
"branded_subdomain": "Branded Subdomain",
"branded_subdomain_description": "Get your own branded subdomain, such as acme.cal.com",

View File

@ -40,6 +40,8 @@ export const withPaidAppRedirect = async ({
? {
subscription_data: {
trial_period_days: trialDays,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - trial_settings isn't available cc @erik
trial_settings: { end_behavior: { missing_payment_method: "cancel" } },
},
}

View File

@ -7,8 +7,6 @@ items:
- 5.jpg
---
**FREE TRIAL until December 1st, 2023**
{DESCRIPTION}
## Example questions:

View File

@ -1,57 +1,14 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { defaultResponder } from "@calcom/lib/server";
import { createContext } from "@calcom/trpc/server/createContext";
import { apiKeysRouter } from "@calcom/trpc/server/routers/viewer/apiKeys/_router";
import checkSession from "../../_utils/auth";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import { checkInstalled, createDefaultInstallation } from "../../_utils/installation";
import { withPaidAppRedirect } from "../../_utils/paid-apps";
import appConfig from "../config.json";
const trialEndDate = new Date(Date.UTC(2023, 11, 1));
export async function getHandler(req: NextApiRequest, res: NextApiResponse) {
const session = checkSession(req);
// if date is in the future, we install normally.
if (new Date() < trialEndDate) {
const ctx = await createContext({ req, res });
const caller = apiKeysRouter.createCaller(ctx);
const apiKey = await caller.create({
note: "Cal.ai",
expiresAt: null,
appId: "cal-ai",
});
await checkInstalled(appConfig.slug, session.user.id);
await createDefaultInstallation({
appType: appConfig.type,
userId: session.user.id,
slug: appConfig.slug,
key: {
apiKey,
},
});
await fetch(
`${process.env.NODE_ENV === "development" ? "http://localhost:3005" : "https://cal.ai"}/api/onboard`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
userId: session.user.id,
}),
}
);
return { url: getInstalledAppPath({ variant: appConfig.variant, slug: "cal-ai" }) };
}
const redirectUrl = await withPaidAppRedirect({
appPaidMode: appConfig.paid.mode,
appSlug: appConfig.slug,

View File

@ -166,6 +166,7 @@ const MultiSelectWidget = ({
return (
<Select
aria-label="multi-select-dropdown"
className="mb-2"
onChange={(items) => {
setValue(items?.map((item) => item.value));
@ -193,6 +194,7 @@ function SelectWidget({ listValues, setValue, value, ...remainingProps }: Select
return (
<Select
aria-label="select-dropdown"
className="data-testid-select mb-2"
onChange={(item) => {
if (!item) {

View File

@ -36,7 +36,7 @@ test.describe("Routing Forms", () => {
await page.goto(`apps/routing-forms/route-builder/${formId}`);
await disableForm(page);
await gotoRoutingLink({ page, formId });
await expect(page.locator("text=ERROR 404")).toBeVisible();
await expect(page.locator("text=This page could not be found")).toBeVisible();
});
test("should be able to edit the form", async ({ page }) => {

View File

@ -17,7 +17,7 @@ class CalendarEventClass implements CalendarEvent {
organizer!: Person;
attendees!: Person[];
description?: string | null;
team?: { name: string; members: Person[] };
team?: { name: string; members: Person[]; id: number };
location?: string | null;
conferenceData?: ConferenceData;
additionalInformation?: AdditionalInformation;

View File

@ -78,13 +78,13 @@ export const TeamInviteEmail = (
marginTop: "48px",
lineHeightStep: "24px",
}}>
<>
{/* <>
{props.language("email_no_user_invite_steps_intro", {
entity: props.language(props.isOrg ? "organization" : "team").toLowerCase(),
})}
</>
</> */}
</p>
{/*
{!props.isCalcomMember && (
<div style={{ display: "flex", flexDirection: "column", gap: "32px" }}>
<EmailStep
@ -120,7 +120,7 @@ export const TeamInviteEmail = (
}
/>
</div>
)}
)} */}
<div className="">
<p

View File

@ -72,7 +72,12 @@ async function getBookingToDelete(id: number | undefined, uid: string | undefine
hideBranding: true,
},
},
teamId: true,
team: {
select: {
id: true,
name: true,
},
},
recurringEvent: true,
title: true,
eventName: true,
@ -151,11 +156,10 @@ async function handler(req: CustomRequest) {
const teamId = await getTeamIdFromEventType({
eventType: {
team: { id: bookingToDelete.eventType?.teamId ?? null },
team: { id: bookingToDelete.eventType?.team?.id ?? null },
parentId: bookingToDelete?.eventType?.parentId ?? null,
},
});
const triggerForUser = !teamId || (teamId && bookingToDelete.eventType?.parentId);
const subscriberOptions = {
@ -255,7 +259,9 @@ async function handler(req: CustomRequest) {
? [bookingToDelete?.user.destinationCalendar]
: [],
cancellationReason: cancellationReason,
...(teamMembers && { team: { name: "", members: teamMembers } }),
...(teamMembers && {
team: { name: bookingToDelete?.eventType?.team?.name || "Nameless", members: teamMembers, id: teamId! },
}),
seatsPerTimeSlot: bookingToDelete.eventType?.seatsPerTimeSlot,
seatsShowAttendees: bookingToDelete.eventType?.seatsShowAttendees,
};
@ -408,7 +414,7 @@ async function handler(req: CustomRequest) {
if (bookingToDelete.location === DailyLocationType) {
bookingToDelete.user.credentials.push({
...FAKE_DAILY_CREDENTIAL,
teamId: bookingToDelete.eventType?.teamId || null,
teamId: bookingToDelete.eventType?.team?.id || null,
});
}
@ -540,10 +546,10 @@ async function handler(req: CustomRequest) {
let eventTypeOwnerId;
if (bookingToDelete.eventType?.owner) {
eventTypeOwnerId = bookingToDelete.eventType.owner.id;
} else if (bookingToDelete.eventType?.teamId) {
} else if (bookingToDelete.eventType?.team?.id) {
const teamOwner = await prisma.membership.findFirst({
where: {
teamId: bookingToDelete.eventType.teamId,
teamId: bookingToDelete.eventType?.team.id,
role: MembershipRole.OWNER,
},
select: {

View File

@ -1079,7 +1079,6 @@ async function handler(
},
};
});
const teamMembers = await Promise.all(teamMemberPromises);
const attendeesList = [...invitee, ...guests];
@ -1887,6 +1886,7 @@ async function handler(
evt.team = {
members: teamMembers,
name: eventType.team?.name || "Nameless",
id: eventType.team?.id ?? 0,
};
}

View File

@ -5,7 +5,7 @@ import React, { Fragment, useEffect } from "react";
import { SUPPORT_MAIL_ADDRESS, WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { EmptyScreen, TopBanner } from "@calcom/ui";
import { EmptyScreen, Alert } from "@calcom/ui";
import { AlertTriangle } from "@calcom/ui/components/icon";
type LicenseRequiredProps = {
@ -37,9 +37,10 @@ const LicenseRequired = ({ children, as = "", ...rest }: LicenseRequiredProps) =
) : process.env.NODE_ENV === "development" ? (
/** We only show a warning in development mode, but allow the feature to be displayed for development/testing purposes */
<>
<TopBanner
text=""
actions={
<Alert
className="mb-4"
severity="warning"
title={
<>
{t("enterprise_license")}.{" "}
<Trans i18nKey="enterprise_license_development">
@ -52,7 +53,6 @@ const LicenseRequired = ({ children, as = "", ...rest }: LicenseRequiredProps) =
</Trans>
</>
}
variant="warning"
/>
{children}
</>

View File

@ -20,6 +20,7 @@ import {
CheckboxField,
} from "@calcom/ui";
import { UserPlus, X } from "@calcom/ui/components/icon";
import InfoBadge from "@calcom/web/components/ui/InfoBadge";
import { ComponentForField } from "./FormBuilderField";
import { propsTypes } from "./propsTypes";
@ -395,6 +396,7 @@ export const Components: Record<FieldType, Component> = {
}
}, [options, setValue, value]);
const { t } = useLocale();
return (
<div>
<div>
@ -418,17 +420,25 @@ export const Components: Record<FieldType, Component> = {
checked={value?.value === option.value}
/>
<span className="text-emphasis me-2 ms-2 text-sm">{option.label ?? ""}</span>
<span>
{option.value === "phone" && (
<InfoBadge content={t("number_in_international_format")} />
)}
</span>
</label>
);
})
) : (
// Show option itself as label because there is just one option
<>
<Label>
<Label className="flex">
{options[0].label}
{!readOnly && optionsInputs[options[0].value]?.required ? (
<span className="text-default mb-1 ml-1 text-sm font-medium">*</span>
) : null}
{options[0].value === "phone" && (
<InfoBadge content={t("number_in_international_format")} />
)}
</Label>
</>
)}

View File

@ -7,6 +7,7 @@ import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Label } from "@calcom/ui";
import { Info } from "@calcom/ui/components/icon";
import InfoBadge from "@calcom/web/components/ui/InfoBadge";
import { Components, isValidValueProp } from "./Components";
import { fieldTypesConfigMap } from "./fieldTypes";
@ -126,6 +127,8 @@ const WithLabel = ({
readOnly: boolean;
children: React.ReactNode;
}) => {
const { t } = useLocale();
return (
<div>
{/* multiemail doesnt show label initially. It is shown on clicking CTA */}
@ -133,11 +136,12 @@ const WithLabel = ({
{/* Component itself managing it's label should remove these checks */}
{field.type !== "boolean" && field.type !== "multiemail" && field.label && (
<div className="mb-2 flex items-center">
<Label className="!mb-0">
<Label className="!mb-0 flex">
<span>{field.label}</span>
<span className="text-emphasis -mb-1 ml-1 text-sm font-medium leading-none">
{!readOnly && field.required ? "*" : ""}
</span>
{field.type === "phone" && <InfoBadge content={t("number_in_international_format")} />}
</Label>
</div>
)}

View File

@ -67,7 +67,7 @@ export const BookingKPICards = () => {
return (
<>
<Grid numColsSm={2} numColsLg={4} className="gap-x-6 gap-y-6">
<Grid numColsSm={2} numColsLg={4} className="gap-x-4 gap-y-4">
{categories.map((item) => (
<KPICard
key={item.title}
@ -85,7 +85,7 @@ export const BookingKPICards = () => {
const LoadingKPICards = (props: { categories: { title: string; index: string }[] }) => {
const { categories } = props;
return (
<Grid numColsSm={2} numColsLg={4} className="gap-x-6 gap-y-6">
<Grid numColsSm={2} numColsLg={4} className="gap-x-4 gap-y-4">
{categories.map((item) => (
<CardInsights key={item.title}>
<SkeletonContainer className="flex w-full flex-col">

View File

@ -4,9 +4,5 @@ import type { LineChartProps } from "./tremor.types";
// Honestly this is a mess. Why are all chart libraries in existance horrible to theme
export const LineChart = (props: LineChartProps) => {
return (
<div className="dark:invert">
<ExternalLineChart {...props} />
</div>
);
return <ExternalLineChart {...props} />;
};

View File

@ -1,6 +1,7 @@
.custom-date > .tremor-DateRangePicker-root > .tremor-DateRangePicker-button {
box-shadow: none;
width: 100%;
background-color: transparent;
}
/* Media query for screens larger than 768px */
@ -10,23 +11,82 @@
}
}
.recharts-cartesian-grid-horizontal line{
@apply stroke-emphasis
}
.tremor-DateRangePicker-button button{
@apply !h-9 !max-h-9 border-default hover:border-emphasis
}
.tremor-DateRangePicker-calendarButton,
.tremor-DateRangePicker-dropdownButton {
@apply border-subtle bg-default focus-within:ring-emphasis hover:border-subtle dark:focus-within:ring-emphasis hover:bg-subtle text-sm leading-4 placeholder:text-sm placeholder:font-normal focus-within:ring-0;
}
.tremor-DateRangePicker-dropdownModal{
@apply divide-none
}
.tremor-DropdownItem-root{
@apply !h-9 !max-h-9 bg-default hover:bg-subtle text-default hover:text-emphasis
}
.tremor-DateRangePicker-calendarButtonText,
.tremor-DateRangePicker-dropdownButtonText {
@apply text-default;
}
.tremor-DateRangePicker-calendarModal,
.tremor-DateRangePicker-dropdownModal {
@apply dark:invert;
.tremor-DateRangePicker-calendarHeaderText{
@apply !text-default
}
.tremor-DateRangePicker-calendarHeader svg{
@apply text-default
}
.tremor-DateRangePicker-calendarHeader button{
@apply hover:bg-emphasis shadow-none focus:ring-0
}
.tremor-DateRangePicker-calendarHeader button:hover svg{
@apply text-emphasis
}
.tremor-DateRangePicker-calendarButtonIcon{
@apply text-default
}
.tremor-DateRangePicker-calendarModal,
.tremor-DateRangePicker-dropdownModal {
@apply bg-default border-subtle shadow-dropdown
}
.tremor-DateRangePicker-calendarBodyDate button{
@apply text-default hover:bg-emphasis
}
.tremor-DateRangePicker-calendarBodyDate button:disabled,
.tremor-DateRangePicker-calendarBodyDate button[disabled]{
@apply opacity-25
}
.tremor-DateRangePicker-calendarHeader button{
@apply border-default text-default
}
.tremor-DateRangePicker-calendarBodyDate .bg-gray-100{
@apply bg-subtle
}
.tremor-DateRangePicker-calendarBodyDate .bg-gray-500{
@apply !bg-brand-default text-inverted
}
.tremor-Card-root {
@apply p-5;
@apply p-5 bg-default;
}
.tremor-TableCell-root {

View File

@ -16,7 +16,7 @@ export const DateSelect = () => {
const startValue = startDate?.toDate() || null;
const endValue = endDate?.toDate() || null;
return (
<div className="custom-date w-full sm:w-auto">
<div className="custom-date max-w-96 w-full sm:w-auto">
<DateRangePicker
value={[startValue, endValue, range]}
defaultValue={[startValue, endValue, range]}
@ -63,7 +63,6 @@ export const DateSelect = () => {
minDate={currentDate.subtract(2, "year").toDate()}
maxDate={currentDate.toDate()}
color="gray"
className="h-[42px]"
/>
</div>
);

View File

@ -37,7 +37,7 @@ const ClearFilters = () => {
export const Filters = () => {
return (
<div className="mb-4 ml-auto mt-6 flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:justify-between">
<div className="ml-auto mt-6 flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:justify-between">
<div className="flex flex-col gap-2 sm:flex-row sm:flex-nowrap sm:justify-start">
<TeamAndSelfList />

View File

@ -6,84 +6,12 @@ import { Card } from "@calcom/ui";
export const tips = [
{
id: 1,
thumbnailUrl: "https://img.youtube.com/vi/60HJt8DOVNo/0.jpg",
mediaLink: "https://go.cal.com/dynamic-video",
title: "Dynamic booking links",
description: "Booking link that allows people to quickly schedule meetings.",
href: "https://cal.com/blog/cal-v-1-9",
},
{
id: 2,
thumbnailUrl: "https://img.youtube.com/vi/EAc46SPL6iA/0.jpg",
mediaLink: "https://go.cal.com/teams-video",
title: "How to set up Teams",
description: "Learn how to use round-robin and collective events.",
href: "https://cal.com/docs/enterprise-features/teams",
},
{
id: 3,
thumbnailUrl: "https://img.youtube.com/vi/c7ZKFuLy1fg/0.jpg",
mediaLink: "https://go.cal.com/routing-video",
title: "Routing Forms, Workflows",
description: "Ask screening questions of potential bookers to connect them with the right person",
href: "https://cal.com/blog/cal-v-1-8",
},
{
id: 4,
thumbnailUrl: "https://img.youtube.com/vi/zGr_s-fG84k/0.jpg",
mediaLink: "https://go.cal.com/confirmation-video",
title: "Requires Confirmation",
description: "Learn how to be in charge of your bookings",
href: "https://cal.com/resources/feature/opt-in",
},
{
id: 5,
thumbnailUrl: "https://img.youtube.com/vi/0v_nQtpxC_4/0.jpg",
mediaLink: "https://go.cal.com/payments-video",
title: "Accept Payments",
description: "Charge for your time with Cal.com's Stripe App",
href: "https://app.cal.com/apps/stripe",
},
{
id: 6,
thumbnailUrl: "https://img.youtube.com/vi/yGiZo1Ry5-8/0.jpg",
mediaLink: "https://go.cal.com/recurring-video",
title: "Recurring Bookings",
description: "Learn how to create a recurring schedule",
href: "https://go.cal.com/recurring-video",
},
{
id: 7,
thumbnailUrl: "https://img.youtube.com/vi/UVXgo12cY4g/0.jpg",
mediaLink: "https://go.cal.com/routing-forms",
title: "Routing Forms",
description: "Ask questions and route to the correct person",
href: "https://go.cal.com/routing-forms",
},
{
id: 8,
thumbnailUrl: "https://img.youtube.com/vi/piKlAiibAFo/0.jpg",
mediaLink: "https://go.cal.com/workflows",
title: "Automate Workflows",
description: "Make time work for you and automate tasks",
href: "https://go.cal.com/workflows",
},
{
id: 9,
thumbnailUrl: "https://img.youtube.com/vi/93iOmzHieCU/0.jpg",
mediaLink: "https://go.cal.com/round-robin",
title: "Round-Robin",
description: "Create advanced group meetings with round-robin",
href: "https://go.cal.com/round-robin",
},
{
id: 10,
thumbnailUrl: "https://img.youtube.com/vi/jvaBafzVUQc/0.jpg",
mediaLink: "https://go.cal.com/video",
title: "Cal Video",
description: "Free video conferencing with recording",
href: "https://go.cal.com/video",
id: 12,
thumbnailUrl: "https://cal.com/og-image-cal-ai.jpg",
mediaLink: "https://go.cal.com/cal-ai",
title: "Cal.ai",
description: "Your personal AI scheduling assistant",
href: "https://go.cal.com/cal-ai",
},
{
id: 11,
@ -94,13 +22,84 @@ export const tips = [
href: "https://go.cal.com/insights",
},
{
id: 12,
thumbnailUrl:
"https://cal.com/og-image-cal-ai.jpg",
mediaLink: "https://go.cal.com/cal-ai",
title: "Cal.ai",
description: "Your personal AI scheduling assistant",
href: "https://go.cal.com/cal-ai",
id: 10,
thumbnailUrl: "https://img.youtube.com/vi/jvaBafzVUQc/0.jpg",
mediaLink: "https://go.cal.com/video",
title: "Cal Video",
description: "Free video conferencing with recording",
href: "https://go.cal.com/video",
},
{
id: 9,
thumbnailUrl: "https://img.youtube.com/vi/93iOmzHieCU/0.jpg",
mediaLink: "https://go.cal.com/round-robin",
title: "Round-Robin",
description: "Create advanced group meetings with round-robin",
href: "https://go.cal.com/round-robin",
},
{
id: 8,
thumbnailUrl: "https://img.youtube.com/vi/piKlAiibAFo/0.jpg",
mediaLink: "https://go.cal.com/workflows",
title: "Automate Workflows",
description: "Make time work for you and automate tasks",
href: "https://go.cal.com/workflows",
},
{
id: 7,
thumbnailUrl: "https://img.youtube.com/vi/UVXgo12cY4g/0.jpg",
mediaLink: "https://go.cal.com/routing-forms",
title: "Routing Forms",
description: "Ask questions and route to the correct person",
href: "https://go.cal.com/routing-forms",
},
{
id: 6,
thumbnailUrl: "https://img.youtube.com/vi/yGiZo1Ry5-8/0.jpg",
mediaLink: "https://go.cal.com/recurring-video",
title: "Recurring Bookings",
description: "Learn how to create a recurring schedule",
href: "https://go.cal.com/recurring-video",
},
{
id: 5,
thumbnailUrl: "https://img.youtube.com/vi/0v_nQtpxC_4/0.jpg",
mediaLink: "https://go.cal.com/payments-video",
title: "Accept Payments",
description: "Charge for your time with Cal.com's Stripe App",
href: "https://app.cal.com/apps/stripe",
},
{
id: 4,
thumbnailUrl: "https://img.youtube.com/vi/zGr_s-fG84k/0.jpg",
mediaLink: "https://go.cal.com/confirmation-video",
title: "Requires Confirmation",
description: "Learn how to be in charge of your bookings",
href: "https://cal.com/resources/feature/opt-in",
},
{
id: 3,
thumbnailUrl: "https://img.youtube.com/vi/c7ZKFuLy1fg/0.jpg",
mediaLink: "https://go.cal.com/routing-video",
title: "Routing Forms, Workflows",
description: "Ask screening questions of potential bookers to connect them with the right person",
href: "https://cal.com/blog/cal-v-1-8",
},
{
id: 2,
thumbnailUrl: "https://img.youtube.com/vi/EAc46SPL6iA/0.jpg",
mediaLink: "https://go.cal.com/teams-video",
title: "How to set up Teams",
description: "Learn how to use round-robin and collective events.",
href: "https://cal.com/docs/enterprise-features/teams",
},
{
id: 1,
thumbnailUrl: "https://img.youtube.com/vi/60HJt8DOVNo/0.jpg",
mediaLink: "https://go.cal.com/dynamic-video",
title: "Dynamic booking links",
description: "Booking link that allows people to quickly schedule meetings.",
href: "https://cal.com/blog/cal-v-1-9",
},
];

View File

@ -5,6 +5,7 @@ const RENDER_URL = process.env.RENDER_EXTERNAL_URL ? `https://${process.env.REND
export const CALCOM_ENV = process.env.CALCOM_ENV || process.env.NODE_ENV;
export const IS_PRODUCTION = CALCOM_ENV === "production";
export const IS_PRODUCTION_BUILD = process.env.NODE_ENV === "production";
const IS_DEV = CALCOM_ENV === "development";
/** https://app.cal.com */
export const WEBAPP_URL =
@ -17,7 +18,7 @@ export const WEBAPP_URL =
// OAuth needs to have HTTPS(which is not generally setup locally) and a valid tld(*.local isn't a valid tld)
// So for development purpose, we would stick to localhost only
export const WEBAPP_URL_FOR_OAUTH = IS_PRODUCTION ? WEBAPP_URL : "http://localhost:3000";
export const WEBAPP_URL_FOR_OAUTH = IS_PRODUCTION || IS_DEV ? WEBAPP_URL : "http://localhost:3000";
/** @deprecated use `WEBAPP_URL` */
export const BASE_URL = WEBAPP_URL;

View File

@ -8,7 +8,9 @@ export const useCompatSearchParams = () => {
Object.getOwnPropertyNames(params).forEach((key) => {
searchParams.delete(key);
const param = params[key];
// Though useParams is supposed to return a string/string[] as the key's value but it is found to return undefined as well.
// Maybe it happens for pages dir when using optional catch-all routes.
const param = params[key] || "";
const paramArr = typeof param === "string" ? param.split("/") : param;
paramArr.forEach((p) => {

View File

@ -61,6 +61,8 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
const userMetadata = handleUserMetadata({ ctx, input });
const data: Prisma.UserUpdateInput = {
...input,
// DO NOT OVERWRITE AVATAR.
avatar: undefined,
metadata: userMetadata,
};
@ -138,14 +140,21 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
// when the email changes, the user needs to sign in again.
signOutUser = true;
}
// don't do anything if avatar is undefined.
if (typeof input.avatar !== "undefined") {
data.avatarUrl = input.avatar
? await uploadAvatar({
avatar: await resizeBase64Image(input.avatar),
userId: user.id,
})
: null;
// if defined AND a base 64 string, upload and set the avatar URL
if (input.avatar && input.avatar.startsWith("data:image/png;base64,")) {
const avatar = await resizeBase64Image(input.avatar);
data.avatarUrl = await uploadAvatar({
avatar,
userId: user.id,
});
// as this is still used in the backwards compatible endpoint, we also write it here
// to ensure no data loss.
data.avatar = avatar;
}
// Unset avatar url if avatar is empty string.
if ("" === input.avatar) {
data.avatarUrl = null;
data.avatar = null;
}
const updatedUser = await prisma.user.update({

View File

@ -1,28 +1,24 @@
import { randomBytes } from "crypto";
import { sendTeamInviteEmail } from "@calcom/emails";
import { updateQuantitySubscriptionFromStripe } from "@calcom/features/ee/teams/lib/payments";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import { getTranslation } from "@calcom/lib/server/i18n";
import { prisma } from "@calcom/prisma";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { isEmail } from "../util";
import type { TInviteMemberInputSchema } from "./inviteMember.schema";
import {
checkPermissions,
getTeamOrThrow,
getEmailsToInvite,
getUserToInviteOrThrowIfExists,
checkInputEmailIsValid,
getOrgConnectionInfo,
createNewUserConnectToOrgIfExists,
throwIfInviteIsToOrgAndUserExists,
createProvisionalMembership,
getIsOrgVerified,
sendVerificationEmail,
createAndAutoJoinIfInOrg,
getUsersToInvite,
createNewUsersConnectToOrgIfExists,
createProvisionalMemberships,
groupUsersByJoinability,
sendTeamInviteEmails,
sendEmails,
} from "./utils";
type InviteMemberOptions = {
@ -33,10 +29,10 @@ type InviteMemberOptions = {
};
export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) => {
const translation = await getTranslation(input.language ?? "en", "common");
await checkRateLimitAndThrowError({
identifier: `invitedBy:${ctx.user.id}`,
});
await checkPermissions({
userId: ctx.user.id,
teamId:
@ -46,100 +42,81 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) =
const team = await getTeamOrThrow(input.teamId, input.isOrg);
const { autoAcceptEmailDomain, orgVerified } = getIsOrgVerified(input.isOrg, team);
const translation = await getTranslation(input.language ?? "en", "common");
const emailsToInvite = await getEmailsToInvite(input.usernameOrEmail);
for (const usernameOrEmail of emailsToInvite) {
const connectionInfo = getOrgConnectionInfo({
orgVerified,
orgAutoAcceptDomain: autoAcceptEmailDomain,
usersEmail: usernameOrEmail,
team,
isOrg: input.isOrg,
});
const invitee = await getUserToInviteOrThrowIfExists({
usernameOrEmail,
teamId: input.teamId,
isOrg: input.isOrg,
});
if (!invitee) {
checkInputEmailIsValid(usernameOrEmail);
// valid email given, create User and add to team
await createNewUserConnectToOrgIfExists({
usernameOrEmail,
input,
connectionInfo,
autoAcceptEmailDomain,
parentId: team.parentId,
});
await sendVerificationEmail({ usernameOrEmail, team, translation, ctx, input, connectionInfo });
} else {
throwIfInviteIsToOrgAndUserExists(invitee, team, input.isOrg);
const shouldAutoJoinOrgTeam = await createAndAutoJoinIfInOrg({
invitee,
role: input.role,
const orgConnectInfoByEmail = emailsToInvite.reduce((acc, email) => {
return {
...acc,
[email]: getOrgConnectionInfo({
orgVerified,
orgAutoAcceptDomain: autoAcceptEmailDomain,
usersEmail: email,
team,
});
if (shouldAutoJoinOrgTeam.autoJoined) {
// Continue here because if this is true we dont need to send an email to the user
// we also dont need to update stripe as thats handled on an ORG level and not a team level.
continue;
}
// create provisional membership
await createProvisionalMembership({
isOrg: input.isOrg,
}),
};
}, {} as Record<string, ReturnType<typeof getOrgConnectionInfo>>);
const existingUsersWithMembersips = await getUsersToInvite({
usernameOrEmail: emailsToInvite,
isInvitedToOrg: input.isOrg,
team,
});
const existingUsersEmails = existingUsersWithMembersips.map((user) => user.email);
const newUsersEmails = emailsToInvite.filter((email) => !existingUsersEmails.includes(email));
// deal with users to create and invite to team/org
if (newUsersEmails.length) {
await createNewUsersConnectToOrgIfExists({
usernamesOrEmails: newUsersEmails,
input,
connectionInfoMap: orgConnectInfoByEmail,
autoAcceptEmailDomain,
parentId: team.parentId,
});
const sendVerifEmailsPromises = newUsersEmails.map((usernameOrEmail) => {
return sendVerificationEmail({
usernameOrEmail,
team,
translation,
ctx,
input,
invitee,
connectionInfo: orgConnectInfoByEmail[usernameOrEmail],
});
});
sendEmails(sendVerifEmailsPromises);
}
let sendTo = usernameOrEmail;
if (!isEmail(usernameOrEmail)) {
sendTo = invitee.email;
}
// inform user of membership by email
if (ctx?.user?.name && team?.name) {
const inviteTeamOptions = {
joinLink: `${WEBAPP_URL}/auth/login?callbackUrl=/settings/teams`,
isCalcomMember: true,
};
/**
* Here we want to redirect to a different place if onboarding has been completed or not. This prevents the flash of going to teams -> Then to onboarding - also show a different email template.
* This only changes if the user is a CAL user and has not completed onboarding and has no password
*/
if (!invitee.completedOnboarding && !invitee.password && invitee.identityProvider === "CAL") {
const token = randomBytes(32).toString("hex");
await prisma.verificationToken.create({
data: {
identifier: usernameOrEmail,
token,
expires: new Date(new Date().setHours(168)), // +1 week
team: {
connect: {
id: team.id,
},
},
},
});
// deal with existing users invited to join the team/org
if (existingUsersWithMembersips.length) {
const [autoJoinUsers, regularUsers] = groupUsersByJoinability({
existingUsersWithMembersips,
team,
});
inviteTeamOptions.joinLink = `${WEBAPP_URL}/signup?token=${token}&callbackUrl=/getting-started`;
inviteTeamOptions.isCalcomMember = false;
}
// invited users can autojoin, create their memberships in org
if (autoJoinUsers.length) {
await prisma.membership.createMany({
data: autoJoinUsers.map((userToAutoJoin) => ({
userId: userToAutoJoin.id,
teamId: team.id,
accepted: true,
role: input.role,
})),
});
}
await sendTeamInviteEmail({
language: translation,
from: ctx.user.name,
to: sendTo,
teamName: team.name,
...inviteTeamOptions,
isOrg: input.isOrg,
});
}
// invited users cannot autojoin, create provisional memberships and send email
if (regularUsers.length) {
await createProvisionalMemberships({
input,
invitees: regularUsers,
});
await sendTeamInviteEmails({
currentUserName: ctx?.user?.name,
currentUserTeamName: team?.name,
existingUsersWithMembersips: regularUsers,
language: translation,
isOrg: input.isOrg,
teamId: team.id,
});
}
}

View File

@ -2,14 +2,39 @@ import { z } from "zod";
import { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
export const ZInviteMemberInputSchema = z.object({
teamId: z.number(),
usernameOrEmail: z.union([z.string(), z.array(z.string())]).transform((usernameOrEmail) => {
if (typeof usernameOrEmail === "string") {
return usernameOrEmail.trim().toLowerCase();
}
return usernameOrEmail.map((item) => item.trim().toLowerCase());
}),
usernameOrEmail: z
.union([z.string(), z.array(z.string())])
.transform((usernameOrEmail) => {
if (typeof usernameOrEmail === "string") {
return usernameOrEmail.trim().toLowerCase();
}
return usernameOrEmail.map((item) => item.trim().toLowerCase());
})
.refine((value) => {
let invalidEmail;
if (Array.isArray(value)) {
if (value.length > 100) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `You are limited to inviting a maximum of 100 users at once.`,
});
}
invalidEmail = value.find((email) => !z.string().email().safeParse(email).success);
} else {
invalidEmail = !z.string().email().safeParse(value).success ? value : null;
}
if (invalidEmail) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Invite failed because '${invalidEmail}' is not a valid email address`,
});
}
return true;
}),
role: z.nativeEnum(MembershipRole),
language: z.string(),
isOrg: z.boolean().default(false),

View File

@ -2,20 +2,19 @@ import { describe, it, vi, expect } from "vitest";
import { isTeamAdmin } from "@calcom/lib/server/queries";
import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations";
import type { User } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
import type { TeamWithParent } from "./types";
import type { Invitee, UserWithMembership } from "./utils";
import {
checkInputEmailIsValid,
checkPermissions,
getEmailsToInvite,
getIsOrgVerified,
getOrgConnectionInfo,
throwIfInviteIsToOrgAndUserExists,
createAndAutoJoinIfInOrg,
validateInviteeEligibility,
shouldAutoJoinIfInOrg,
} from "./utils";
vi.mock("@calcom/lib/server/queries", () => {
@ -60,46 +59,29 @@ const mockedTeam: TeamWithParent = {
parentId: null,
parent: null,
isPrivate: false,
logoUrl: "",
};
const mockUser: User = {
const mockUser: Invitee = {
id: 4,
username: "pro",
name: "Pro Example",
email: "pro@example.com",
emailVerified: new Date(),
password: "",
bio: null,
avatar: null,
timeZone: "Europe/London",
weekStart: "Sunday",
startTime: 0,
endTime: 1440,
bufferTime: 0,
hideBranding: false,
theme: null,
createdDate: new Date(),
trialEndsAt: null,
defaultScheduleId: null,
completedOnboarding: true,
locale: "en",
timeFormat: 12,
twoFactorSecret: null,
twoFactorEnabled: false,
identityProvider: "CAL",
identityProviderId: null,
invitedTo: null,
brandColor: "#292929",
darkBrandColor: "#fafafa",
away: false,
allowDynamicBooking: true,
metadata: null,
verified: false,
role: "USER",
disableImpersonation: false,
organizationId: null,
};
const userInTeamAccepted: UserWithMembership = {
...mockUser,
teams: [{ teamId: mockedTeam.id, accepted: true, userId: mockUser.id }],
};
const userInTeamNotAccepted: UserWithMembership = {
...mockUser,
teams: [{ teamId: mockedTeam.id, accepted: false, userId: mockUser.id }],
};
describe("Invite Member Utils", () => {
describe("checkPermissions", () => {
it("It should throw an error if the user is not an admin of the ORG", async () => {
@ -134,20 +116,7 @@ describe("Invite Member Utils", () => {
expect(result).toEqual(["test1@example.com", "test2@example.com"]);
});
});
describe("checkInputEmailIsValid", () => {
it("should throw a TRPCError with code BAD_REQUEST if the email is invalid", () => {
const invalidEmail = "invalid-email";
expect(() => checkInputEmailIsValid(invalidEmail)).toThrow(TRPCError);
expect(() => checkInputEmailIsValid(invalidEmail)).toThrowError(
"Invite failed because invalid-email is not a valid email address"
);
});
it("should not throw an error if the email is valid", () => {
const validEmail = "valid-email@example.com";
expect(() => checkInputEmailIsValid(validEmail)).not.toThrow();
});
});
describe("getOrgConnectionInfo", () => {
const orgAutoAcceptDomain = "example.com";
const usersEmail = "user@example.com";
@ -270,8 +239,8 @@ describe("Invite Member Utils", () => {
});
});
describe("throwIfInviteIsToOrgAndUserExists", () => {
const invitee: User = {
describe("validateInviteeEligibility: Check if user can be invited to the team/org", () => {
const invitee: Invitee = {
...mockUser,
id: 1,
username: "testuser",
@ -280,8 +249,8 @@ describe("Invite Member Utils", () => {
};
const isOrg = false;
it("should not throw when inviting an existing user to the same organization", () => {
const inviteeWithOrg: User = {
it("should not throw when inviting to an organization's team an existing org user", () => {
const inviteeWithOrg: Invitee = {
...invitee,
organizationId: 2,
};
@ -289,10 +258,36 @@ describe("Invite Member Utils", () => {
...mockedTeam,
parentId: 2,
};
expect(() => throwIfInviteIsToOrgAndUserExists(inviteeWithOrg, teamWithOrg, isOrg)).not.toThrow();
expect(() => validateInviteeEligibility(inviteeWithOrg, teamWithOrg, isOrg)).not.toThrow();
});
it("should throw a TRPCError when inviting a user who is already a member of the org", () => {
const inviteeWithOrg: Invitee = {
...invitee,
organizationId: 1,
};
const teamWithOrg = {
...mockedTeam,
id: 1,
};
expect(() => validateInviteeEligibility(inviteeWithOrg, teamWithOrg, isOrg)).toThrow(TRPCError);
});
it("should throw a TRPCError when inviting a user who is already a member of the team", () => {
const inviteeWithOrg: UserWithMembership = {
...invitee,
organizationId: null,
teams: [{ teamId: 1, accepted: true, userId: invitee.id }],
};
const teamWithOrg = {
...mockedTeam,
id: 1,
};
expect(() => validateInviteeEligibility(inviteeWithOrg, teamWithOrg, isOrg)).toThrow(TRPCError);
});
it("should throw a TRPCError with code FORBIDDEN if the invitee is already a member of another organization", () => {
const inviteeWithOrg: User = {
const inviteeWithOrg: Invitee = {
...invitee,
organizationId: 2,
};
@ -300,36 +295,48 @@ describe("Invite Member Utils", () => {
...mockedTeam,
parentId: 3,
};
expect(() => throwIfInviteIsToOrgAndUserExists(inviteeWithOrg, teamWithOrg, isOrg)).toThrow(TRPCError);
expect(() => validateInviteeEligibility(inviteeWithOrg, teamWithOrg, isOrg)).toThrow(TRPCError);
});
it("should throw a TRPCError with code FORBIDDEN if the invitee already exists in Cal.com and is being invited to an organization", () => {
const isOrg = true;
expect(() => throwIfInviteIsToOrgAndUserExists(invitee, mockedTeam, isOrg)).toThrow(TRPCError);
expect(() => validateInviteeEligibility(invitee, mockedTeam, isOrg)).toThrow(TRPCError);
});
it("should not throw an error if the invitee does not already belong to another organization and is not being invited to an organization", () => {
expect(() => throwIfInviteIsToOrgAndUserExists(invitee, mockedTeam, isOrg)).not.toThrow();
expect(() => validateInviteeEligibility(invitee, mockedTeam, isOrg)).not.toThrow();
});
});
describe("createAndAutoJoinIfInOrg", () => {
describe("shouldAutoJoinIfInOrg", () => {
it("should return autoJoined: false if the user is not in the same organization as the team", async () => {
const result = await createAndAutoJoinIfInOrg({
const result = await shouldAutoJoinIfInOrg({
team: mockedTeam,
role: MembershipRole.ADMIN,
invitee: mockUser,
invitee: userInTeamAccepted,
});
expect(result).toEqual({ autoJoined: false });
expect(result).toEqual(false);
});
it("should return autoJoined: false if the team does not have a parent organization", async () => {
const result = await createAndAutoJoinIfInOrg({
const result = await shouldAutoJoinIfInOrg({
team: { ...mockedTeam, parentId: null },
role: MembershipRole.ADMIN,
invitee: mockUser,
invitee: userInTeamAccepted,
});
expect(result).toEqual({ autoJoined: false });
expect(result).toEqual(false);
});
it("should return `autoJoined: false` if team has parent organization and invitee has not accepted membership to organization", async () => {
const result = await shouldAutoJoinIfInOrg({
team: { ...mockedTeam, parentId: mockedTeam.id },
invitee: { ...userInTeamNotAccepted, organizationId: mockedTeam.id },
});
expect(result).toEqual(false);
});
it("should return `autoJoined: true` if team has parent organization and invitee has accepted membership to organization", async () => {
const result = await shouldAutoJoinIfInOrg({
team: { ...mockedTeam, parentId: mockedTeam.id },
invitee: { ...userInTeamAccepted, organizationId: mockedTeam.id },
});
expect(result).toEqual(true);
});
// TODO: Add test for when the user is already a member of the organization - need to mock prisma response value
});
});

View File

@ -3,11 +3,12 @@ import type { TFunction } from "next-i18next";
import { sendTeamInviteEmail, sendOrganizationAutoJoinEmail } from "@calcom/emails";
import { WEBAPP_URL } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { isTeamAdmin } from "@calcom/lib/server/queries";
import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations";
import slugify from "@calcom/lib/slugify";
import { prisma } from "@calcom/prisma";
import type { Team } from "@calcom/prisma/client";
import type { Membership, Team } from "@calcom/prisma/client";
import { Prisma, type User } from "@calcom/prisma/client";
import type { MembershipRole } from "@calcom/prisma/enums";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
@ -18,6 +19,15 @@ import type { TrpcSessionUser } from "../../../../trpc";
import { isEmail } from "../util";
import type { InviteMemberOptions, TeamWithParent } from "./types";
export type Invitee = Pick<
User,
"id" | "email" | "organizationId" | "username" | "password" | "identityProvider" | "completedOnboarding"
>;
export type UserWithMembership = Invitee & {
teams?: Pick<Membership, "userId" | "teamId" | "accepted">[];
};
export async function checkPermissions({
userId,
teamId,
@ -53,7 +63,9 @@ export async function getTeamOrThrow(teamId: number, isOrg?: boolean) {
}
export async function getEmailsToInvite(usernameOrEmail: string | string[]) {
const emailsToInvite = Array.isArray(usernameOrEmail) ? usernameOrEmail : [usernameOrEmail];
const emailsToInvite = Array.isArray(usernameOrEmail)
? Array.from(new Set(usernameOrEmail))
: [usernameOrEmail];
if (emailsToInvite.length === 0) {
throw new TRPCError({
@ -65,43 +77,102 @@ export async function getEmailsToInvite(usernameOrEmail: string | string[]) {
return emailsToInvite;
}
export async function getUserToInviteOrThrowIfExists({
usernameOrEmail,
teamId,
isOrg,
}: {
usernameOrEmail: string;
teamId: number;
isOrg?: boolean;
}) {
// Check if user exists in ORG or exists all together
const orgWhere = isOrg && {
organizationId: teamId,
};
const invitee = await prisma.user.findFirst({
where: {
OR: [{ username: usernameOrEmail, ...orgWhere }, { email: usernameOrEmail }],
},
});
// We throw on error cause we can't have two users in the same org with the same username
if (isOrg && invitee) {
export function validateInviteeEligibility(
invitee: UserWithMembership,
team: TeamWithParent,
isOrg: boolean
) {
const alreadyInvited = invitee.teams?.find(({ teamId: membershipTeamId }) => team.id === membershipTeamId);
if (alreadyInvited) {
throw new TRPCError({
code: "NOT_FOUND",
message: `Email ${usernameOrEmail} already exists, you can't invite existing users.`,
code: "BAD_REQUEST",
message: `${invitee.email} has already been invited.`,
});
}
return invitee;
const orgMembership = invitee.teams?.find((membersip) => membersip.teamId === team.parentId);
// invitee is invited to the org's team and is already part of the organization
if (invitee.organizationId && team.parentId && invitee.organizationId === team.parentId) {
return;
}
// user invited to join a team inside an org, but has not accepted invite to org yet
if (team.parentId && orgMembership && !orgMembership.accepted) {
throw new TRPCError({
code: "FORBIDDEN",
message: `User ${invitee.username} needs to accept the invitation to join your organization first.`,
});
}
// user is invited to join a team which is not in his organization
if (invitee.organizationId && invitee.organizationId !== team.parentId) {
throw new TRPCError({
code: "FORBIDDEN",
message: `User ${invitee.username} is already a member of another organization.`,
});
}
if (invitee && isOrg) {
throw new TRPCError({
code: "FORBIDDEN",
message: `You cannot add a user that already exists in Cal.com to an organization. If they wish to join via this email address, they must update their email address in their profile to that of your organization.`,
});
}
if (team.parentId && invitee) {
throw new TRPCError({
code: "FORBIDDEN",
message: `You cannot add a user that already exists in Cal.com to an organization's team. If they wish to join via this email address, they must update their email address in their profile to that of your organization.`,
});
}
}
export function checkInputEmailIsValid(email: string) {
if (!isEmail(email))
throw new TRPCError({
code: "BAD_REQUEST",
message: `Invite failed because ${email} is not a valid email address`,
});
export async function getUsersToInvite({
usernameOrEmail,
isInvitedToOrg,
team,
}: {
usernameOrEmail: string[];
isInvitedToOrg: boolean;
team: TeamWithParent;
}): Promise<UserWithMembership[]> {
const orgWhere = isInvitedToOrg && {
organizationId: team.id,
};
const memberships = [];
if (isInvitedToOrg) {
memberships.push({ teamId: team.id });
} else {
memberships.push({ teamId: team.id });
team.parentId && memberships.push({ teamId: team.parentId });
}
const invitees: UserWithMembership[] = await prisma.user.findMany({
where: {
OR: [{ username: { in: usernameOrEmail }, ...orgWhere }, { email: { in: usernameOrEmail } }],
},
select: {
id: true,
email: true,
organizationId: true,
username: true,
password: true,
completedOnboarding: true,
identityProvider: true,
teams: {
select: { teamId: true, userId: true, accepted: true },
where: {
OR: memberships,
},
},
},
});
// Check if the users found in the database can be invited to join the team/org
invitees.forEach((invitee) => {
validateInviteeEligibility(invitee, team, isInvitedToOrg);
});
return invitees;
}
export function getOrgConnectionInfo({
@ -133,84 +204,92 @@ export function getOrgConnectionInfo({
return { orgId, autoAccept };
}
export async function createNewUserConnectToOrgIfExists({
usernameOrEmail,
export async function createNewUsersConnectToOrgIfExists({
usernamesOrEmails,
input,
parentId,
autoAcceptEmailDomain,
connectionInfo,
connectionInfoMap,
}: {
usernameOrEmail: string;
usernamesOrEmails: string[];
input: InviteMemberOptions["input"];
parentId?: number | null;
autoAcceptEmailDomain?: string;
connectionInfo: ReturnType<typeof getOrgConnectionInfo>;
connectionInfoMap: Record<string, ReturnType<typeof getOrgConnectionInfo>>;
}) {
const { orgId, autoAccept } = connectionInfo;
await prisma.$transaction(async (tx) => {
for (let index = 0; index < usernamesOrEmails.length; index++) {
const usernameOrEmail = usernamesOrEmails[index];
const { orgId, autoAccept } = connectionInfoMap[usernameOrEmail];
const [emailUser, emailDomain] = usernameOrEmail.split("@");
const username =
emailDomain === autoAcceptEmailDomain
? slugify(emailUser)
: slugify(`${emailUser}-${emailDomain.split(".")[0]}`);
const [emailUser, emailDomain] = usernameOrEmail.split("@");
const username =
emailDomain === autoAcceptEmailDomain
? slugify(emailUser)
: slugify(`${emailUser}-${emailDomain.split(".")[0]}`);
const createdUser = await prisma.user.create({
data: {
username,
email: usernameOrEmail,
verified: true,
invitedTo: input.teamId,
organizationId: orgId || null, // If the user is invited to a child team, they are automatically added to the parent org
teams: {
create: {
teamId: input.teamId,
role: input.role as MembershipRole,
accepted: autoAccept, // If the user is invited to a child team, they are automatically accepted
const createdUser = await tx.user.create({
data: {
username,
email: usernameOrEmail,
verified: true,
invitedTo: input.teamId,
organizationId: orgId || null, // If the user is invited to a child team, they are automatically added to the parent org
teams: {
create: {
teamId: input.teamId,
role: input.role as MembershipRole,
accepted: autoAccept, // If the user is invited to a child team, they are automatically accepted
},
},
},
},
},
});
});
// We also need to create the membership in the parent org if it exists
if (parentId) {
await prisma.membership.create({
data: {
teamId: parentId,
userId: createdUser.id,
role: input.role as MembershipRole,
accepted: autoAccept,
},
});
}
// We also need to create the membership in the parent org if it exists
if (parentId) {
await tx.membership.create({
data: {
teamId: parentId,
userId: createdUser.id,
role: input.role as MembershipRole,
accepted: autoAccept,
},
});
}
}
});
}
export async function createProvisionalMembership({
export async function createProvisionalMemberships({
input,
invitee,
invitees,
parentId,
}: {
input: InviteMemberOptions["input"];
invitee: User;
invitees: UserWithMembership[];
parentId?: number;
}) {
try {
await prisma.membership.create({
data: {
teamId: input.teamId,
userId: invitee.id,
role: input.role as MembershipRole,
},
});
// Create the membership in the parent also if it exists
if (parentId) {
await prisma.membership.create({
data: {
teamId: parentId,
await prisma.membership.createMany({
data: invitees.flatMap((invitee) => {
const data = [];
// membership for the team
data.push({
teamId: input.teamId,
userId: invitee.id,
role: input.role as MembershipRole,
},
});
}
});
// membership for the org
if (parentId) {
data.push({
teamId: parentId,
userId: invitee.id,
role: input.role as MembershipRole,
});
}
return data;
}),
});
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
// Don't throw an error if the user is already a member of the team when inviting multiple users
@ -219,9 +298,13 @@ export async function createProvisionalMembership({
code: "FORBIDDEN",
message: "This user is a member of this team / has a pending invitation.",
});
} else {
console.log(`User ${invitee.id} is already a member of this team.`);
} else if (Array.isArray(input.usernameOrEmail) && e.code === "P2002") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Trying to invite users already members of this team / have pending invitations",
});
}
logger.error("Failed to create provisional memberships", input.teamId);
} else throw e;
}
}
@ -282,26 +365,6 @@ export async function sendVerificationEmail({
}
}
export function throwIfInviteIsToOrgAndUserExists(invitee: User, team: TeamWithParent, isOrg: boolean) {
if (invitee.organizationId && invitee.organizationId === team.parentId) {
return;
}
if (invitee.organizationId && invitee.organizationId !== team.parentId) {
throw new TRPCError({
code: "FORBIDDEN",
message: `User ${invitee.username} is already a member of another organization.`,
});
}
if ((invitee && isOrg) || (team.parentId && invitee)) {
throw new TRPCError({
code: "FORBIDDEN",
message: `You cannot add a user that already exists in Cal.com to an organization. If they wish to join via this email address, they must update their email address in their profile to that of your organization.`,
});
}
}
export function getIsOrgVerified(
isOrg: boolean,
team: Team & {
@ -331,52 +394,125 @@ export function getIsOrgVerified(
} as { isInOrgScope: false; orgVerified: never; autoAcceptEmailDomain: never };
}
export async function createAndAutoJoinIfInOrg({
export function shouldAutoJoinIfInOrg({
team,
role,
invitee,
}: {
team: TeamWithParent;
invitee: User;
role: MembershipRole;
invitee: UserWithMembership;
}) {
// Not a member of the org
if (invitee.organizationId && invitee.organizationId !== team.parentId) {
return {
autoJoined: false,
};
return false;
}
// team is an Org
if (!team.parentId) {
return {
autoJoined: false,
};
return false;
}
const orgMembership = await prisma.membership.findFirst({
where: {
userId: invitee.id,
teamId: team.parentId,
},
});
const orgMembership = invitee.teams?.find((membership) => membership.teamId === team.parentId);
if (!orgMembership?.accepted) {
return {
autoJoined: false,
};
return false;
}
// Since we early return if the user is not a member of the org. Or the team they are being invited to is an org (not having a parentID)
// We create the membership in the child team
await prisma.membership.create({
data: {
userId: invitee.id,
teamId: team.id,
accepted: true,
role: role,
},
return true;
}
// split invited users between ones that can autojoin and the others who cannot autojoin
export const groupUsersByJoinability = ({
existingUsersWithMembersips,
team,
}: {
team: TeamWithParent;
existingUsersWithMembersips: UserWithMembership[];
}) => {
const usersToAutoJoin = [];
const regularUsers = [];
for (let index = 0; index < existingUsersWithMembersips.length; index++) {
const existingUserWithMembersips = existingUsersWithMembersips[index];
const canAutojoin = shouldAutoJoinIfInOrg({
invitee: existingUserWithMembersips,
team,
});
canAutojoin
? usersToAutoJoin.push(existingUserWithMembersips)
: regularUsers.push(existingUserWithMembersips);
}
return [usersToAutoJoin, regularUsers];
};
export const sendEmails = async (emailPromises: Promise<void>[]) => {
const sentEmails = await Promise.allSettled(emailPromises);
sentEmails.forEach((sentEmail) => {
if (sentEmail.status === "rejected") {
logger.error("Could not send email to user");
}
});
};
export const sendTeamInviteEmails = async ({
existingUsersWithMembersips,
language,
currentUserTeamName,
currentUserName,
isOrg,
teamId,
}: {
language: TFunction;
existingUsersWithMembersips: UserWithMembership[];
currentUserTeamName?: string;
currentUserName?: string | null;
isOrg: boolean;
teamId: number;
}) => {
const sendEmailsPromises = existingUsersWithMembersips.map(async (user) => {
let sendTo = user.email;
if (!isEmail(user.email)) {
sendTo = user.email;
}
// inform user of membership by email
if (currentUserName && currentUserTeamName) {
const inviteTeamOptions = {
joinLink: `${WEBAPP_URL}/auth/login?callbackUrl=/settings/teams`,
isCalcomMember: true,
};
/**
* Here we want to redirect to a different place if onboarding has been completed or not. This prevents the flash of going to teams -> Then to onboarding - also show a different email template.
* This only changes if the user is a CAL user and has not completed onboarding and has no password
*/
if (!user.completedOnboarding && !user.password && user.identityProvider === "CAL") {
const token = randomBytes(32).toString("hex");
await prisma.verificationToken.create({
data: {
identifier: user.email,
token,
expires: new Date(new Date().setHours(168)), // +1 week
team: {
connect: {
id: teamId,
},
},
},
});
inviteTeamOptions.joinLink = `${WEBAPP_URL}/signup?token=${token}&callbackUrl=/getting-started`;
inviteTeamOptions.isCalcomMember = false;
}
return sendTeamInviteEmail({
language,
from: currentUserName,
to: sendTo,
teamName: currentUserTeamName,
...inviteTeamOptions,
isOrg: isOrg,
});
}
});
return {
autoJoined: true,
};
}
await sendEmails(sendEmailsPromises);
};

View File

@ -160,6 +160,7 @@ export interface CalendarEvent {
team?: {
name: string;
members: TeamMember[];
id: number;
};
location?: string | null;
conferenceCredentialId?: number;

View File

@ -27,6 +27,7 @@ declare namespace NodeJS {
readonly STRIPE_PRIVATE_KEY: string | undefined;
readonly STRIPE_CLIENT_ID: string | undefined;
readonly STRIPE_WEBHOOK_SECRET: string | undefined;
readonly STRIPE_WEBHOOK_SECRET_APPS: string | undefined;
readonly PAYMENT_FEE_PERCENTAGE: number | undefined;
readonly PAYMENT_FEE_FIXED: number | undefined;
readonly NEXT_PUBLIC_INTERCOM_APP_ID: string | undefined;

View File

@ -313,6 +313,7 @@
"STRIPE_PRODUCT_ID_SCALE",
"STRIPE_PRODUCT_ID_STARTER",
"STRIPE_WEBHOOK_SECRET",
"STRIPE_WEBHOOK_SECRET_APPS",
"TANDEM_BASE_URL",
"TANDEM_CLIENT_ID",
"TANDEM_CLIENT_SECRET",

210
yarn.lock
View File

@ -3190,6 +3190,15 @@ __metadata:
languageName: node
linkType: hard
"@babel/runtime@npm:^7.14.5, @babel/runtime@npm:^7.17.2, @babel/runtime@npm:^7.18.6":
version: 7.23.4
resolution: "@babel/runtime@npm:7.23.4"
dependencies:
regenerator-runtime: ^0.14.0
checksum: 8eb6a6b2367f7d60e7f7dd83f477cc2e2fdb169e5460694d7614ce5c730e83324bcf29251b70940068e757ad1ee56ff8073a372260d90cad55f18a825caf97cd
languageName: node
linkType: hard
"@babel/runtime@npm:^7.21.0":
version: 7.23.1
resolution: "@babel/runtime@npm:7.23.1"
@ -3543,15 +3552,13 @@ __metadata:
"@calcom/ui": "*"
"@types/node": 16.9.1
"@types/react": 18.0.26
"@types/react-dom": ^18.0.9
"@types/react-dom": 18.0.9
eslint: ^8.34.0
eslint-config-next: ^13.2.1
next: ^13.4.6
next-auth: ^4.22.1
postcss: ^8.4.18
next: ^13.2.1
next-auth: ^4.20.1
react: ^18.2.0
react-dom: ^18.2.0
tailwindcss: ^3.3.3
typescript: ^4.9.4
languageName: unknown
linkType: soft
@ -3645,7 +3652,7 @@ __metadata:
"@calcom/ui": "*"
"@headlessui/react": ^1.5.0
"@heroicons/react": ^1.0.6
"@prisma/client": ^5.4.2
"@prisma/client": ^4.13.0
"@tailwindcss/forms": ^0.5.2
"@types/node": 16.9.1
"@types/react": 18.0.26
@ -3653,21 +3660,21 @@ __metadata:
chart.js: ^3.7.1
client-only: ^0.0.1
eslint: ^8.34.0
next: ^13.4.6
next-auth: ^4.22.1
next-i18next: ^13.2.2
next: ^13.2.1
next-auth: ^4.20.1
next-i18next: ^11.3.0
postcss: ^8.4.18
prisma: ^5.4.2
prisma: ^4.13.0
prisma-field-encryption: ^1.4.0
react: ^18.2.0
react-chartjs-2: ^4.0.1
react-dom: ^18.2.0
react-hook-form: ^7.43.3
react-live-chat-loader: ^2.8.1
react-live-chat-loader: ^2.7.3
swr: ^1.2.2
tailwindcss: ^3.3.3
tailwindcss: ^3.2.1
typescript: ^4.9.4
zod: ^3.22.2
zod: ^3.20.2
languageName: unknown
linkType: soft
@ -8479,6 +8486,20 @@ __metadata:
languageName: node
linkType: hard
"@prisma/client@npm:^4.13.0":
version: 4.16.2
resolution: "@prisma/client@npm:4.16.2"
dependencies:
"@prisma/engines-version": 4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81
peerDependencies:
prisma: "*"
peerDependenciesMeta:
prisma:
optional: true
checksum: 38e1356644a764946c69c8691ea4bbed0ba37739d833a435625bd5435912bed4b9bdd7c384125f3a4ab8128faf566027985c0f0840a42741c338d72e40b5d565
languageName: node
linkType: hard
"@prisma/client@npm:^5.4.2":
version: 5.4.2
resolution: "@prisma/client@npm:5.4.2"
@ -8537,6 +8558,13 @@ __metadata:
languageName: node
linkType: hard
"@prisma/engines-version@npm:4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81":
version: 4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81
resolution: "@prisma/engines-version@npm:4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81"
checksum: b42c6abe7c1928e546f15449e40ffa455701ef2ab1f62973628ecb4e19ff3652e34609a0d83196d1cbd0864adb44c55e082beec852b11929acf1c15fb57ca45a
languageName: node
linkType: hard
"@prisma/engines-version@npm:5.4.1-2.ac9d7041ed77bcc8a8dbd2ab6616b39013829574":
version: 5.4.1-2.ac9d7041ed77bcc8a8dbd2ab6616b39013829574
resolution: "@prisma/engines-version@npm:5.4.1-2.ac9d7041ed77bcc8a8dbd2ab6616b39013829574"
@ -8544,6 +8572,13 @@ __metadata:
languageName: node
linkType: hard
"@prisma/engines@npm:4.16.2":
version: 4.16.2
resolution: "@prisma/engines@npm:4.16.2"
checksum: f423e6092c3e558cd089a68ae87459fba7fd390c433df087342b3269c3b04163965b50845150dfe47d01f811781bfff89d5ae81c95ca603c59359ab69ebd810f
languageName: node
linkType: hard
"@prisma/engines@npm:5.1.1":
version: 5.1.1
resolution: "@prisma/engines@npm:5.1.1"
@ -24677,6 +24712,13 @@ __metadata:
languageName: node
linkType: hard
"i18next-fs-backend@npm:^1.1.4":
version: 1.2.0
resolution: "i18next-fs-backend@npm:1.2.0"
checksum: da74d20f2b007f8e34eaf442fa91ad12aaff3b9891e066c6addd6d111b37e370c62370dfbc656730ab2f8afd988f2e7ea1c48301ebb19ccb716fb5965600eddf
languageName: node
linkType: hard
"i18next-fs-backend@npm:^2.1.1":
version: 2.1.3
resolution: "i18next-fs-backend@npm:2.1.3"
@ -24684,6 +24726,15 @@ __metadata:
languageName: node
linkType: hard
"i18next@npm:^21.8.13":
version: 21.10.0
resolution: "i18next@npm:21.10.0"
dependencies:
"@babel/runtime": ^7.17.2
checksum: f997985e2d4d15a62a0936a82ff6420b97f3f971e776fe685bdd50b4de0cb4dc2198bc75efe6b152844794ebd5040d8060d6d152506a687affad534834836d81
languageName: node
linkType: hard
"i18next@npm:^23.2.3":
version: 23.2.3
resolution: "i18next@npm:23.2.3"
@ -26352,6 +26403,15 @@ __metadata:
languageName: node
linkType: hard
"jiti@npm:^1.19.1":
version: 1.21.0
resolution: "jiti@npm:1.21.0"
bin:
jiti: bin/jiti.js
checksum: a7bd5d63921c170eaec91eecd686388181c7828e1fa0657ab374b9372bfc1f383cf4b039e6b272383d5cb25607509880af814a39abdff967322459cca41f2961
languageName: node
linkType: hard
"joi@npm:^17.7.0":
version: 17.10.2
resolution: "joi@npm:17.10.2"
@ -30078,6 +30138,31 @@ __metadata:
languageName: node
linkType: hard
"next-auth@npm:^4.20.1":
version: 4.24.5
resolution: "next-auth@npm:4.24.5"
dependencies:
"@babel/runtime": ^7.20.13
"@panva/hkdf": ^1.0.2
cookie: ^0.5.0
jose: ^4.11.4
oauth: ^0.9.15
openid-client: ^5.4.0
preact: ^10.6.3
preact-render-to-string: ^5.1.19
uuid: ^8.3.2
peerDependencies:
next: ^12.2.5 || ^13 || ^14
nodemailer: ^6.6.5
react: ^17.0.2 || ^18
react-dom: ^17.0.2 || ^18
peerDependenciesMeta:
nodemailer:
optional: true
checksum: 7cc49385123690ccb908f4552b75012717c4e45205a9fdc7cf48cd730dbcc7823a3e33e2a2073ecf1edae5c1980123f68678fd4af9198ea21ab0decb630cc71e
languageName: node
linkType: hard
"next-auth@npm:^4.22.1":
version: 4.22.1
resolution: "next-auth@npm:4.22.1"
@ -30145,6 +30230,24 @@ __metadata:
languageName: node
linkType: hard
"next-i18next@npm:^11.3.0":
version: 11.3.0
resolution: "next-i18next@npm:11.3.0"
dependencies:
"@babel/runtime": ^7.18.6
"@types/hoist-non-react-statics": ^3.3.1
core-js: ^3
hoist-non-react-statics: ^3.3.2
i18next: ^21.8.13
i18next-fs-backend: ^1.1.4
react-i18next: ^11.18.0
peerDependencies:
next: ">= 10.0.0"
react: ">= 16.8.0"
checksum: fbce97a4fbf9ad846c08652471a833c7f173c3e7ddc7cafa1423625b4a684715bb85f76ae06fe9cbed3e70f12b8e78e2459e5bc1a3c3f5c517743f17648f8939
languageName: node
linkType: hard
"next-i18next@patch:next-i18next@npm%3A13.3.0#./.yarn/patches/next-i18next-npm-13.3.0-bf25b0943c.patch::locator=calcom-monorepo%40workspace%3A.":
version: 13.3.0
resolution: "next-i18next@patch:next-i18next@npm%3A13.3.0#./.yarn/patches/next-i18next-npm-13.3.0-bf25b0943c.patch::version=13.3.0&hash=bcbde7&locator=calcom-monorepo%40workspace%3A."
@ -30226,7 +30329,7 @@ __metadata:
languageName: node
linkType: hard
"next@npm:^13.4.6":
"next@npm:^13.2.1, next@npm:^13.4.6":
version: 13.5.6
resolution: "next@npm:13.5.6"
dependencies:
@ -32900,6 +33003,18 @@ __metadata:
languageName: node
linkType: hard
"prisma@npm:^4.13.0":
version: 4.16.2
resolution: "prisma@npm:4.16.2"
dependencies:
"@prisma/engines": 4.16.2
bin:
prisma: build/index.js
prisma2: build/index.js
checksum: 1d0ed616abd7f8de22441e333b976705f1cb05abcb206965df3fc6a7ea03911ef467dd484a4bc51fdc6cff72dd9857b9852be5f232967a444af0a98c49bfdb76
languageName: node
linkType: hard
"prisma@npm:^5.4.2":
version: 5.4.2
resolution: "prisma@npm:5.4.2"
@ -33797,6 +33912,24 @@ __metadata:
languageName: node
linkType: hard
"react-i18next@npm:^11.18.0":
version: 11.18.6
resolution: "react-i18next@npm:11.18.6"
dependencies:
"@babel/runtime": ^7.14.5
html-parse-stringify: ^3.0.1
peerDependencies:
i18next: ">= 19.0.0"
react: ">= 16.8.0"
peerDependenciesMeta:
react-dom:
optional: true
react-native:
optional: true
checksum: 624c0a0313fac4e0d18560b83c99a8bd0a83abc02e5db8d01984e0643ac409d178668aa3a4720d01f7a0d9520d38598dcbff801d6f69a970bae67461de6cd852
languageName: node
linkType: hard
"react-i18next@npm:^12.2.0":
version: 12.3.1
resolution: "react-i18next@npm:12.3.1"
@ -33920,6 +34053,15 @@ __metadata:
languageName: node
linkType: hard
"react-live-chat-loader@npm:^2.7.3":
version: 2.8.2
resolution: "react-live-chat-loader@npm:2.8.2"
peerDependencies:
react: ^16.14.0 || ^17.0.0 || ^18.0.0
checksum: 30de0d27693f1c80641347f0efc9c846e0c8d52231eb3181b68d684ef580764d3bd8393d77ff61f3066af5cc65977fc1a108726965181ddbbd6a0feb0a9ebcb9
languageName: node
linkType: hard
"react-live-chat-loader@npm:^2.8.1":
version: 2.8.1
resolution: "react-live-chat-loader@npm:2.8.1"
@ -37784,6 +37926,39 @@ __metadata:
languageName: node
linkType: hard
"tailwindcss@npm:^3.2.1":
version: 3.3.5
resolution: "tailwindcss@npm:3.3.5"
dependencies:
"@alloc/quick-lru": ^5.2.0
arg: ^5.0.2
chokidar: ^3.5.3
didyoumean: ^1.2.2
dlv: ^1.1.3
fast-glob: ^3.3.0
glob-parent: ^6.0.2
is-glob: ^4.0.3
jiti: ^1.19.1
lilconfig: ^2.1.0
micromatch: ^4.0.5
normalize-path: ^3.0.0
object-hash: ^3.0.0
picocolors: ^1.0.0
postcss: ^8.4.23
postcss-import: ^15.1.0
postcss-js: ^4.0.1
postcss-load-config: ^4.0.1
postcss-nested: ^6.0.1
postcss-selector-parser: ^6.0.11
resolve: ^1.22.2
sucrase: ^3.32.0
bin:
tailwind: lib/cli.js
tailwindcss: lib/cli.js
checksum: e04bb3bb7f9f17e9b6db0c7ace755ef0d6d05bff36ebeb9e5006e13c018ed5566f09db30a1a34380e38fa93ebbb4ae0e28fe726879d5e9ddd8c5b52bffd26f14
languageName: node
linkType: hard
"tailwindcss@npm:^3.3.3":
version: 3.3.3
resolution: "tailwindcss@npm:3.3.3"
@ -41974,6 +42149,13 @@ __metadata:
languageName: node
linkType: hard
"zod@npm:^3.20.2":
version: 3.22.4
resolution: "zod@npm:3.22.4"
checksum: 80bfd7f8039b24fddeb0718a2ec7c02aa9856e4838d6aa4864335a047b6b37a3273b191ef335bf0b2002e5c514ef261ffcda5a589fb084a48c336ffc4cdbab7f
languageName: node
linkType: hard
"zod@npm:^3.21.4, zod@npm:^3.22.2":
version: 3.22.2
resolution: "zod@npm:3.22.2"