Compare commits

..
10 Commits
Author SHA1 Message Date
GitHub Actions 95bf90402c chore: release v4.9.4
Post release / release (push) Canceled after 0s
2025-01-21 20:23:21 +00:00
Carina WollendorferandCarinaWolli f523ce7795 fix: custom templates on team plan (#18792)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-01-21 20:16:57 +00:00
Joe Au-Yeung 65b70d682d fix: Initialize Hubspot client in CRMService (#18789)
* Initialize Hubspot client in class

* Remove debugging artifact
2025-01-21 16:47:32 -03:00
Alex van Andel 2d518f49dd Double available memory on api/book/event (#18790) 2025-01-21 19:01:58 +00:00
7a9d291d1f fix: handle cancelation reason when user is host (#18681)
* handle cancelation reason when user is host

* add attendee reason warning

* add cancellation reason to tests when we are the host

* req.body and not req

* fix: isloggedIn logic

* use new isLoggedInUser from ssr instead of client

* update e2e tests to fill in reason when hosts

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2025-01-21 20:24:06 +05:30
Keith Williams 1a1a506324 chore: Remove Chromatic GitHub workflow (#18780) 2025-01-21 13:50:53 +00:00
Benny Joo 0f7ecb3345 chore: migrate low-risk 3 /auth pages to App Router (#18753)
* add i18n string

* Update config.matcher in middleware

* migrate auth/oauth2/authorize

* migrate auth/platform/authorize

* migrate auth/signin
2025-01-21 10:41:00 +01:00
Mehul 4f1d71e660 fix: routing form list overflow (#18686) 2025-01-21 10:40:44 +01:00
Udit Takkar 30eb7ce846 feat: add transcription as attachment (#18740)
* feat: add transcription as attachment

* fix: change style
2025-01-21 06:07:10 +00:00
3eb5490df9 feat: Use Cloudflare Turnstile in booker (#18755)
* feat: Use Cloudflare Turnstile in booker

* move files arround

* render turnstile widget and add token to store

* use token from body instead of headers

* watch cf token to re-render disabled state

* Update packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx

* Update packages/features/bookings/lib/create-booking.ts

* ensure bool

* add to recuring event

* fix recuring events

---------

Co-authored-by: sean-brydon <sean@cal.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
2025-01-21 11:27:56 +05:30
45 changed files with 341 additions and 289 deletions
+1
View File
@@ -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"
View File
@@ -5,7 +5,7 @@ import Page from "~/auth/oauth2/authorize-view";
export const generateMetadata = async () => {
return await _generateMetadata(
() => "Authorize",
(t) => t("authorize"),
() => ""
);
};
@@ -5,7 +5,7 @@ import Page from "~/auth/platform/authorize-view";
export const generateMetadata = async () => {
return await _generateMetadata(
() => "Authorize",
(t) => t("authorize"),
() => ""
);
};
+12 -2
View File
@@ -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);
+3
View File
@@ -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}
/>
</>
))}
+1 -1
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "4.9.3",
"version": "4.9.4",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
+8
View File
@@ -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,
-7
View File
@@ -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;
-14
View File
@@ -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 };
+3
View File
@@ -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"]');
+4
View File
@@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+3
View File
@@ -26,6 +26,9 @@
},
"pages/router/embed.tsx": {
"memory": 2048
},
"pages/api/book/event.ts": {
"memory": 2048
}
}
}
+11 -11
View File
@@ -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 });
+1
View File
@@ -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;
+1 -1
View File
@@ -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 };
}
+1 -1
View File
@@ -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
/>
);
};
+1
View File
@@ -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;
+1
View File
@@ -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",