Merge branch 'main' into zomars/cal-2724-implement-csrf-protection-for-public-trpc-endpoints
This commit is contained in:
commit
dcff308392
|
@ -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=
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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 || "",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "3.5.0",
|
||||
"version": "3.5.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
|
|
@ -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 || "");
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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") {
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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":
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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" } },
|
||||
},
|
||||
}
|
||||
|
|
|
@ -7,8 +7,6 @@ items:
|
|||
- 5.jpg
|
||||
---
|
||||
|
||||
**FREE TRIAL until December 1st, 2023**
|
||||
|
||||
{DESCRIPTION}
|
||||
|
||||
## Example questions:
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 }) => {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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}
|
||||
</>
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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} />;
|
||||
};
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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 />
|
||||
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -160,6 +160,7 @@ export interface CalendarEvent {
|
|||
team?: {
|
||||
name: string;
|
||||
members: TeamMember[];
|
||||
id: number;
|
||||
};
|
||||
location?: string | null;
|
||||
conferenceCredentialId?: number;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
210
yarn.lock
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue
Block a user