Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95bf90402c | ||
|
|
f523ce7795 | ||
|
|
65b70d682d | ||
|
|
2d518f49dd | ||
|
|
7a9d291d1f | ||
|
|
1a1a506324 | ||
|
|
0f7ecb3345 | ||
|
|
4f1d71e660 | ||
|
|
30eb7ce846 | ||
|
|
3eb5490df9 |
@@ -242,6 +242,7 @@ E2E_TEST_MAILHOG_ENABLED=
|
||||
|
||||
# Cloudflare Turnstile
|
||||
NEXT_PUBLIC_CLOUDFLARE_SITEKEY=
|
||||
NEXT_PUBLIC_CLOUDFLARE_USE_TURNSTILE_IN_BOOKER=
|
||||
CLOUDFLARE_TURNSTILE_SECRET=
|
||||
|
||||
# Set the following value to true if you wish to enable Team Impersonation
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
# .github/workflows/chromatic.yml
|
||||
|
||||
# Workflow name
|
||||
name: "Chromatic"
|
||||
|
||||
# Event for the workflow
|
||||
on:
|
||||
pull_request_target: # So we can test on forks
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- apps/storybook/**
|
||||
- packages/ui/**
|
||||
workflow_dispatch:
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# List of jobs
|
||||
jobs:
|
||||
chromatic-deployment:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }} # So we can test on forks
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
run: yarn
|
||||
# 👇 Adds Chromatic as a step in the workflow
|
||||
- name: Publish to Chromatic
|
||||
uses: chromaui/action@v1
|
||||
# Options required to the GitHub Chromatic Action
|
||||
with:
|
||||
# 👇 Chromatic projectToken, refer to the manage page to obtain it.
|
||||
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
|
||||
workingDir: "apps/storybook"
|
||||
buildScriptName: "build"
|
||||
Vendored
+1
-1
@@ -5,7 +5,7 @@ import Page from "~/auth/oauth2/authorize-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
() => "Authorize",
|
||||
(t) => t("authorize"),
|
||||
() => ""
|
||||
);
|
||||
};
|
||||
+1
-1
@@ -5,7 +5,7 @@ import Page from "~/auth/platform/authorize-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
() => "Authorize",
|
||||
(t) => t("authorize"),
|
||||
() => ""
|
||||
);
|
||||
};
|
||||
@@ -5,7 +5,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useRefreshData } from "@calcom/lib/hooks/useRefreshData";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import type { RecurringEvent } from "@calcom/types/Calendar";
|
||||
import { Button, Icon, TextArea } from "@calcom/ui";
|
||||
import { Button, Icon, Label, TextArea } from "@calcom/ui";
|
||||
|
||||
type Props = {
|
||||
booking: {
|
||||
@@ -33,6 +33,7 @@ type Props = {
|
||||
};
|
||||
eventType: unknown;
|
||||
};
|
||||
isHost: boolean;
|
||||
};
|
||||
|
||||
export default function CancelBooking(props: Props) {
|
||||
@@ -70,7 +71,7 @@ export default function CancelBooking(props: Props) {
|
||||
)}
|
||||
{!error && (
|
||||
<div className="mt-5 sm:mt-6">
|
||||
<label className="text-default font-medium">{t("cancellation_reason")}</label>
|
||||
<Label>{props.isHost ? t("cancellation_reason_host") : t("cancellation_reason")}</Label>
|
||||
<TextArea
|
||||
data-testid="cancel_reason"
|
||||
ref={cancelBookingRef}
|
||||
@@ -80,6 +81,14 @@ export default function CancelBooking(props: Props) {
|
||||
className="mb-4 mt-2 w-full "
|
||||
rows={3}
|
||||
/>
|
||||
{props.isHost ? (
|
||||
<div className="-mt-2 mb-4 flex items-center gap-2">
|
||||
<Icon name="info" className="text-subtle h-4 w-4" />
|
||||
<p className="text-default text-subtle text-sm leading-none">
|
||||
{t("notify_attendee_cancellation_reason_warning")}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-col-reverse rtl:space-x-reverse ">
|
||||
<div className="ml-auto flex w-full space-x-4 ">
|
||||
<Button
|
||||
@@ -90,6 +99,7 @@ export default function CancelBooking(props: Props) {
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="confirm_cancel"
|
||||
disabled={props.isHost && !cancellationReason}
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
|
||||
|
||||
@@ -168,6 +168,9 @@ export const config = {
|
||||
"/login",
|
||||
"/auth/login",
|
||||
"/auth/error",
|
||||
"/auth/signin",
|
||||
"/auth/oauth2/authorize",
|
||||
"/auth/platform/authorize",
|
||||
/**
|
||||
* Paths required by routingForms.handle
|
||||
*/
|
||||
|
||||
@@ -163,10 +163,24 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
});
|
||||
|
||||
const userId = session?.user?.id;
|
||||
const isLoggedInUserHost =
|
||||
userId &&
|
||||
(eventType.users.some((user) => user.id === userId) ||
|
||||
eventType.hosts.some(({ user }) => user.id === userId));
|
||||
|
||||
const checkIfUserIsHost = (userId?: number | null) => {
|
||||
if (!userId) return false;
|
||||
|
||||
return (
|
||||
bookingInfo?.user?.id === userId ||
|
||||
eventType.users.some(
|
||||
(user) =>
|
||||
user.id === userId && bookingInfo.attendees.some((attendee) => attendee.email === user.email)
|
||||
) ||
|
||||
eventType.hosts.some(
|
||||
({ user }) =>
|
||||
user.id === userId && bookingInfo.attendees.some((attendee) => attendee.email === user.email)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const isLoggedInUserHost = checkIfUserIsHost(userId);
|
||||
|
||||
if (!isLoggedInUserHost) {
|
||||
// Removing hidden fields from responses
|
||||
@@ -195,6 +209,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
userTimeFormat,
|
||||
requiresLoginToUpdate,
|
||||
rescheduledToUid,
|
||||
isLoggedInUserHost,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -132,7 +132,6 @@ export default function Success(props: PageProps) {
|
||||
) {
|
||||
rescheduleLocation = bookingInfo.responses.location.optionValue;
|
||||
}
|
||||
|
||||
const locationVideoCallUrl: string | undefined = bookingMetadataSchema.parse(
|
||||
bookingInfo?.metadata || {}
|
||||
)?.videoCallUrl;
|
||||
@@ -149,6 +148,7 @@ export default function Success(props: PageProps) {
|
||||
props?.userTimeFormat ? props.userTimeFormat === 24 : isBrowserLocale24h()
|
||||
);
|
||||
const { data: session } = useSession();
|
||||
const isHost = props.isLoggedInUserHost;
|
||||
|
||||
const [date, setDate] = useState(dayjs.utc(bookingInfo.startTime));
|
||||
|
||||
@@ -317,7 +317,6 @@ export default function Success(props: PageProps) {
|
||||
return t(`needs_to_be_confirmed_or_rejected${titleSuffix}`);
|
||||
}
|
||||
if (bookingInfo.user) {
|
||||
const isHost = bookingInfo.user.id === session?.user?.id;
|
||||
const isAttendee = bookingInfo.attendees.find((attendee) => attendee.email === session?.user?.email);
|
||||
const attendee = bookingInfo.attendees[0]?.name || bookingInfo.attendees[0]?.email || "Nameless";
|
||||
const host = bookingInfo.user.name || bookingInfo.user.email;
|
||||
@@ -778,6 +777,7 @@ export default function Success(props: PageProps) {
|
||||
seatReferenceUid={seatReferenceUid}
|
||||
bookingCancelledEventProps={bookingCancelledEventProps}
|
||||
currentUserEmail={currentUserEmail}
|
||||
isHost={isHost}
|
||||
/>
|
||||
</>
|
||||
))}
|
||||
|
||||
@@ -58,7 +58,7 @@ const signupSchema = apiSignupSchema.extend({
|
||||
cfToken: z.string().optional(),
|
||||
});
|
||||
|
||||
const TurnstileCaptcha = dynamic(() => import("@components/auth/Turnstile"), { ssr: false });
|
||||
const TurnstileCaptcha = dynamic(() => import("@calcom/features/auth/Turnstile"), { ssr: false });
|
||||
|
||||
type FormValues = z.infer<typeof signupSchema>;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "4.9.3",
|
||||
"version": "4.9.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
||||
@@ -6,10 +6,18 @@ import handleNewBooking from "@calcom/features/bookings/lib/handleNewBooking";
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import getIP from "@calcom/lib/getIP";
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
import { checkCfTurnstileToken } from "@calcom/lib/server/checkCfTurnstileToken";
|
||||
|
||||
async function handler(req: NextApiRequest & { userId?: number }, res: NextApiResponse) {
|
||||
const userIp = getIP(req);
|
||||
|
||||
if (process.env.NEXT_PUBLIC_CLOUDFLARE_USE_TURNSTILE_IN_BOOKER === "1") {
|
||||
await checkCfTurnstileToken({
|
||||
token: req.body["cfToken"] as string,
|
||||
remoteIp: userIp,
|
||||
});
|
||||
}
|
||||
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "core",
|
||||
identifier: userIp,
|
||||
|
||||
@@ -6,12 +6,20 @@ import type { BookingResponse } from "@calcom/features/bookings/types";
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import getIP from "@calcom/lib/getIP";
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
import { checkCfTurnstileToken } from "@calcom/lib/server/checkCfTurnstileToken";
|
||||
|
||||
// @TODO: Didn't look at the contents of this function in order to not break old booking page.
|
||||
|
||||
async function handler(req: NextApiRequest & { userId?: number }, res: NextApiResponse) {
|
||||
const userIp = getIP(req);
|
||||
|
||||
if (process.env.NEXT_PUBLIC_CLOUDFLARE_USE_TURNSTILE_IN_BOOKER === "1") {
|
||||
await checkCfTurnstileToken({
|
||||
token: req.body[0]["cfToken"] as string,
|
||||
remoteIp: userIp,
|
||||
});
|
||||
}
|
||||
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "core",
|
||||
identifier: userIp,
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
import Authorize from "~/auth/oauth2/authorize-view";
|
||||
|
||||
const Page = () => <Authorize />;
|
||||
Page.PageWrapper = PageWrapper;
|
||||
export default Page;
|
||||
@@ -1,7 +0,0 @@
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
import Authorize from "~/auth/platform/authorize-view";
|
||||
|
||||
const Page = () => <Authorize />;
|
||||
Page.PageWrapper = PageWrapper;
|
||||
export default Page;
|
||||
@@ -1,14 +0,0 @@
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
import { getServerSideProps } from "@server/lib/auth/signin/getServerSideProps";
|
||||
|
||||
import type { PageProps } from "~/auth/signin-view";
|
||||
import SignIn from "~/auth/signin-view";
|
||||
|
||||
const Page = (props: PageProps) => <SignIn {...props} />;
|
||||
|
||||
Page.PageWrapper = PageWrapper;
|
||||
|
||||
export default Page;
|
||||
|
||||
export { getServerSideProps };
|
||||
@@ -175,6 +175,8 @@ test.describe("pro user", () => {
|
||||
await page.waitForURL((url) => {
|
||||
return url.pathname.startsWith("/booking/");
|
||||
});
|
||||
|
||||
await page.locator('[data-testid="cancel_reason"]').fill("Test reason");
|
||||
await page.locator('[data-testid="confirm_cancel"]').click();
|
||||
|
||||
const cancelledHeadline = page.locator('[data-testid="cancelled-headline"]');
|
||||
@@ -205,6 +207,7 @@ test.describe("pro user", () => {
|
||||
await page.waitForURL((url) => {
|
||||
return url.pathname.startsWith("/booking/");
|
||||
});
|
||||
await page.locator('[data-testid="cancel_reason"]').fill("Test reason");
|
||||
await page.locator('[data-testid="confirm_cancel"]').click();
|
||||
|
||||
const cancelledHeadline = page.locator('[data-testid="cancelled-headline"]');
|
||||
|
||||
@@ -228,6 +228,8 @@ test.describe("Reschedule for booking with seats", () => {
|
||||
`/booking/${booking.uid}?cancel=true&allRemainingBookings=false&seatReferenceUid=${bookingSeats[0].referenceUid}`
|
||||
);
|
||||
|
||||
await page.locator('[data-testid="cancel_reason"]').fill("Test reason");
|
||||
|
||||
await page.locator('[data-testid="confirm_cancel"]').click();
|
||||
|
||||
await expect(page.locator("text=You are no longer attending this event")).toBeVisible();
|
||||
@@ -236,6 +238,8 @@ test.describe("Reschedule for booking with seats", () => {
|
||||
`/booking/${booking.uid}?cancel=true&allRemainingBookings=false&seatReferenceUid=${bookingSeats[1].referenceUid}`
|
||||
);
|
||||
|
||||
await page.locator('[data-testid="cancel_reason"]').fill("Test reason");
|
||||
|
||||
// Page should not be 404
|
||||
await page.locator('[data-testid="confirm_cancel"]').click();
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ test("dynamic booking", async ({ page, users }) => {
|
||||
await page.waitForURL((url) => {
|
||||
return url.pathname.startsWith("/booking");
|
||||
});
|
||||
await page.locator('[data-testid="cancel_reason"]').fill("Test reason");
|
||||
await page.locator('[data-testid="confirm_cancel"]').click();
|
||||
|
||||
const cancelledHeadline = page.locator('[data-testid="cancelled-headline"]');
|
||||
|
||||
@@ -52,7 +52,9 @@
|
||||
"you_can_view_booking_details_with_this_url": "You can view the booking details from this url {{url}} and add the event to your calendar",
|
||||
"no_options_available": "No options available",
|
||||
"cancellation_reason": "Reason for cancellation (optional)",
|
||||
"cancellation_reason_host": "Reason for cancellation",
|
||||
"cancellation_reason_placeholder": "Why are you cancelling?",
|
||||
"notify_attendee_cancellation_reason_warning":"Please note that the cancellation reason will be visible to the attendee.",
|
||||
"rejection_reason_placeholder": "Why are you rejecting?",
|
||||
"rejection_reason": "Reason for rejecting",
|
||||
"rejection_reason_title": "Reject the booking request?",
|
||||
@@ -1544,6 +1546,7 @@
|
||||
"download_transcript": "Download Transcript",
|
||||
"recording_from_your_recent_call": "A recording from your recent call on {{appName}} is ready for download",
|
||||
"transcript_from_previous_call": "Transcript from your recent call on {{appName}} is ready to download. Links are valid only for 1 Hour",
|
||||
"you_can_download_transcript_from_attachments": "You can also download transcript from attachments",
|
||||
"link_valid_for_12_hrs": "Note: The download link is valid only for 12 hours. You can generate new download link by following instructions <1>here</1>.",
|
||||
"create_your_first_form": "Create your first form",
|
||||
"create_your_first_form_description": "With Routing Forms you can ask qualifying questions and route to the correct person or event type.",
|
||||
@@ -2927,6 +2930,7 @@
|
||||
"managed_users_description": "See all the managed users created by your OAuth client",
|
||||
"select_oAuth_client": "Select Oauth Client",
|
||||
"on_every_instance": "On every instance",
|
||||
"authorize": "Authorize",
|
||||
"next": "Next",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
},
|
||||
"pages/router/embed.tsx": {
|
||||
"memory": 2048
|
||||
},
|
||||
"pages/api/book/event.ts": {
|
||||
"memory": 2048
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,6 @@ import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
|
||||
import refreshOAuthTokens from "../../_utils/oauth/refreshOAuthTokens";
|
||||
import type { HubspotToken } from "../api/callback";
|
||||
|
||||
const hubspotClient = new hubspot.Client();
|
||||
|
||||
interface CustomPublicObjectInput extends SimplePublicObjectInput {
|
||||
id?: string;
|
||||
}
|
||||
@@ -33,8 +31,11 @@ export default class HubspotCalendarService implements CRM {
|
||||
private log: typeof logger;
|
||||
private client_id = "";
|
||||
private client_secret = "";
|
||||
private hubspotClient: hubspot.Client;
|
||||
|
||||
constructor(credential: CredentialPayload) {
|
||||
this.hubspotClient = new hubspot.Client();
|
||||
|
||||
this.integrationName = "hubspot_other_calendar";
|
||||
|
||||
this.auth = this.hubspotAuth(credential).then((r) => r);
|
||||
@@ -63,7 +64,7 @@ export default class HubspotCalendarService implements CRM {
|
||||
},
|
||||
};
|
||||
|
||||
return hubspotClient.crm.objects.meetings.basicApi.create(simplePublicObjectInput);
|
||||
return this.hubspotClient.crm.objects.meetings.basicApi.create(simplePublicObjectInput);
|
||||
};
|
||||
|
||||
private hubspotAssociate = async (meeting: SimplePublicObject, contacts: Array<{ id: string }>) => {
|
||||
@@ -74,7 +75,7 @@ export default class HubspotCalendarService implements CRM {
|
||||
type: "meeting_event_to_contact",
|
||||
})),
|
||||
};
|
||||
return hubspotClient.crm.associations.batchApi.create(
|
||||
return this.hubspotClient.crm.associations.batchApi.create(
|
||||
"meetings",
|
||||
"contacts",
|
||||
batchInputPublicAssociation
|
||||
@@ -94,11 +95,11 @@ export default class HubspotCalendarService implements CRM {
|
||||
},
|
||||
};
|
||||
|
||||
return hubspotClient.crm.objects.meetings.basicApi.update(uid, simplePublicObjectInput);
|
||||
return this.hubspotClient.crm.objects.meetings.basicApi.update(uid, simplePublicObjectInput);
|
||||
};
|
||||
|
||||
private hubspotDeleteMeeting = async (uid: string) => {
|
||||
return hubspotClient.crm.objects.meetings.basicApi.archive(uid);
|
||||
return this.hubspotClient.crm.objects.meetings.basicApi.archive(uid);
|
||||
};
|
||||
|
||||
private hubspotAuth = async (credential: CredentialPayload) => {
|
||||
@@ -120,7 +121,7 @@ export default class HubspotCalendarService implements CRM {
|
||||
try {
|
||||
const hubspotRefreshToken: HubspotToken = await refreshOAuthTokens(
|
||||
async () =>
|
||||
await hubspotClient.oauth.tokensApi.createToken(
|
||||
await this.hubspotClient.oauth.tokensApi.createToken(
|
||||
"refresh_token",
|
||||
undefined,
|
||||
`${WEBAPP_URL}/api/integrations/hubspot/callback`,
|
||||
@@ -131,7 +132,6 @@ export default class HubspotCalendarService implements CRM {
|
||||
"hubspot",
|
||||
credential.userId
|
||||
);
|
||||
|
||||
// set expiry date as offset from current time.
|
||||
hubspotRefreshToken.expiryDate = Math.round(Date.now() + hubspotRefreshToken.expiresIn * 1000);
|
||||
await prisma.credential.update({
|
||||
@@ -143,7 +143,7 @@ export default class HubspotCalendarService implements CRM {
|
||||
},
|
||||
});
|
||||
|
||||
hubspotClient.setAccessToken(hubspotRefreshToken.accessToken);
|
||||
this.hubspotClient.setAccessToken(hubspotRefreshToken.accessToken);
|
||||
} catch (e: unknown) {
|
||||
this.log.error(e);
|
||||
}
|
||||
@@ -220,7 +220,7 @@ export default class HubspotCalendarService implements CRM {
|
||||
after: 0,
|
||||
};
|
||||
|
||||
const contacts = await hubspotClient.crm.contacts.searchApi
|
||||
const contacts = await this.hubspotClient.crm.contacts.searchApi
|
||||
.doSearch(publicObjectSearchRequest)
|
||||
.then((apiResponse) => apiResponse.results);
|
||||
|
||||
@@ -248,7 +248,7 @@ export default class HubspotCalendarService implements CRM {
|
||||
});
|
||||
const createdContacts = await Promise.all(
|
||||
simplePublicObjectInputs.map((contact) =>
|
||||
hubspotClient.crm.contacts.basicApi.create(contact).catch((error) => {
|
||||
this.hubspotClient.crm.contacts.basicApi.create(contact).catch((error) => {
|
||||
// If multiple events are created, subsequent events may fail due to
|
||||
// contact was created by previous event creation, so we introduce a
|
||||
// fallback taking advantage of the error message providing the contact id
|
||||
|
||||
@@ -173,169 +173,167 @@ export default function RoutingForms({
|
||||
</div>
|
||||
}>
|
||||
<FormActionsProvider appUrl={appUrl}>
|
||||
<div className="-mx-4 md:-mx-8">
|
||||
<div className="mb-10 w-full px-4 pb-2 sm:px-6 md:px-8">
|
||||
<div className="flex">
|
||||
<TeamsFilter />
|
||||
</div>
|
||||
<FilterResults
|
||||
queryRes={queryRes}
|
||||
emptyScreen={
|
||||
<EmptyScreen
|
||||
Icon="git-merge"
|
||||
headline={t("create_your_first_form")}
|
||||
description={t("create_your_first_form_description")}
|
||||
buttonRaw={<NewFormButton />}
|
||||
/>
|
||||
}
|
||||
noResultsScreen={
|
||||
<EmptyScreen
|
||||
Icon="git-merge"
|
||||
headline={t("no_results_for_filter")}
|
||||
description={t("change_filter_common")}
|
||||
/>
|
||||
}
|
||||
SkeletonLoader={SkeletonLoaderTeamList}>
|
||||
<div className="bg-default mb-16 overflow-hidden">
|
||||
<List data-testid="routing-forms-list" ref={parent}>
|
||||
{forms?.map(({ form, readOnly }, index) => {
|
||||
if (!form) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const description = form.description || "";
|
||||
form.routes = form.routes || [];
|
||||
const fields = form.fields || [];
|
||||
const userRoutes = form.routes.filter((route) => !isFallbackRoute(route));
|
||||
const firstItem = forms[0].form;
|
||||
const lastItem = forms[forms.length - 1].form;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group flex w-full max-w-full items-center justify-between overflow-hidden"
|
||||
key={form.id}>
|
||||
{!(firstItem && firstItem.id === form.id) && (
|
||||
<ArrowButton onClick={() => moveRoutingForm(index, -1)} arrowDirection="up" />
|
||||
)}
|
||||
|
||||
{!(lastItem && lastItem.id === form.id) && (
|
||||
<ArrowButton onClick={() => moveRoutingForm(index, 1)} arrowDirection="down" />
|
||||
)}
|
||||
<ListLinkItem
|
||||
href={`${appUrl}/form-edit/${form.id}`}
|
||||
heading={form.name}
|
||||
disabled={readOnly}
|
||||
subHeading={description}
|
||||
className="space-x-2 rtl:space-x-reverse"
|
||||
actions={
|
||||
<>
|
||||
{form.team?.name && (
|
||||
<div className="border-subtle border-r-2">
|
||||
<Badge className="ltr:mr-2 rtl:ml-2" variant="gray">
|
||||
{form.team.name}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
<FormAction
|
||||
disabled={readOnly}
|
||||
className="self-center"
|
||||
action="toggle"
|
||||
routingForm={form}
|
||||
/>
|
||||
<ButtonGroup combined>
|
||||
<Tooltip content={t("preview")}>
|
||||
<FormAction
|
||||
action="preview"
|
||||
routingForm={form}
|
||||
target="_blank"
|
||||
StartIcon="external-link"
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
/>
|
||||
</Tooltip>
|
||||
<FormAction
|
||||
routingForm={form}
|
||||
action="copyLink"
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
StartIcon="link"
|
||||
tooltip={t("copy_link_to_form")}
|
||||
/>
|
||||
<FormAction
|
||||
routingForm={form}
|
||||
action="embed"
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
StartIcon="code"
|
||||
tooltip={t("embed")}
|
||||
/>
|
||||
<FormActionsDropdown disabled={readOnly}>
|
||||
<FormAction
|
||||
action="edit"
|
||||
routingForm={form}
|
||||
color="minimal"
|
||||
className="!flex"
|
||||
StartIcon="pencil">
|
||||
{t("edit")}
|
||||
</FormAction>
|
||||
<FormAction
|
||||
action="download"
|
||||
routingForm={form}
|
||||
color="minimal"
|
||||
StartIcon="download">
|
||||
{t("download_responses")}
|
||||
</FormAction>
|
||||
<FormAction
|
||||
action="duplicate"
|
||||
routingForm={form}
|
||||
color="minimal"
|
||||
className="w-full"
|
||||
StartIcon="copy">
|
||||
{t("duplicate")}
|
||||
</FormAction>
|
||||
{typeformApp?.isInstalled ? (
|
||||
<FormAction
|
||||
data-testid="copy-redirect-url"
|
||||
routingForm={form}
|
||||
action="copyRedirectUrl"
|
||||
color="minimal"
|
||||
type="button"
|
||||
StartIcon="link">
|
||||
{t("Copy Typeform Redirect Url")}
|
||||
</FormAction>
|
||||
) : null}
|
||||
<FormAction
|
||||
action="_delete"
|
||||
routingForm={form}
|
||||
color="destructive"
|
||||
className="w-full"
|
||||
StartIcon="trash">
|
||||
{t("delete")}
|
||||
</FormAction>
|
||||
</FormActionsDropdown>
|
||||
</ButtonGroup>
|
||||
</>
|
||||
}>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Badge variant="gray" startIcon="menu">
|
||||
{fields.length} {fields.length === 1 ? "field" : "fields"}
|
||||
</Badge>
|
||||
<Badge variant="gray" startIcon="git-merge">
|
||||
{userRoutes.length} {userRoutes.length === 1 ? "route" : "routes"}
|
||||
</Badge>
|
||||
<Badge variant="gray" startIcon="message-circle">
|
||||
{form._count.responses}{" "}
|
||||
{form._count.responses === 1 ? "response" : "responses"}
|
||||
</Badge>
|
||||
</div>
|
||||
</ListLinkItem>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</div>
|
||||
</FilterResults>
|
||||
<div className="mb-10 w-full">
|
||||
<div className="flex">
|
||||
<TeamsFilter />
|
||||
</div>
|
||||
<FilterResults
|
||||
queryRes={queryRes}
|
||||
emptyScreen={
|
||||
<EmptyScreen
|
||||
Icon="git-merge"
|
||||
headline={t("create_your_first_form")}
|
||||
description={t("create_your_first_form_description")}
|
||||
buttonRaw={<NewFormButton />}
|
||||
/>
|
||||
}
|
||||
noResultsScreen={
|
||||
<EmptyScreen
|
||||
Icon="git-merge"
|
||||
headline={t("no_results_for_filter")}
|
||||
description={t("change_filter_common")}
|
||||
/>
|
||||
}
|
||||
SkeletonLoader={SkeletonLoaderTeamList}>
|
||||
<div className="bg-default mb-16 overflow-hidden">
|
||||
<List data-testid="routing-forms-list" ref={parent}>
|
||||
{forms?.map(({ form, readOnly }, index) => {
|
||||
if (!form) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const description = form.description || "";
|
||||
form.routes = form.routes || [];
|
||||
const fields = form.fields || [];
|
||||
const userRoutes = form.routes.filter((route) => !isFallbackRoute(route));
|
||||
const firstItem = forms[0].form;
|
||||
const lastItem = forms[forms.length - 1].form;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group flex w-full max-w-full items-center justify-between overflow-hidden"
|
||||
key={form.id}>
|
||||
{!(firstItem && firstItem.id === form.id) && (
|
||||
<ArrowButton onClick={() => moveRoutingForm(index, -1)} arrowDirection="up" />
|
||||
)}
|
||||
|
||||
{!(lastItem && lastItem.id === form.id) && (
|
||||
<ArrowButton onClick={() => moveRoutingForm(index, 1)} arrowDirection="down" />
|
||||
)}
|
||||
<ListLinkItem
|
||||
href={`${appUrl}/form-edit/${form.id}`}
|
||||
heading={form.name}
|
||||
disabled={readOnly}
|
||||
subHeading={description}
|
||||
className="space-x-2 rtl:space-x-reverse"
|
||||
actions={
|
||||
<>
|
||||
{form.team?.name && (
|
||||
<div className="border-subtle border-r-2">
|
||||
<Badge className="ltr:mr-2 rtl:ml-2" variant="gray">
|
||||
{form.team.name}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
<FormAction
|
||||
disabled={readOnly}
|
||||
className="self-center"
|
||||
action="toggle"
|
||||
routingForm={form}
|
||||
/>
|
||||
<ButtonGroup combined>
|
||||
<Tooltip content={t("preview")}>
|
||||
<FormAction
|
||||
action="preview"
|
||||
routingForm={form}
|
||||
target="_blank"
|
||||
StartIcon="external-link"
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
/>
|
||||
</Tooltip>
|
||||
<FormAction
|
||||
routingForm={form}
|
||||
action="copyLink"
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
StartIcon="link"
|
||||
tooltip={t("copy_link_to_form")}
|
||||
/>
|
||||
<FormAction
|
||||
routingForm={form}
|
||||
action="embed"
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
StartIcon="code"
|
||||
tooltip={t("embed")}
|
||||
/>
|
||||
<FormActionsDropdown disabled={readOnly}>
|
||||
<FormAction
|
||||
action="edit"
|
||||
routingForm={form}
|
||||
color="minimal"
|
||||
className="!flex"
|
||||
StartIcon="pencil">
|
||||
{t("edit")}
|
||||
</FormAction>
|
||||
<FormAction
|
||||
action="download"
|
||||
routingForm={form}
|
||||
color="minimal"
|
||||
StartIcon="download">
|
||||
{t("download_responses")}
|
||||
</FormAction>
|
||||
<FormAction
|
||||
action="duplicate"
|
||||
routingForm={form}
|
||||
color="minimal"
|
||||
className="w-full"
|
||||
StartIcon="copy">
|
||||
{t("duplicate")}
|
||||
</FormAction>
|
||||
{typeformApp?.isInstalled ? (
|
||||
<FormAction
|
||||
data-testid="copy-redirect-url"
|
||||
routingForm={form}
|
||||
action="copyRedirectUrl"
|
||||
color="minimal"
|
||||
type="button"
|
||||
StartIcon="link">
|
||||
{t("Copy Typeform Redirect Url")}
|
||||
</FormAction>
|
||||
) : null}
|
||||
<FormAction
|
||||
action="_delete"
|
||||
routingForm={form}
|
||||
color="destructive"
|
||||
className="w-full"
|
||||
StartIcon="trash">
|
||||
{t("delete")}
|
||||
</FormAction>
|
||||
</FormActionsDropdown>
|
||||
</ButtonGroup>
|
||||
</>
|
||||
}>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Badge variant="gray" startIcon="menu">
|
||||
{fields.length} {fields.length === 1 ? "field" : "fields"}
|
||||
</Badge>
|
||||
<Badge variant="gray" startIcon="git-merge">
|
||||
{userRoutes.length} {userRoutes.length === 1 ? "route" : "routes"}
|
||||
</Badge>
|
||||
<Badge variant="gray" startIcon="message-circle">
|
||||
{form._count.responses}{" "}
|
||||
{form._count.responses === 1 ? "response" : "responses"}
|
||||
</Badge>
|
||||
</div>
|
||||
</ListLinkItem>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</div>
|
||||
</FilterResults>
|
||||
</div>
|
||||
</FormActionsProvider>
|
||||
</UpgradeTip>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { TFunction } from "next-i18next";
|
||||
|
||||
import { WEBAPP_URL, APP_NAME, COMPANY_NAME } from "@calcom/lib/constants";
|
||||
import { WEBAPP_URL, COMPANY_NAME } from "@calcom/lib/constants";
|
||||
|
||||
import { V2BaseEmailHtml, CallToAction } from "../components";
|
||||
import { V2BaseEmailHtml } from "../components";
|
||||
|
||||
interface DailyVideoDownloadTranscriptEmailProps {
|
||||
language: TFunction;
|
||||
@@ -55,13 +55,13 @@ export const DailyVideoDownloadTranscriptEmail = (
|
||||
<>{props.language("hi_user_name", { name: props.name })},</>
|
||||
</p>
|
||||
<p style={{ fontWeight: 400, lineHeight: "24px", marginBottom: "40px" }}>
|
||||
<>{props.language("transcript_from_previous_call", { appName: APP_NAME })}</>
|
||||
<>{props.language("you_can_download_transcript_from_attachments")}</>
|
||||
</p>
|
||||
|
||||
{props.transcriptDownloadLinks.map((downloadLink, index) => {
|
||||
{props.transcriptDownloadLinks.map((_, index) => {
|
||||
return (
|
||||
<div
|
||||
key={downloadLink}
|
||||
key={`transcript-${index}`}
|
||||
style={{
|
||||
backgroundColor: "#F3F4F6",
|
||||
padding: "32px",
|
||||
@@ -87,7 +87,6 @@ export const DailyVideoDownloadTranscriptEmail = (
|
||||
}}>
|
||||
{props.date} Transcript {index + 1}
|
||||
</p>
|
||||
<CallToAction label={props.language("download_transcript")} href={downloadLink} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -21,6 +21,18 @@ export default class AttendeeDailyVideoDownloadTranscriptEmail extends BaseEmail
|
||||
this.t = attendee.language.translate;
|
||||
}
|
||||
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
|
||||
const attachments = await Promise.all(
|
||||
this.transcriptDownloadLinks.map(async (url, index) => {
|
||||
const response = await fetch(url);
|
||||
const buffer = await response.arrayBuffer();
|
||||
return {
|
||||
filename: `transcript-${index + 1}.vtt`,
|
||||
content: Buffer.from(buffer),
|
||||
contentType: "text/vtt",
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
to: `${this.attendee.name} <${this.attendee.email}>`,
|
||||
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
|
||||
@@ -36,6 +48,7 @@ export default class AttendeeDailyVideoDownloadTranscriptEmail extends BaseEmail
|
||||
language: this.t,
|
||||
name: this.attendee.name,
|
||||
}),
|
||||
attachments,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,18 @@ export default class OrganizerDailyVideoDownloadTranscriptEmail extends BaseEmai
|
||||
this.t = this.calEvent.organizer.language.translate;
|
||||
}
|
||||
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
|
||||
const attachments = await Promise.all(
|
||||
this.transcriptDownloadLinks.map(async (url, index) => {
|
||||
const response = await fetch(url);
|
||||
const buffer = await response.arrayBuffer();
|
||||
return {
|
||||
filename: `transcript-${index + 1}.vtt`,
|
||||
content: Buffer.from(buffer),
|
||||
contentType: "text/vtt",
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
to: `${this.calEvent.organizer.email}>`,
|
||||
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
|
||||
@@ -35,6 +47,7 @@ export default class OrganizerDailyVideoDownloadTranscriptEmail extends BaseEmai
|
||||
language: this.t,
|
||||
name: this.calEvent.organizer.name,
|
||||
}),
|
||||
attachments,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ const BookerComponent = ({
|
||||
areInstantMeetingParametersSet = false,
|
||||
userLocale,
|
||||
hasValidLicense,
|
||||
renderCaptcha,
|
||||
}: BookerProps & WrappedBookerProps) => {
|
||||
const searchParams = useCompatSearchParams();
|
||||
const isPlatformBookerEmbed = useIsPlatformBookerEmbed();
|
||||
@@ -166,6 +167,7 @@ const BookerComponent = ({
|
||||
return bookerState === "booking" ? (
|
||||
<BookEventForm
|
||||
key={key}
|
||||
renderCaptcha={renderCaptcha}
|
||||
onCancel={() => {
|
||||
setSelectedTimeslot(null);
|
||||
if (seatedEventData.bookingUid) {
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import type { TFunction } from "next-i18next";
|
||||
import { Trans } from "next-i18next";
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { FieldError } from "react-hook-form";
|
||||
|
||||
import { useIsPlatformBookerEmbed } from "@calcom/atoms/monorepo";
|
||||
import type { BookerEvent } from "@calcom/features/bookings/types";
|
||||
import { WEBSITE_PRIVACY_POLICY_URL, WEBSITE_TERMS_URL } from "@calcom/lib/constants";
|
||||
import {
|
||||
WEBSITE_PRIVACY_POLICY_URL,
|
||||
WEBSITE_TERMS_URL,
|
||||
CLOUDFLARE_SITE_ID,
|
||||
CLOUDFLARE_USE_TURNSTILE_IN_BOOKER,
|
||||
} from "@calcom/lib/constants";
|
||||
import { getPaymentAppData } from "@calcom/lib/getPaymentAppData";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Alert, Button, EmptyScreen, Form } from "@calcom/ui";
|
||||
@@ -17,6 +23,8 @@ import type { IUseBookingErrors, IUseBookingLoadingStates } from "../hooks/useBo
|
||||
import { BookingFields } from "./BookingFields";
|
||||
import { FormSkeleton } from "./Skeleton";
|
||||
|
||||
const TurnstileCaptcha = dynamic(() => import("@calcom/features/auth/Turnstile"), { ssr: false });
|
||||
|
||||
type BookEventFormProps = {
|
||||
onCancel?: () => void;
|
||||
onSubmit: () => void;
|
||||
@@ -29,6 +37,7 @@ type BookEventFormProps = {
|
||||
extraOptions: Record<string, string | string[]>;
|
||||
isPlatform?: boolean;
|
||||
isVerificationCodeSending: boolean;
|
||||
renderCaptcha?: boolean;
|
||||
};
|
||||
|
||||
export const BookEventForm = ({
|
||||
@@ -45,6 +54,7 @@ export const BookEventForm = ({
|
||||
extraOptions,
|
||||
isVerificationCodeSending,
|
||||
isPlatform = false,
|
||||
renderCaptcha,
|
||||
}: Omit<BookEventFormProps, "event"> & {
|
||||
eventQuery: {
|
||||
isError: boolean;
|
||||
@@ -61,6 +71,10 @@ export const BookEventForm = ({
|
||||
const isInstantMeeting = useBookerStore((state) => state.isInstantMeeting);
|
||||
const isPlatformBookerEmbed = useIsPlatformBookerEmbed();
|
||||
|
||||
// Cloudflare Turnstile Captcha
|
||||
const shouldRenderCaptcha =
|
||||
renderCaptcha && CLOUDFLARE_SITE_ID && CLOUDFLARE_USE_TURNSTILE_IN_BOOKER === "1";
|
||||
|
||||
const [responseVercelIdHeader] = useState<string | null>(null);
|
||||
const { t } = useLocale();
|
||||
|
||||
@@ -88,6 +102,8 @@ export const BookEventForm = ({
|
||||
return <Alert severity="warning" message={t("error_booking_event")} />;
|
||||
}
|
||||
|
||||
const watchedCfToken = bookingForm.watch("cfToken");
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<Form
|
||||
@@ -120,6 +136,15 @@ export const BookEventForm = ({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Cloudflare Turnstile Captcha */}
|
||||
{shouldRenderCaptcha ? (
|
||||
<TurnstileCaptcha
|
||||
appearance="interaction-only"
|
||||
onVerify={(token) => {
|
||||
bookingForm.setValue("cfToken", token);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{!isPlatform && (
|
||||
<div className="text-subtle my-3 w-full text-xs">
|
||||
<Trans
|
||||
@@ -143,6 +168,7 @@ export const BookEventForm = ({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPlatformBookerEmbed && (
|
||||
<div className="text-subtle my-3 w-full text-xs">
|
||||
{t("proceeding_agreement")}{" "}
|
||||
@@ -176,9 +202,11 @@ export const BookEventForm = ({
|
||||
{t("back")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
color="primary"
|
||||
disabled={!!shouldRenderCaptcha && !watchedCfToken}
|
||||
loading={
|
||||
loadingStates.creatingBooking ||
|
||||
loadingStates.creatingRecurringBooking ||
|
||||
|
||||
@@ -60,6 +60,7 @@ export const useBookingForm = ({
|
||||
// Key is not really part of form values, but only used to have a key
|
||||
// to set generic error messages on. Needed until RHF has implemented root error keys.
|
||||
globalError: undefined;
|
||||
cfToken?: string;
|
||||
};
|
||||
const isRescheduling = !!rescheduleUid && !!bookingData;
|
||||
|
||||
|
||||
@@ -118,6 +118,7 @@ export type WrappedBookerPropsMain = {
|
||||
bookerLayout: UseBookerLayoutType;
|
||||
verifyEmail: UseVerifyEmailReturnType;
|
||||
customClassNames?: CustomClassNames;
|
||||
renderCaptcha?: boolean;
|
||||
};
|
||||
|
||||
export type WrappedBookerPropsForPlatform = WrappedBookerPropsMain & {
|
||||
|
||||
@@ -180,6 +180,13 @@ async function handler(req: CustomRequest) {
|
||||
throw new HttpError({ statusCode: 400, message: "User not found" });
|
||||
}
|
||||
|
||||
if (!cancellationReason && req.bookingToDelete.userId == userId) {
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: "Cancellation reason is required when you are the host",
|
||||
});
|
||||
}
|
||||
|
||||
// If the booking is a seated event and there is no seatReferenceUid we should validate that logged in user is host
|
||||
if (bookingToDelete.eventType?.seatsPerTimeSlot && !seatReferenceUid) {
|
||||
const userIsHost = bookingToDelete.eventType.hosts.find((host) => {
|
||||
|
||||
@@ -1677,6 +1677,7 @@ describe("handleSeats", () => {
|
||||
});
|
||||
|
||||
req.userId = organizer.id;
|
||||
req.body.cancellationReason = "test cancellation reason";
|
||||
|
||||
await handleCancelBooking(req);
|
||||
|
||||
@@ -1817,6 +1818,7 @@ describe("handleSeats", () => {
|
||||
});
|
||||
|
||||
req.userId = organizer.id;
|
||||
req.body.cancellationReason = "test cancellation reason";
|
||||
|
||||
await handleCancelBooking(req);
|
||||
|
||||
@@ -2682,6 +2684,7 @@ describe("handleSeats", () => {
|
||||
});
|
||||
|
||||
req.userId = organizer.id;
|
||||
req.body.cancellationReason = "test cancellation reason";
|
||||
|
||||
await handleCancelBooking(req);
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
{ enabled: !!teamId }
|
||||
);
|
||||
|
||||
const { hasActiveTeamPlan } = useHasActiveTeamPlan(teamId);
|
||||
const { hasActiveTeamPlan } = useHasActiveTeamPlan();
|
||||
|
||||
const { data: _verifiedEmails } = trpc.viewer.workflows.getVerifiedEmails.useQuery({ teamId });
|
||||
|
||||
|
||||
@@ -159,6 +159,7 @@ export const BOOKER_NUMBER_OF_DAYS_TO_LOAD = parseInt(
|
||||
);
|
||||
|
||||
export const CLOUDFLARE_SITE_ID = process.env.NEXT_PUBLIC_CLOUDFLARE_SITEKEY;
|
||||
export const CLOUDFLARE_USE_TURNSTILE_IN_BOOKER = process.env.NEXT_PUBLIC_CLOUDFLARE_USE_TURNSTILE_IN_BOOKER;
|
||||
export const MINIMUM_NUMBER_OF_ORG_SEATS = 30;
|
||||
export const ORG_SELF_SERVE_ENABLED = process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED === "1";
|
||||
export const ORG_MINIMUM_PUBLISHED_TEAMS_SELF_SERVE = 0;
|
||||
|
||||
@@ -42,7 +42,7 @@ export function useHasEnterprisePlan() {
|
||||
export function useHasActiveTeamPlan(teamId?: number) {
|
||||
if (IS_SELF_HOSTED) return { isPending: false, hasActiveTeamPlan: true };
|
||||
|
||||
const { data, isPending } = trpc.viewer.teams.hasActiveTeamPlan.useQuery({ teamId });
|
||||
const { data, isPending } = trpc.viewer.teams.hasActiveTeamPlan.useQuery();
|
||||
|
||||
return { isPending, hasActiveTeamPlan: !!data };
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { HttpError } from "../http-error";
|
||||
const TURNSTILE_SECRET_ID = process.env.CLOUDFLARE_TURNSTILE_SECRET;
|
||||
|
||||
export async function checkCfTurnstileToken({ token, remoteIp }: { token?: string; remoteIp: string }) {
|
||||
// This means the instant doesnt have turnstile enabled - we skip the check and just return success.
|
||||
// This means the instance doesnt have turnstile enabled - we skip the check and just return success.
|
||||
// OR the instance is running in CI so we skip these checks also
|
||||
if (!TURNSTILE_SECRET_ID || !!process.env.NEXT_PUBLIC_IS_E2E) {
|
||||
return {
|
||||
|
||||
@@ -232,6 +232,7 @@ export const BookerWebWrapper = (props: BookerWebWrapperAtomProps) => {
|
||||
isPlatform={false}
|
||||
areInstantMeetingParametersSet={areInstantMeetingParametersSet}
|
||||
userLocale={session?.user.locale}
|
||||
renderCaptcha
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -270,6 +270,7 @@ export const bookingCreateBodySchema = z.object({
|
||||
routingFormResponseId: z.number().optional(),
|
||||
skipContactOwner: z.boolean().optional(),
|
||||
crmAppSlug: z.string().nullish().optional(),
|
||||
cfToken: z.string().nullish().optional(),
|
||||
|
||||
/**
|
||||
* Holds the corrected responses of the Form for a booking, provided during rerouting
|
||||
|
||||
@@ -12,7 +12,6 @@ import { ZGetSchema } from "./get.schema";
|
||||
import { ZGetMemberAvailabilityInputSchema } from "./getMemberAvailability.schema";
|
||||
import { ZGetMembershipbyUserInputSchema } from "./getMembershipbyUser.schema";
|
||||
import { ZGetUserConnectedAppsInputSchema } from "./getUserConnectedApps.schema";
|
||||
import { ZHasActiveTeamPlanSchema } from "./hasActiveTeamPlan.schema";
|
||||
import { ZHasEditPermissionForUserSchema } from "./hasEditPermissionForUser.schema";
|
||||
import { ZInviteMemberInputSchema } from "./inviteMember/inviteMember.schema";
|
||||
import { ZInviteMemberByTokenSchemaInputSchema } from "./inviteMemberByToken.schema";
|
||||
@@ -223,7 +222,7 @@ export const viewerTeamsRouter = router({
|
||||
);
|
||||
return handler(opts);
|
||||
}),
|
||||
hasActiveTeamPlan: authedProcedure.input(ZHasActiveTeamPlanSchema).query(async (opts) => {
|
||||
hasActiveTeamPlan: authedProcedure.query(async (opts) => {
|
||||
const handler = await importHandler(
|
||||
namespaced("hasActiveTeamPlan"),
|
||||
() => import("./hasActiveTeamPlan.handler")
|
||||
|
||||
@@ -3,39 +3,37 @@ import { IS_SELF_HOSTED } from "@calcom/lib/constants";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import type { THasActiveTeamPlanSchema } from "./hasActiveTeamPlan.schema";
|
||||
|
||||
type HasActiveTeamPlanOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: THasActiveTeamPlanSchema;
|
||||
};
|
||||
|
||||
export const hasActiveTeamPlanHandler = async ({ input, ctx }: HasActiveTeamPlanOptions) => {
|
||||
export const hasActiveTeamPlanHandler = async ({ ctx }: HasActiveTeamPlanOptions) => {
|
||||
if (IS_SELF_HOSTED) return true;
|
||||
|
||||
if (!input.teamId) return false;
|
||||
|
||||
const userId = ctx.user.id;
|
||||
|
||||
// Check if the user is a member of the requested team
|
||||
const team = await prisma.team.findFirst({
|
||||
const teams = await prisma.team.findMany({
|
||||
where: {
|
||||
id: input.teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId: userId,
|
||||
userId: ctx.user.id,
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!team) return false;
|
||||
|
||||
// Get the current team's subscription
|
||||
const teamBillingService = new InternalTeamBilling(team);
|
||||
return await teamBillingService.checkIfTeamHasActivePlan();
|
||||
if (!teams.length) return false;
|
||||
|
||||
// check if user has at least on membership with an active plan
|
||||
for (const team of teams) {
|
||||
const teamBillingService = new InternalTeamBilling(team);
|
||||
const isPlanActive = await teamBillingService.checkIfTeamHasActivePlan();
|
||||
if (isPlanActive) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export default hasActiveTeamPlanHandler;
|
||||
|
||||
@@ -1,7 +1 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZHasActiveTeamPlanSchema = z.object({
|
||||
teamId: z.number().optional(),
|
||||
});
|
||||
|
||||
export type THasActiveTeamPlanSchema = z.infer<typeof ZHasActiveTeamPlanSchema>;
|
||||
export {};
|
||||
|
||||
@@ -84,7 +84,6 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
||||
if (!isCurrentUsernamePremium) {
|
||||
isTeamsPlan = await hasActiveTeamPlanHandler({
|
||||
ctx,
|
||||
input: { teamId: userWorkflow?.teamId ?? undefined },
|
||||
});
|
||||
}
|
||||
const hasPaidPlan = IS_SELF_HOSTED || isCurrentUsernamePremium || isTeamsPlan;
|
||||
|
||||
@@ -357,6 +357,7 @@
|
||||
"NEXT_PUBLIC_TEAM_IMPERSONATION",
|
||||
"NEXT_PUBLIC_VERCEL_URL",
|
||||
"NEXT_PUBLIC_CLOUDFLARE_SITEKEY",
|
||||
"NEXT_PUBLIC_CLOUDFLARE_USE_TURNSTILE_IN_BOOKER",
|
||||
"NEXT_PUBLIC_VERCEL_ENV",
|
||||
"NEXT_PUBLIC_VERCEL_BRANCH_URL",
|
||||
"NEXT_PUBLIC_GTM_ID",
|
||||
|
||||
Reference in New Issue
Block a user