Compare commits

...
41 Commits
Author SHA1 Message Date
Udit Takkar 5d1976295c fix: hover state (#13862)
release.yml / release (push) Canceled after 0s
2024-02-27 14:32:59 +00:00
Morgan e9b1fa1f50 v3.8.4 2024-02-27 16:31:14 +02:00
Udit Takkar 80080dda7a fix: missing userPrimaryEmail in workflows (#13875)
* fix: missing userPrimaryEmail in workflows

* chore

* fix: type err
2024-02-27 09:17:34 -05:00
Udit Takkar 27c25e1f95 chore: use transaction (#13874)
* fix: use transaction

* chore
2024-02-27 10:17:46 -03:00
Shyam Raghuwanshi 858be0f5d8 fix ics-feedcalendar logo not showing (#13886) 2024-02-27 11:43:25 +00:00
Hariom Balhara 68d0090f50 Make sure that state doesnt reset (#13880) 2024-02-27 07:51:11 -03:00
Udit Takkar a43fa13b11 fix: infinite rendering in event setup tab (#13877) 2024-02-26 22:18:58 +00:00
Pritam 2bf6da30b3 fix periodDays dirtystate (#13851) 2024-02-26 19:17:53 +00:00
Alex van Andel d85e0b51e4 fix: Prevent duplicate bookings with idempotency key (#13865)
* fix: Prevent duplicate bookings with idempotency key

* Add idempotencyKey to builder.ts type fix

* Add tests for new idempotency key (booking create)

* Add endTime to idempotency key, it should be possible to book different durations

* Move reschedule before the create so the idempotency key is unset
2024-02-26 18:33:22 +00:00
Carina WollendorferandCarinaWolli 55a317d677 fix: video call url in event location for recurring events (#13842)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-02-26 10:44:07 -05:00
Crowdin Bot 5fff8fc58c New Crowdin translations by Github Action 2024-02-26 14:36:48 +00:00
Udit Takkar 0d68767d0a fix: seed script (#13868) 2024-02-26 14:31:25 +00:00
Shyam Raghuwanshi 2051e87436 fix: username overflow (#13832) 2024-02-23 23:01:43 +00:00
Udit Takkar 77a157a12b fix: show go back button after expiry (#13833) 2024-02-23 22:20:46 +00:00
Omar López c4f25f2c44 build: deploy script for gradual migration (#13834) 2024-02-23 12:38:13 -07:00
Peer Richelsen d967facb63 feat: demodesk app (#13733) 2024-02-23 10:14:37 -05:00
c6dad39fb0 test: Create E2E tests for Analytics Apps (teste2e-analyticsApps) (#13695)
* refactor

* refactor

* refactor

* refactor

* fix failing check

* Update apps.ts

* fix failing check

* fix failing check

* fix failing check

---------

Co-authored-by: gitstart-calcom <gitstart-calcom@users.noreply.github.com>
Co-authored-by: GitStart-Cal.com <121884634+gitstart-calcom@users.noreply.github.com>
2024-02-22 21:54:49 -03:00
zomars eec7f4d7d4 Update yarn.lock 2024-02-22 15:00:52 -07:00
Udit Takkar 95d5824ba9 fix: og image (#13829) 2024-02-22 10:46:33 -07:00
Somay Chauhan 5f95a4d041 fix: New Team Members Join Org as OWNER/ADMIN (#13822) 2024-02-22 11:46:28 -05:00
Keith Williams 8e97444129 v3.8.3
release.yml / release (push) Canceled after 0s
2024-02-22 10:16:18 -03:00
Crowdin Bot 8d8264b5f5 New Crowdin translations by Github Action 2024-02-22 12:48:57 +00:00
2ad81221d0 chore: Added a period after undone (#13824)
* Added period after undone

* chore: undo yarn.lock

* Revert yarn.lock

---------

Co-authored-by: Udit Takkar <udit222001@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-02-22 09:44:43 -03:00
3d1ac4523f fix: Update credentialId when null for a given selectedCalendar (#13735)
* fix: Update credentialId when null for a given selectedCalendar

* Add returnTo return path

* chore

* chore: undo return

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
2024-02-22 12:05:46 +00:00
Alex van Andel b38807cb0f fix: Type check failure in occupiedSeats (#13805) 2024-02-22 10:27:55 +00:00
Peer Richelsen 11fb387370 feat: let org admins change usernames (#13384) 2024-02-22 07:48:46 +00:00
Crowdin Bot 1546f9115d New Crowdin translations by Github Action 2024-02-22 07:42:13 +00:00
Peer RichelsenandUdit Takkar 40ecd5dd9d chore: removing wrong translations about {{consoleUrl}} (#13802)
* removing wrong translations

* fixed source string

* revert: eng translation

* chore: remove translation

* chore

---------

Co-authored-by: Udit Takkar <udit222001@gmail.com>
2024-02-22 07:38:47 +00:00
Omar López ac6ac14a23 fix: more og avatar fixes (#13817) 2024-02-22 07:25:31 +00:00
Bailey Pumfleet c6534160e4 chore: Shorten "aplicaciones instaladas" to "apps instaladas" (#13806) 2024-02-22 07:21:43 +00:00
Keith Williams aa2db562b8 fix: Bookings API (#13815) 2024-02-21 16:05:53 -07:00
Carina WollendorferandCarinaWolli b7ed314be2 fix: log more info on sendEmail error (#13813)
* add from and subject to error message

* remove comment

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-02-21 20:59:45 +00:00
Udit TakkarandCarinaWolli d102268b33 fix: sdk action events (#13755)
* fix: sdk action events

* fix: type error

* fix: type err

* fix: reschedule event

* chore: remove timezone

* fix: feedback

* chore: use bookingInfo

* fix type error

* fix type errror

* add cancellation reason

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-02-21 14:25:24 -05:00
Somay ChauhanandUdit Takkar f4397d6593 fix: error on signup page while inviting a member to an org (#13807)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-02-21 18:49:51 +00:00
Keith Williams 192603a1fa fix: No available users found near midnight (#13801)
* fix: No available users found near midnight

* Update date-ranges.ts
2024-02-21 10:58:38 -07:00
Keith Williams a3d969680f chore: Including original start and end dates from request in log (#13800) 2024-02-21 13:22:42 -03:00
Crowdin Bot adc3b3aec6 New Crowdin translations by Github Action 2024-02-21 15:27:50 +00:00
sean-brydonandUdit Takkar 92286e2453 feat: signup redirect query param after onboarding (#13761)
* feat:redirect - if signup url has url to goto

* fix: dont throw if url is invalid - just dont set

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-02-21 20:53:09 +05:30
51b2a15cb0 feat: Settings for dashboard theme that is separate from system (#13779)
* feat(db): add new col: user appTheme

* feat(event-types page): apply appTheme color

* add appTheme for trpc

* feat: mutate main page theme

* feat: add english translation for app theme update section

* modify keys in common (en)

* add woring

* Revert yarn.lock to its state before unintended changes

* feat(i18n): add back the i18n string (for dark/light mode switching)

* refactor: refactor ThemeLabel

* fix: add new user field "appTheme" to test files

* chore: modify TODO comment

* chore: update common.json (en) for "theme"

since we had appTheme now, I think it's better to update the wording here to avoid confusion

* fix: update button's data-testid to fix e2e test error

* chore: remove comment

* fix: fix brand-color-not-apply bug

* solve type error

* fix: fix type error

* fix: use correct storageKey for booker / dashboard

* fix: tidy up

* fix: skeleton

---------

Co-authored-by: swh00tw <a6140000@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
2024-02-21 13:02:40 +00:00
Morgan fa9a099dd6 fix: prefillFormParams guest type (#13798) 2024-02-21 12:36:48 +00:00
ErikandOmar López 83ceb46cca chore: Refactor feature flags (#13734)
Co-authored-by: Omar López <zomars@me.com>
2024-02-20 21:49:48 -03:00
103 changed files with 1287 additions and 258 deletions

No files matched your search

+4
View File
@@ -74,6 +74,7 @@ export const schemaUserBaseBodyParams = User.pick({
timeZone: true, timeZone: true,
weekStart: true, weekStart: true,
theme: true, theme: true,
appTheme: true,
defaultScheduleId: true, defaultScheduleId: true,
locale: true, locale: true,
hideBranding: true, hideBranding: true,
@@ -100,6 +101,7 @@ const schemaUserEditParams = z.object({
hideBranding: z.boolean().optional(), hideBranding: z.boolean().optional(),
timeZone: timeZone.optional(), timeZone: timeZone.optional(),
theme: z.nativeEnum(theme).optional().nullable(), theme: z.nativeEnum(theme).optional().nullable(),
appTheme: z.nativeEnum(theme).optional().nullable(),
timeFormat: z.nativeEnum(timeFormat).optional(), timeFormat: z.nativeEnum(timeFormat).optional(),
defaultScheduleId: z defaultScheduleId: z
.number() .number()
@@ -122,6 +124,7 @@ const schemaUserCreateParams = z.object({
hideBranding: z.boolean().optional(), hideBranding: z.boolean().optional(),
timeZone: timeZone.optional(), timeZone: timeZone.optional(),
theme: z.nativeEnum(theme).optional().nullable(), theme: z.nativeEnum(theme).optional().nullable(),
appTheme: z.nativeEnum(theme).optional().nullable(),
timeFormat: z.nativeEnum(timeFormat).optional(), timeFormat: z.nativeEnum(timeFormat).optional(),
defaultScheduleId: z defaultScheduleId: z
.number() .number()
@@ -159,6 +162,7 @@ export const schemaUserReadPublic = User.pick({
weekStart: true, weekStart: true,
endTime: true, endTime: true,
bufferTime: true, bufferTime: true,
appTheme: true,
theme: true, theme: true,
defaultScheduleId: true, defaultScheduleId: true,
locale: true, locale: true,
+20 -1
View File
@@ -1,6 +1,7 @@
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { sdkActionManager } from "@calcom/embed-core/embed-iframe";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import type { RecurringEvent } from "@calcom/types/Calendar"; import type { RecurringEvent } from "@calcom/types/Calendar";
@@ -23,13 +24,22 @@ type Props = {
theme: string | null; theme: string | null;
allRemainingBookings: boolean; allRemainingBookings: boolean;
seatReferenceUid?: string; seatReferenceUid?: string;
bookingCancelledEventProps: {
booking: unknown;
organizer: {
name: string;
email: string;
timeZone?: string;
};
eventType: unknown;
};
}; };
export default function CancelBooking(props: Props) { export default function CancelBooking(props: Props) {
const [cancellationReason, setCancellationReason] = useState<string>(""); const [cancellationReason, setCancellationReason] = useState<string>("");
const { t } = useLocale(); const { t } = useLocale();
const router = useRouter(); const router = useRouter();
const { booking, allRemainingBookings, seatReferenceUid } = props; const { booking, allRemainingBookings, seatReferenceUid, bookingCancelledEventProps } = props;
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const telemetry = useTelemetry(); const telemetry = useTelemetry();
const [error, setError] = useState<string | null>(booking ? null : t("booking_already_cancelled")); const [error, setError] = useState<string | null>(booking ? null : t("booking_already_cancelled"));
@@ -97,8 +107,17 @@ export default function CancelBooking(props: Props) {
method: "POST", method: "POST",
}); });
const bookingWithCancellationReason = {
...(bookingCancelledEventProps.booking as object),
cancellationReason,
} as unknown;
if (res.status >= 200 && res.status < 300) { if (res.status >= 200 && res.status < 300) {
// tested by apps/web/playwright/booking-pages.e2e.ts // tested by apps/web/playwright/booking-pages.e2e.ts
sdkActionManager?.fire("bookingCancelled", {
...bookingCancelledEventProps,
booking: bookingWithCancellationReason,
});
router.refresh(); router.refresh();
} else { } else {
setLoading(false); setLoading(false);
@@ -360,10 +360,12 @@ export const EventLimitsTab = () => {
title={t("limit_future_bookings")} title={t("limit_future_bookings")}
description={t("limit_future_bookings_description")} description={t("limit_future_bookings_description")}
checked={isChecked} checked={isChecked}
onCheckedChange={(bool) => onCheckedChange={(bool) => {
// formMethods.setValue("periodType", bool ? "ROLLING" : "UNLIMITED", { shouldDirty: true }) if (bool && !formMethods.getValues("periodDays")) {
onChange(bool ? "ROLLING" : "UNLIMITED") formMethods.setValue("periodDays", 30, { shouldDirty: true });
}> }
return onChange(bool ? "ROLLING" : "UNLIMITED");
}}>
<div className="border-subtle rounded-b-lg border border-t-0 p-6"> <div className="border-subtle rounded-b-lg border border-t-0 p-6">
<RadioGroup.Root <RadioGroup.Root
value={watchPeriodType} value={watchPeriodType}
@@ -318,7 +318,7 @@ export const EventSetupTab = (
/> />
</div> </div>
<ErrorMessage <ErrorMessage
errors={formMethods.formState.errors.locations?.[index]} errors={formMethods.formState.errors?.locations?.[index]}
name={eventLocationType.defaultValueVariable} name={eventLocationType.defaultValueVariable}
className="text-error my-1 ml-6 text-sm" className="text-error my-1 ml-6 text-sm"
as="div" as="div"
@@ -37,7 +37,7 @@ const UserProfile = () => {
if (context.avatar) { if (context.avatar) {
showToast(t("your_user_profile_updated_successfully"), "success"); showToast(t("your_user_profile_updated_successfully"), "success");
await utils.viewer.me.refetch(); await utils.viewer.me.refetch();
} else { } else
try { try {
if (eventTypes?.length === 0) { if (eventTypes?.length === 0) {
await Promise.all( await Promise.all(
@@ -50,9 +50,11 @@ const UserProfile = () => {
console.error(error); console.error(error);
} }
await utils.viewer.me.refetch(); await utils.viewer.me.refetch();
router.push("/"); const redirectUrl = localStorage.getItem("onBoardingRedirect");
} localStorage.removeItem("onBoardingRedirect");
redirectUrl ? router.push(redirectUrl) : router.push("/");
}, },
onError: () => { onError: () => {
showToast(t("problem_saving_user_profile"), "error"); showToast(t("problem_saving_user_profile"), "error");
+1 -1
View File
@@ -113,7 +113,7 @@ const enum ThemeSupport {
// e.g. Login Page // e.g. Login Page
None = "none", None = "none",
// Entire App except Booking Pages // Entire App except Booking Pages
App = "systemOnly", App = "userConfigured",
// Booking Pages(including Routing Forms) // Booking Pages(including Routing Forms)
Booking = "userConfigured", Booking = "userConfigured",
} }
+5 -3
View File
@@ -127,9 +127,9 @@ const enum ThemeSupport {
// e.g. Login Page // e.g. Login Page
None = "none", None = "none",
// Entire App except Booking Pages // Entire App except Booking Pages
App = "systemOnly", App = "appConfigured",
// Booking Pages(including Routing Forms) // Booking Pages(including Routing Forms)
Booking = "userConfigured", Booking = "bookingConfigured",
} }
type CalcomThemeProps = PropsWithChildren< type CalcomThemeProps = PropsWithChildren<
@@ -144,8 +144,10 @@ const CalcomThemeProvider = (props: CalcomThemeProps) => {
const embedNamespace = getEmbedNamespace(props.router.query); const embedNamespace = getEmbedNamespace(props.router.query);
const isEmbedMode = typeof embedNamespace === "string"; const isEmbedMode = typeof embedNamespace === "string";
const themeProviderProps = getThemeProviderProps({ props, isEmbedMode, embedNamespace });
return ( return (
<ThemeProvider {...getThemeProviderProps({ props, isEmbedMode, embedNamespace })}> <ThemeProvider {...themeProviderProps}>
{/* Embed Mode can be detected reliably only on client side here as there can be static generated pages as well which can't determine if it's embed mode at backend */} {/* Embed Mode can be detected reliably only on client side here as there can be static generated pages as well which can't determine if it's embed mode at backend */}
{/* color-scheme makes background:transparent not work in iframe which is required by embed. */} {/* color-scheme makes background:transparent not work in iframe which is required by embed. */}
{typeof window !== "undefined" && !isEmbedMode && ( {typeof window !== "undefined" && !isEmbedMode && (
+3 -3
View File
@@ -1,11 +1,11 @@
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils"; import { getFeatureFlag } from "@calcom/features/flags/server/utils";
// If feature flag is disabled, return not found on getServerSideProps // If feature flag is disabled, return not found on getServerSideProps
export const getServerSideProps = async () => { export const getServerSideProps = async () => {
const prisma = await import("@calcom/prisma").then((mod) => mod.default); const prisma = await import("@calcom/prisma").then((mod) => mod.default);
const flags = await getFeatureFlagMap(prisma); const insightsEnabled = await getFeatureFlag(prisma, "insights");
if (flags.insights === false) { if (!insightsEnabled) {
return { return {
notFound: true, notFound: true,
} as const; } as const;
@@ -1,12 +1,12 @@
import type { GetServerSidePropsContext } from "next"; import type { GetServerSidePropsContext } from "next";
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils"; import { getFeatureFlag } from "@calcom/features/flags/server/utils";
export const getServerSideProps = async (context: GetServerSidePropsContext) => { export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const prisma = await import("@calcom/prisma").then((mod) => mod.default); const prisma = await import("@calcom/prisma").then((mod) => mod.default);
const flags = await getFeatureFlagMap(prisma); const organizations = await getFeatureFlag(prisma, "organizations");
// Check if organizations are enabled // Check if organizations are enabled
if (flags["organizations"] !== true) { if (!organizations) {
return { return {
notFound: true, notFound: true,
} as const; } as const;
+14 -3
View File
@@ -4,7 +4,7 @@ import { z } from "zod";
import { getOrgUsernameFromEmail } from "@calcom/features/auth/signup/utils/getOrgUsernameFromEmail"; import { getOrgUsernameFromEmail } from "@calcom/features/auth/signup/utils/getOrgUsernameFromEmail";
import { checkPremiumUsername } from "@calcom/features/ee/common/lib/checkPremiumUsername"; import { checkPremiumUsername } from "@calcom/features/ee/common/lib/checkPremiumUsername";
import { isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml"; import { isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml";
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils"; import { getFeatureFlag } from "@calcom/features/flags/server/utils";
import { IS_SELF_HOSTED, WEBAPP_URL } from "@calcom/lib/constants"; import { IS_SELF_HOSTED, WEBAPP_URL } from "@calcom/lib/constants";
import slugify from "@calcom/lib/slugify"; import slugify from "@calcom/lib/slugify";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
@@ -24,11 +24,22 @@ const querySchema = z.object({
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
const prisma = await import("@calcom/prisma").then((mod) => mod.default); const prisma = await import("@calcom/prisma").then((mod) => mod.default);
const flags = await getFeatureFlagMap(prisma); const signupDisabled = await getFeatureFlag(prisma, "disable-signup");
const ssr = await ssrInit(ctx); const ssr = await ssrInit(ctx);
const token = z.string().optional().parse(ctx.query.token); const token = z.string().optional().parse(ctx.query.token);
const redirectUrlData = z
.string()
.refine((value) => value.startsWith(WEBAPP_URL), {
params: (value: string) => ({ value }),
message: "Redirect URL must start with 'cal.com'",
})
.optional()
.safeParse(ctx.query.redirect);
const redirectUrl = redirectUrlData.success && redirectUrlData.data ? redirectUrlData.data : null;
const props = { const props = {
redirectUrl,
isGoogleLoginEnabled: IS_GOOGLE_LOGIN_ENABLED, isGoogleLoginEnabled: IS_GOOGLE_LOGIN_ENABLED,
isSAMLLoginEnabled, isSAMLLoginEnabled,
trpcState: ssr.dehydrate(), trpcState: ssr.dehydrate(),
@@ -38,7 +49,7 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
// username + email prepopulated from query params // username + email prepopulated from query params
const { username: preFillusername, email: prefilEmail } = querySchema.parse(ctx.query); const { username: preFillusername, email: prefilEmail } = querySchema.parse(ctx.query);
if ((process.env.NEXT_PUBLIC_DISABLE_SIGNUP === "true" && !token) || flags["disable-signup"]) { if ((process.env.NEXT_PUBLIC_DISABLE_SIGNUP === "true" && !token) || signupDisabled) {
return { return {
notFound: true, notFound: true,
} as const; } as const;
@@ -1,7 +1,7 @@
import type { GetServerSidePropsContext } from "next"; import type { GetServerSidePropsContext } from "next";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils"; import { getFeatureFlag } from "@calcom/features/flags/server/utils";
import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client"; import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client";
import logger from "@calcom/lib/logger"; import logger from "@calcom/lib/logger";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML"; import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
@@ -37,12 +37,11 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
// Provided by Rewrite from next.config.js // Provided by Rewrite from next.config.js
const isOrgProfile = context.query?.isOrgProfile === "1"; const isOrgProfile = context.query?.isOrgProfile === "1";
const flags = await getFeatureFlagMap(prisma); const organizationsEnabled = await getFeatureFlag(prisma, "organizations");
const isOrganizationFeatureEnabled = flags["organizations"];
log.debug("getServerSideProps", { log.debug("getServerSideProps", {
isOrgProfile, isOrgProfile,
isOrganizationFeatureEnabled, isOrganizationFeatureEnabled: organizationsEnabled,
isValidOrgDomain, isValidOrgDomain,
currentOrgDomain, currentOrgDomain,
}); });
@@ -74,7 +73,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
if ( if (
(!isValidOrgDomain && team?.parent) || (!isValidOrgDomain && team?.parent) ||
(!isValidOrgDomain && !!metadata?.isOrganization) || (!isValidOrgDomain && !!metadata?.isOrganization) ||
!isOrganizationFeatureEnabled !organizationsEnabled
) { ) {
return { notFound: true } as const; return { notFound: true } as const;
} }
@@ -19,7 +19,6 @@ import { getEventName } from "@calcom/core/event";
import type { ConfigType } from "@calcom/dayjs"; import type { ConfigType } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs"; import dayjs from "@calcom/dayjs";
import { import {
sdkActionManager,
useEmbedNonStylesConfig, useEmbedNonStylesConfig,
useIsBackgroundTransparent, useIsBackgroundTransparent,
useIsEmbed, useIsEmbed,
@@ -193,22 +192,6 @@ export default function Success(props: PageProps) {
}, [telemetry]); */ }, [telemetry]); */
useEffect(() => { useEffect(() => {
const users = eventType.users;
if (!sdkActionManager) return;
// TODO: We should probably make it consistent with Webhook payload. Some data is not available here, as and when requirement comes we can add
sdkActionManager.fire("bookingSuccessful", {
booking: bookingInfo,
eventType,
date: date.toString(),
duration: calculatedDuration,
organizer: {
name: users[0].name || "Nameless",
email: bookingInfo?.userPrimaryEmail || users[0].email || "Email-less",
timeZone: users[0].timeZone,
},
confirmed: !needsConfirmation,
// TODO: Add payment details
});
setDate( setDate(
date.tz(localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess() || "Europe/London") date.tz(localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess() || "Europe/London")
); );
@@ -294,6 +277,16 @@ export default function Success(props: PageProps) {
const providerName = guessEventLocationType(location)?.label; const providerName = guessEventLocationType(location)?.label;
const rescheduleProviderName = guessEventLocationType(rescheduleLocation)?.label; const rescheduleProviderName = guessEventLocationType(rescheduleLocation)?.label;
const bookingCancelledEventProps = {
booking: bookingInfo,
organizer: {
name: bookingInfo?.user?.name || "Nameless",
email: bookingInfo?.userPrimaryEmail || bookingInfo?.user?.email || "Email-less",
timeZone: bookingInfo?.user?.timeZone,
},
eventType,
};
return ( return (
<div className={isEmbed ? "" : "h-screen"} data-testid="success-page"> <div className={isEmbed ? "" : "h-screen"} data-testid="success-page">
{!isEmbed && ( {!isEmbed && (
@@ -610,6 +603,7 @@ export default function Success(props: PageProps) {
theme={isSuccessBookingPage ? props.profile.theme : "light"} theme={isSuccessBookingPage ? props.profile.theme : "light"}
allRemainingBookings={allRemainingBookings} allRemainingBookings={allRemainingBookings}
seatReferenceUid={seatReferenceUid} seatReferenceUid={seatReferenceUid}
bookingCancelledEventProps={bookingCancelledEventProps}
/> />
</> </>
))} ))}
@@ -266,7 +266,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
beforeEventBuffer: eventType.beforeEventBuffer, beforeEventBuffer: eventType.beforeEventBuffer,
eventName: eventType.eventName || "", eventName: eventType.eventName || "",
scheduleName: eventType.scheduleName, scheduleName: eventType.scheduleName,
periodDays: eventType.periodDays || 30, periodDays: eventType.periodDays,
requiresBookerEmailVerification: eventType.requiresBookerEmailVerification, requiresBookerEmailVerification: eventType.requiresBookerEmailVerification,
seatsPerTimeSlot: eventType.seatsPerTimeSlot, seatsPerTimeSlot: eventType.seatsPerTimeSlot,
seatsShowAttendees: eventType.seatsShowAttendees, seatsShowAttendees: eventType.seatsShowAttendees,
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@calcom/web", "name": "@calcom/web",
"version": "3.8.2", "version": "3.8.4",
"private": true, "private": true,
"scripts": { "scripts": {
"analyze": "ANALYZE=true next build", "analyze": "ANALYZE=true next build",
@@ -95,6 +95,7 @@ const IntegrationsContainer = ({
className="mb-6" className="mb-6"
actions={ actions={
<Button <Button
data-testid="add-apps"
href={variant ? `/apps/categories/${variant}` : "/apps"} href={variant ? `/apps/categories/${variant}` : "/apps"}
color="secondary" color="secondary"
StartIcon={Plus}> StartIcon={Plus}>
@@ -11,8 +11,10 @@ import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { APP_NAME } from "@calcom/lib/constants"; import { APP_NAME } from "@calcom/lib/constants";
import { DEFAULT_LIGHT_BRAND_COLOR, DEFAULT_DARK_BRAND_COLOR } from "@calcom/lib/constants"; import { DEFAULT_LIGHT_BRAND_COLOR, DEFAULT_DARK_BRAND_COLOR } from "@calcom/lib/constants";
import { checkWCAGContrastColor } from "@calcom/lib/getBrandColours"; import { checkWCAGContrastColor } from "@calcom/lib/getBrandColours";
import useGetBrandingColours from "@calcom/lib/getBrandColours";
import { useHasPaidPlan } from "@calcom/lib/hooks/useHasPaidPlan"; import { useHasPaidPlan } from "@calcom/lib/hooks/useHasPaidPlan";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import useTheme from "@calcom/lib/hooks/useTheme";
import { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts"; import { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts";
import type { userMetadata } from "@calcom/prisma/zod-utils"; import type { userMetadata } from "@calcom/prisma/zod-utils";
import { trpc } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react";
@@ -29,6 +31,7 @@ import {
SkeletonText, SkeletonText,
SettingsToggle, SettingsToggle,
UpgradeTeamsBadge, UpgradeTeamsBadge,
useCalcomTheme,
} from "@calcom/ui"; } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper"; import PageWrapper from "@components/PageWrapper";
@@ -41,10 +44,10 @@ const SkeletonLoader = ({ title, description }: { title: string; description: st
<SkeletonText className="h-8 w-1/3" /> <SkeletonText className="h-8 w-1/3" />
</div> </div>
<div className="border-subtle space-y-6 border-x px-4 py-6 sm:px-6"> <div className="border-subtle space-y-6 border-x px-4 py-6 sm:px-6">
<div className="flex items-center justify-center"> <div className="[&>*]:bg-emphasis flex w-full items-center justify-center gap-x-2 [&>*]:animate-pulse">
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" /> <div className="h-32 flex-1 rounded-md p-5" />
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" /> <div className="h-32 flex-1 rounded-md p-5" />
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" /> <div className="h-32 flex-1 rounded-md p-5" />
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<SkeletonText className="h-8 w-1/3" /> <SkeletonText className="h-8 w-1/3" />
@@ -62,6 +65,26 @@ const SkeletonLoader = ({ title, description }: { title: string; description: st
); );
}; };
const useBrandColors = (
currentTheme: string | null,
{
brandColor,
darkBrandColor,
}: {
brandColor?: string | null;
darkBrandColor?: string | null;
}
): void => {
const brandTheme = useGetBrandingColours({
lightVal: brandColor,
darkVal: darkBrandColor,
});
const selectedTheme = currentTheme ? brandTheme[currentTheme as "light" | "dark"] : {};
useCalcomTheme({
root: selectedTheme,
});
};
const AppearanceView = ({ const AppearanceView = ({
user, user,
hasPaidPlan, hasPaidPlan,
@@ -77,6 +100,22 @@ const AppearanceView = ({
user?.brandColor !== DEFAULT_LIGHT_BRAND_COLOR || user?.darkBrandColor !== DEFAULT_DARK_BRAND_COLOR user?.brandColor !== DEFAULT_LIGHT_BRAND_COLOR || user?.darkBrandColor !== DEFAULT_DARK_BRAND_COLOR
); );
const [hideBrandingValue, setHideBrandingValue] = useState(user?.hideBranding ?? false); const [hideBrandingValue, setHideBrandingValue] = useState(user?.hideBranding ?? false);
useTheme(user?.appTheme);
useBrandColors(user?.appTheme ?? null, {
brandColor: user?.brandColor,
darkBrandColor: user?.darkBrandColor,
});
const userAppThemeFormMethods = useForm({
defaultValues: {
appTheme: user.appTheme,
},
});
const {
formState: { isSubmitting: isUserAppThemeSubmitting, isDirty: isUserAppThemeDirty },
reset: resetUserAppThemeReset,
} = userAppThemeFormMethods;
const userThemeFormMethods = useForm({ const userThemeFormMethods = useForm({
defaultValues: { defaultValues: {
@@ -131,6 +170,7 @@ const AppearanceView = ({
resetBrandColorsThemeReset({ brandColor: data.brandColor, darkBrandColor: data.darkBrandColor }); resetBrandColorsThemeReset({ brandColor: data.brandColor, darkBrandColor: data.darkBrandColor });
resetBookerLayoutThemeReset({ metadata: data.metadata }); resetBookerLayoutThemeReset({ metadata: data.metadata });
resetUserThemeReset({ theme: data.theme }); resetUserThemeReset({ theme: data.theme });
resetUserAppThemeReset({ appTheme: data.appTheme });
}, },
onError: (error) => { onError: (error) => {
if (error.message) { if (error.message) {
@@ -147,6 +187,56 @@ const AppearanceView = ({
return ( return (
<div> <div>
<Meta title={t("appearance")} description={t("appearance_description")} borderInShellHeader={false} /> <Meta title={t("appearance")} description={t("appearance_description")} borderInShellHeader={false} />
<div className="border-subtle mt-6 flex items-center rounded-t-lg border p-6 text-sm">
<div>
<p className="text-default text-base font-semibold">{t("app_theme")}</p>
<p className="text-default">{t("app_theme_applies_note")}</p>
</div>
</div>
<Form
form={userAppThemeFormMethods}
handleSubmit={(values) => {
mutation.mutate({
appTheme: values.appTheme ?? null,
});
}}>
<div className="border-subtle flex flex-col justify-between border-x px-6 py-8 sm:flex-row">
<ThemeLabel
variant="system"
value={undefined}
label={t("theme_system")}
defaultChecked={user.appTheme === null}
register={userAppThemeFormMethods.register}
fieldName="appTheme"
/>
<ThemeLabel
variant="light"
value="light"
label={t("light")}
defaultChecked={user.appTheme === "light"}
register={userAppThemeFormMethods.register}
fieldName="appTheme"
/>
<ThemeLabel
variant="dark"
value="dark"
label={t("dark")}
defaultChecked={user.appTheme === "dark"}
register={userAppThemeFormMethods.register}
fieldName="appTheme"
/>
</div>
<SectionBottomActions className="mb-6" align="end">
<Button
disabled={isUserAppThemeSubmitting || !isUserAppThemeDirty}
type="submit"
data-testid="update-app-theme-btn"
color="primary">
{t("update")}
</Button>
</SectionBottomActions>
</Form>
<div className="border-subtle mt-6 flex items-center rounded-t-lg border p-6 text-sm"> <div className="border-subtle mt-6 flex items-center rounded-t-lg border p-6 text-sm">
<div> <div>
<p className="text-default text-base font-semibold">{t("theme")}</p> <p className="text-default text-base font-semibold">{t("theme")}</p>
+7
View File
@@ -159,6 +159,7 @@ export default function Signup({
isGoogleLoginEnabled, isGoogleLoginEnabled,
isSAMLLoginEnabled, isSAMLLoginEnabled,
orgAutoAcceptEmail, orgAutoAcceptEmail,
redirectUrl,
}: SignupProps) { }: SignupProps) {
const [premiumUsername, setPremiumUsername] = useState(false); const [premiumUsername, setPremiumUsername] = useState(false);
const [usernameTaken, setUsernameTaken] = useState(false); const [usernameTaken, setUsernameTaken] = useState(false);
@@ -180,6 +181,12 @@ export default function Signup({
formState: { isSubmitting, errors, isSubmitSuccessful }, formState: { isSubmitting, errors, isSubmitSuccessful },
} = formMethods; } = formMethods;
useEffect(() => {
if (redirectUrl) {
localStorage.setItem("onBoardingRedirect", redirectUrl);
}
}, [redirectUrl]);
const loadingSubmitState = isSubmitSuccessful || isSubmitting; const loadingSubmitState = isSubmitSuccessful || isSubmitting;
const handleErrorsAndStripe = async (resp: Response) => { const handleErrorsAndStripe = async (resp: Response) => {
+1 -1
View File
@@ -123,7 +123,7 @@ function TeamPage({
(mem) => mem.subteams?.includes(ch.slug) && mem.accepted (mem) => mem.subteams?.includes(ch.slug) && mem.accepted
).length; ).length;
return ( return (
<li key={i} className="hover:bg-muted w-full"> <li key={i} className="hover:bg-muted w-full rounded-md">
<Link href={`/${ch.slug}`} className="flex items-center justify-between"> <Link href={`/${ch.slug}`} className="flex items-center justify-between">
<div className="flex items-center px-5 py-5"> <div className="flex items-center px-5 py-5">
<div className="ms-3 inline-block truncate"> <div className="ms-3 inline-block truncate">
@@ -0,0 +1,35 @@
import { loginUser } from "../../fixtures/regularBookings";
import { test } from "../../lib/fixtures";
const ALL_APPS = ["fathom", "matomo", "plausible", "ga4", "gtm", "metapixel"];
test.describe("Check analytics Apps", () => {
test.beforeEach(async ({ page, users }) => {
await loginUser(users);
await page.goto("/apps/");
});
test("Check analytics Apps", async ({ appsPage, page }) => {
await appsPage.goToAppsCategory("analytics");
await appsPage.installApp("fathom");
await appsPage.goBackToAppsPage();
await appsPage.installApp("matomo");
await appsPage.goBackToAppsPage();
await appsPage.installApp("plausible");
await appsPage.goBackToAppsPage();
await appsPage.installApp("ga4");
await appsPage.goBackToAppsPage();
await appsPage.installApp("gtm");
await appsPage.goBackToAppsPage();
await appsPage.installApp("metapixel");
await appsPage.goBackToAppsPage();
await page.goto("/event-types");
await appsPage.goToEventType("30 min");
await appsPage.goToAppsTab();
await appsPage.verifyAppsInfo(0);
for (const app of ALL_APPS) {
await appsPage.activeApp(app);
}
await appsPage.verifyAppsInfo(6);
});
});
+29
View File
@@ -175,6 +175,35 @@ testBothFutureAndLegacyRoutes.describe("pro user", () => {
await expect(page.locator('[data-testid="empty-screen"]')).toBeVisible(); await expect(page.locator('[data-testid="empty-screen"]')).toBeVisible();
}); });
test("can book an unconfirmed event multiple times", async ({ page, users }) => {
await page.locator('[data-testid="event-type-link"]:has-text("Opt in")').click();
await selectFirstAvailableTimeSlotNextMonth(page);
const pageUrl = page.url();
await bookTimeSlot(page);
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
// go back to the booking page to re-book.
await page.goto(pageUrl);
await bookTimeSlot(page, { email: "test2@example.com" });
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
});
test("cannot book an unconfirmed event multiple times with the same email", async ({ page, users }) => {
await page.locator('[data-testid="event-type-link"]:has-text("Opt in")').click();
await selectFirstAvailableTimeSlotNextMonth(page);
const pageUrl = page.url();
await bookTimeSlot(page);
// go back to the booking page to re-book.
await page.goto(pageUrl);
await bookTimeSlot(page);
await expect(page.getByText("Could not book the meeting.")).toBeVisible();
});
test("can book with multiple guests", async ({ page, users }) => { test("can book with multiple guests", async ({ page, users }) => {
const additionalGuests = ["test@gmail.com", "test2@gmail.com"]; const additionalGuests = ["test@gmail.com", "test2@gmail.com"];
+29
View File
@@ -0,0 +1,29 @@
import { expect, type Page } from "@playwright/test";
export function createAppsFixture(page: Page) {
return {
goToAppsCategory: async (category: string) => {
await page.getByTestId(`app-store-category-${category}`).nth(1).click();
await page.goto("apps/categories/analytics");
},
installApp: async (app: string) => {
await page.getByTestId(`app-store-app-card-${app}`).click();
await page.getByTestId("install-app-button").click();
},
goBackToAppsPage: async () => {
await page.getByTestId("add-apps").click();
},
goToEventType: async (eventType: string) => {
await page.getByRole("link", { name: eventType }).click();
},
goToAppsTab: async () => {
await page.getByTestId("vertical-tab-apps").click();
},
activeApp: async (app: string) => {
await page.getByTestId(`${app}-app-switch`).click();
},
verifyAppsInfo: async (activeApps: number) => {
await expect(page.locator(`text=6 apps, ${activeApps} active`)).toBeVisible();
},
};
}
+6
View File
@@ -4,6 +4,7 @@ import { test as base } from "@playwright/test";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
import type { ExpectedUrlDetails } from "../../../../playwright.config"; import type { ExpectedUrlDetails } from "../../../../playwright.config";
import { createAppsFixture } from "../fixtures/apps";
import { createBookingsFixture } from "../fixtures/bookings"; import { createBookingsFixture } from "../fixtures/bookings";
import { createEmailsFixture } from "../fixtures/emails"; import { createEmailsFixture } from "../fixtures/emails";
import { createEmbedsFixture } from "../fixtures/embeds"; import { createEmbedsFixture } from "../fixtures/embeds";
@@ -32,6 +33,7 @@ export interface Fixtures {
workflowPage: ReturnType<typeof createWorkflowPageFixture>; workflowPage: ReturnType<typeof createWorkflowPageFixture>;
features: ReturnType<typeof createFeatureFixture>; features: ReturnType<typeof createFeatureFixture>;
eventTypePage: ReturnType<typeof createEventTypeFixture>; eventTypePage: ReturnType<typeof createEventTypeFixture>;
appsPage: ReturnType<typeof createAppsFixture>;
} }
declare global { declare global {
@@ -104,4 +106,8 @@ export const test = base.extend<Fixtures>({
const eventTypePage = createEventTypeFixture(page); const eventTypePage = createEventTypeFixture(page);
await use(eventTypePage); await use(eventTypePage);
}, },
appsPage: async ({ page }, use) => {
const appsPage = createAppsFixture(page);
await use(appsPage);
},
}); });
@@ -638,7 +638,7 @@
"hide_book_a_team_member_description": "Hide Book a Team Member Button from your public pages.", "hide_book_a_team_member_description": "Hide Book a Team Member Button from your public pages.",
"danger_zone": "Danger zone", "danger_zone": "Danger zone",
"account_deletion_cannot_be_undone":"Be Careful. Account deletion cannot be undone.", "account_deletion_cannot_be_undone":"Be Careful. Account deletion cannot be undone.",
"team_deletion_cannot_be_undone":"Be Careful. Team deletion cannot be undone", "team_deletion_cannot_be_undone":"Be Careful. Team deletion cannot be undone.",
"back": "Back", "back": "Back",
"cancel": "Cancel", "cancel": "Cancel",
"cancel_all_remaining": "Cancel all remaining", "cancel_all_remaining": "Cancel all remaining",
@@ -1473,8 +1473,10 @@
"route_to_the_right_person": "Route to the right person based on the answers to your form", "route_to_the_right_person": "Route to the right person based on the answers to your form",
"form_description": "Create your form to route a booker", "form_description": "Create your form to route a booker",
"copy_link_to_form": "Copy link to form", "copy_link_to_form": "Copy link to form",
"theme": "Theme", "theme": "Booking Page Theme",
"theme_applies_note": "This only applies to your public booking pages", "theme_applies_note": "This only applies to your public booking pages",
"app_theme": "Dashboard Theme",
"app_theme_applies_note": "This only applies to your logged in dashboard",
"theme_system": "System default", "theme_system": "System default",
"add_a_team": "Add a team", "add_a_team": "Add a team",
"add_webhook_description": "Receive meeting data in real-time when something happens in {{appName}}", "add_webhook_description": "Receive meeting data in real-time when something happens in {{appName}}",
@@ -893,7 +893,7 @@
"number_apps_other": "{{count}} aplicaciones", "number_apps_other": "{{count}} aplicaciones",
"trending_apps": "Aplicaciones populares", "trending_apps": "Aplicaciones populares",
"most_popular": "Más popular", "most_popular": "Más popular",
"installed_apps": "Aplicaciones instaladas", "installed_apps": "Apps instaladas",
"free_to_use_apps": "Gratis", "free_to_use_apps": "Gratis",
"no_category_apps": "No hay aplicaciones de {{category}}", "no_category_apps": "No hay aplicaciones de {{category}}",
"all_apps": "Todas las aplicaciones", "all_apps": "Todas las aplicaciones",
@@ -1475,6 +1475,8 @@
"copy_link_to_form": "להעתיק את הקישור לטופס", "copy_link_to_form": "להעתיק את הקישור לטופס",
"theme": "ערכת נושא", "theme": "ערכת נושא",
"theme_applies_note": "חלה רק על דפים של הזמנות ציבוריות", "theme_applies_note": "חלה רק על דפים של הזמנות ציבוריות",
"app_theme": "ערכת עיצוב ללוח בקרה",
"app_theme_applies_note": "חל רק על לוח הבקרה שנכנסת אליו",
"theme_system": "ברירת המחדל של המערכת", "theme_system": "ברירת המחדל של המערכת",
"add_a_team": "הוספת צוות", "add_a_team": "הוספת צוות",
"add_webhook_description": "לקבל נתוני פגישה בזמן אמת כשמשהו קורה ב-{{appName}}", "add_webhook_description": "לקבל נתוני פגישה בזמן אמת כשמשהו קורה ב-{{appName}}",
@@ -1,4 +1,5 @@
{ {
"identity_provider": "Azonosítás szolgáltató",
"trial_days_left": "Még $t(day, {\"count\": {{days}} }) maradt a PRO próbaidőszakodból", "trial_days_left": "Még $t(day, {\"count\": {{days}} }) maradt a PRO próbaidőszakodból",
"day_one": "{{count}} nap", "day_one": "{{count}} nap",
"day_other": "{{count}} nap", "day_other": "{{count}} nap",
@@ -7,6 +8,7 @@
"upgrade_now": "Frissíts most", "upgrade_now": "Frissíts most",
"accept_invitation": "Meghívó elfogadása", "accept_invitation": "Meghívó elfogadása",
"have_any_questions": "Van kérdésed? Itt vagyunk hogy segítsünk.", "have_any_questions": "Van kérdésed? Itt vagyunk hogy segítsünk.",
"verify_email_email_button": "Email igazolása",
"organizer": "Szervező", "organizer": "Szervező",
"rejection_reason": "Lemondás oka", "rejection_reason": "Lemondás oka",
"help": "Segítség", "help": "Segítség",
@@ -160,7 +162,7 @@
"yearly_one": "év", "yearly_one": "év",
"yearly_other": "év", "yearly_other": "év",
"name": "Név", "name": "Név",
"logo": "Logo", "logo": "Logó",
"error": "Hiba", "error": "Hiba",
"type": "Típus", "type": "Típus",
"edit": "Szerkesztés", "edit": "Szerkesztés",
@@ -1447,6 +1447,8 @@
"copy_link_to_form": "将链接复制到表格", "copy_link_to_form": "将链接复制到表格",
"theme": "主题", "theme": "主题",
"theme_applies_note": "这仅适用于您的公开预约页面", "theme_applies_note": "这仅适用于您的公开预约页面",
"app_theme": "仪表板主题",
"app_theme_applies_note": "这仅在您登录仪表板时适用",
"theme_system": "系统默认设置", "theme_system": "系统默认设置",
"add_a_team": "添加团队", "add_a_team": "添加团队",
"add_webhook_description": "当 {{appName}} 上有活动时实时接收会议数据", "add_webhook_description": "当 {{appName}} 上有活动时实时接收会议数据",
+47
View File
@@ -0,0 +1,47 @@
#!/bin/bash
# Only deploy to production
if [ "$VERCEL_ENV" != "production" ]; then exit 0; fi
checkRoute () {
if [ "$1" != '1' ]; then rm -rf $2; fi
}
# These conditionals are used to remove directories from the build that are not needed in production
# This is to reduce the size of the build and prevent OOM errors
checkRoute "$APP_ROUTER_EVENT_TYPES_ENABLED" app/future/event-types
checkRoute "$APP_ROUTER_SETTINGS_ADMIN_ENABLED" app/future/settings/admin
checkRoute "$APP_ROUTER_APPS_INSTALLED_CATEGORY_ENABLED" app/future/apps/installed
checkRoute "$APP_ROUTER_APPS_SLUG_ENABLED" app/future/apps/\[slug\]
checkRoute "$APP_ROUTER_APPS_SLUG_SETUP_ENABLED" app/future/apps/\[slug\]/setup
checkRoute "$APP_ROUTER_APPS_CATEGORIES_ENABLED" app/future/apps/categories
checkRoute "$APP_ROUTER_APPS_CATEGORIES_CATEGORY_ENABLED" app/future/apps/categories/\[category\]
checkRoute "$APP_ROUTER_WORKFLOWS_ENABLED" app/future/workflows
checkRoute "$APP_ROUTER_SETTINGS_TEAMS_ENABLED" app/future/settings/teams
checkRoute "$APP_ROUTER_GETTING_STARTED_STEP_ENABLED" app/future/getting-started
checkRoute "$APP_ROUTER_APPS_ENABLED" app/future/apps
checkRoute "$APP_ROUTER_BOOKINGS_STATUS_ENABLED" app/future/bookings
checkRoute "$APP_ROUTER_VIDEO_ENABLED" app/future/video
checkRoute "$APP_ROUTER_TEAMS_ENABLED" app/future/teams
# These are routes that don't have and environment variable to enable or disable them
# Will stop removing gradually as we test and confirm that they are working
rm -rf \
app/future/[user]\
app/future/auth\
app/future/booking\
app/future/connect-and-join\
app/future/d\
app/future/enterprise\
app/future/insights\
app/future/maintenance\
app/future/more\
app/future/org\
app/future/payment\
app/future/reschedule\
app/future/routing-forms\
app/future/settings\
app/future/signup\
app/future/team
exit 1
@@ -37,6 +37,7 @@ export const getSampleUserInSession = function () {
disableImpersonation: false, disableImpersonation: false,
organizationId: null, organizationId: null,
theme: "", theme: "",
appTheme: "",
createdDate: new Date(), createdDate: new Date(),
trialEndsAt: new Date(), trialEndsAt: new Date(),
completedOnboarding: false, completedOnboarding: false,
@@ -14,6 +14,7 @@ import closecom_config_json from "./closecom/config.json";
import cron_config_json from "./cron/config.json"; import cron_config_json from "./cron/config.json";
import { metadata as dailyvideo__metadata_ts } from "./dailyvideo/_metadata"; import { metadata as dailyvideo__metadata_ts } from "./dailyvideo/_metadata";
import deel_config_json from "./deel/config.json"; import deel_config_json from "./deel/config.json";
import demodesk_config_json from "./demodesk/config.json";
import discord_config_json from "./discord/config.json"; import discord_config_json from "./discord/config.json";
import eightxeight_config_json from "./eightxeight/config.json"; import eightxeight_config_json from "./eightxeight/config.json";
import element_call_config_json from "./element-call/config.json"; import element_call_config_json from "./element-call/config.json";
@@ -97,6 +98,7 @@ export const appStoreMetadata = {
cron: cron_config_json, cron: cron_config_json,
dailyvideo: dailyvideo__metadata_ts, dailyvideo: dailyvideo__metadata_ts,
deel: deel_config_json, deel: deel_config_json,
demodesk: demodesk_config_json,
discord: discord_config_json, discord: discord_config_json,
eightxeight: eightxeight_config_json, eightxeight: eightxeight_config_json,
"element-call": element_call_config_json, "element-call": element_call_config_json,
@@ -14,6 +14,7 @@ export const apiHandlers = {
closecom: import("./closecom/api"), closecom: import("./closecom/api"),
cron: import("./cron/api"), cron: import("./cron/api"),
deel: import("./deel/api"), deel: import("./deel/api"),
demodesk: import("./demodesk/api"),
discord: import("./discord/api"), discord: import("./discord/api"),
eightxeight: import("./eightxeight/api"), eightxeight: import("./eightxeight/api"),
"element-call": import("./element-call/api"), "element-call": import("./element-call/api"),
@@ -5,6 +5,7 @@
import around_config_json from "./around/config.json"; import around_config_json from "./around/config.json";
import campfire_config_json from "./campfire/config.json"; import campfire_config_json from "./campfire/config.json";
import { metadata as dailyvideo__metadata_ts } from "./dailyvideo/_metadata"; import { metadata as dailyvideo__metadata_ts } from "./dailyvideo/_metadata";
import demodesk_config_json from "./demodesk/config.json";
import discord_config_json from "./discord/config.json"; import discord_config_json from "./discord/config.json";
import eightxeight_config_json from "./eightxeight/config.json"; import eightxeight_config_json from "./eightxeight/config.json";
import element_call_config_json from "./element-call/config.json"; import element_call_config_json from "./element-call/config.json";
@@ -41,6 +42,7 @@ export const appStoreMetadata = {
around: around_config_json, around: around_config_json,
campfire: campfire_config_json, campfire: campfire_config_json,
dailyvideo: dailyvideo__metadata_ts, dailyvideo: dailyvideo__metadata_ts,
demodesk: demodesk_config_json,
discord: discord_config_json, discord: discord_config_json,
eightxeight: eightxeight_config_json, eightxeight: eightxeight_config_json,
"element-call": element_call_config_json, "element-call": element_call_config_json,
@@ -0,0 +1,30 @@
---
items:
- iframe: { src: https://www.youtube.com/embed/X6XVzJ0E8xY }
- 1.jpeg
- 2.webp
- 3.webp
- 4.webp
---
{DESCRIPTION}
### AI superpowers for your sales meetings
#### Coach and onboard sellers with AI-based insights
- Automatically record all meetings, GDPR & MiFID compliant
- Transcribe and summarize meetings using Demodesk’s AI
- Coach teams along conversational metrics and scorecards
- Automatically log key information into your CRM
#### Schedule more customer meetings, on auto-pilot
- Let customers schedule time without email back-and-forth
- Qualify website leads and route them to the right person
- Automate and control handoff processes in your team
- Automate admin work and sync interactions to your CRM
#### Run secure video meetings that convert into revenue
- Host professional, secure video meetings without downloads
- Engage customers with interactive screen sharing
- Coach sellers in real-time along standardized playbooks
- Automate preparation, follow-up and CRM documentation
+16
View File
@@ -0,0 +1,16 @@
import { createDefaultInstallation } from "@calcom/app-store/_utils/installation";
import type { AppDeclarativeHandler } from "@calcom/types/AppHandler";
import appConfig from "../config.json";
const handler: AppDeclarativeHandler = {
appType: appConfig.type,
variant: appConfig.variant,
slug: appConfig.slug,
supportsMultipleInstalls: false,
handlerType: "add",
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, user: user, slug, key: {}, teamId }),
};
export default handler;
+1
View File
@@ -0,0 +1 @@
export { default as add } from "./add";
+25
View File
@@ -0,0 +1,25 @@
{
"/*": "Don't modify slug - If required, do it using cli edit command",
"name": "Demodesk",
"slug": "demodesk",
"type": "demodesk_conferencing",
"logo": "icon.svg",
"url": "https://demodesk.com",
"variant": "conferencing",
"categories": ["conferencing"],
"publisher": "Cal.com, Inc.",
"email": "support@cal.com",
"appData": {
"location": {
"type": "integrations:{SLUG}_video",
"label": "{TITLE}",
"linkType": "static",
"organizerInputPlaceholder": "https://demodesk.com/meet/mylink",
"urlRegExp": "^http(s)?:\\/\\/(www\\.)?demodesk.com\\/[a-zA-Z0-9]*"
}
},
"description": "Run Professional Video Meetings, Coach Sales Teams in Real-Time with AI, And Schedule Meetings on Auto-Pilot. 100% GDPR Compliant, Enterprise Ready.",
"isTemplate": false,
"__createdUsingCli": true,
"__template": "event-type-location-video-static"
}
+1
View File
@@ -0,0 +1 @@
export * as api from "./api";
+14
View File
@@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"name": "@calcom/demodesk",
"version": "0.0.1",
"main": "./index.ts",
"dependencies": {
"@calcom/lib": "*"
},
"devDependencies": {
"@calcom/types": "*"
},
"description": "Run Professional Video Meetings, Coach Sales Teams in Real-Time with AI, And Schedule Meetings on Auto-Pilot. 100% GDPR Compliant, Enterprise Ready."
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 425 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

@@ -0,0 +1,4 @@
<svg width="2484" height="2484" viewBox="0 0 2484 2484" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="2484" height="2484" rx="200" fill="#DDDDDD"/>
<path d="M1126.24 514.004H687.083C663.823 514.004 645 532.827 645 556.088V656.4C645 679.661 663.823 698.484 687.083 698.484H1109.75C1451.81 698.484 1699.98 941.881 1699.98 1241.1C1699.98 1365.7 1657.75 1480.59 1586.85 1572.29C1599.97 1525.62 1607.02 1476.42 1607.02 1425.58C1607.02 1126.36 1418.26 882.963 1025.89 882.963H687.083C663.823 882.963 645 901.786 645 925.047V1025.36C645 1048.62 663.823 1067.44 687.083 1067.44H1019.19C1270.01 1068.59 1412.48 1228.09 1412.48 1425.58C1412.48 1623.06 1270.01 1783.75 1019.19 1783.75H850.096V1294.04C850.096 1270.79 831.274 1251.96 808.014 1251.96H687.083C663.823 1251.96 645 1270.79 645 1294.04V1926.15C645 1949.4 663.823 1968.23 687.083 1968.23H1064.45H1064.53H1126.28C1579.28 1968.23 1894.59 1642.04 1894.59 1241.13C1894.59 840.229 1607.05 512.703 1126.24 514.004Z" fill="#2B2D28"/>
</svg>

After

Width:  |  Height:  |  Size: 998 B

@@ -21,6 +21,7 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
switchChecked={enabled} switchChecked={enabled}
teamId={eventType.team?.id || undefined}> teamId={eventType.team?.id || undefined}>
<TextField <TextField
dataTestid={app.slug}
name="Tracking ID" name="Tracking ID"
value={trackingId} value={trackingId}
disabled={disabled} disabled={disabled}
@@ -21,6 +21,7 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
switchChecked={enabled} switchChecked={enabled}
teamId={eventType.team?.id || undefined}> teamId={eventType.team?.id || undefined}>
<TextField <TextField
dataTestid={app.slug}
name="Tracking ID" name="Tracking ID"
value={trackingId} value={trackingId}
disabled={disabled} disabled={disabled}
@@ -2,6 +2,7 @@ import type { Auth } from "googleapis";
import { google } from "googleapis"; import { google } from "googleapis";
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { renewSelectedCalendarCredentialId } from "@calcom/lib/connectedCalendar";
import { WEBAPP_URL_FOR_OAUTH, WEBAPP_URL } from "@calcom/lib/constants"; import { WEBAPP_URL_FOR_OAUTH, WEBAPP_URL } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import { HttpError } from "@calcom/lib/http-error"; import { HttpError } from "@calcom/lib/http-error";
@@ -105,23 +106,38 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
appId: "google-calendar", appId: "google-calendar",
}, },
}); });
const selectedCalendarWhereUnique = {
userId: req.session.user.id,
externalId: primaryCal.id,
integration: "google_calendar",
};
// Wrapping in a try/catch to reduce chance of race conditions- // Wrapping in a try/catch to reduce chance of race conditions-
// also this improves performance for most of the happy-paths. // also this improves performance for most of the happy-paths.
try { try {
await prisma.selectedCalendar.create({ await prisma.selectedCalendar.create({
data: { data: {
userId: req.session.user.id,
externalId: primaryCal.id,
credentialId: credential.id, credentialId: credential.id,
integration: "google_calendar", ...selectedCalendarWhereUnique,
}, },
}); });
} catch (error) { } catch (error) {
await prisma.credential.delete({ where: { id: credential.id } });
let errorMessage = "something_went_wrong"; let errorMessage = "something_went_wrong";
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
// it is possible a selectedCalendar was orphaned, in this situation-
// we want to recover by connecting the existing selectedCalendar to the new Credential.
if (await renewSelectedCalendarCredentialId(selectedCalendarWhereUnique, credential.id)) {
res.redirect(
getSafeRedirectUrl(state?.returnTo) ??
getInstalledAppPath({ variant: "calendar", slug: "google-calendar" })
);
return;
}
// else
errorMessage = "account_already_linked"; errorMessage = "account_already_linked";
} }
await prisma.credential.delete({ where: { id: credential.id } });
res.redirect( res.redirect(
`${ `${
getSafeRedirectUrl(state?.onErrorReturnTo) ?? getSafeRedirectUrl(state?.onErrorReturnTo) ??
@@ -9,9 +9,7 @@ afterEach(() => {
}); });
vi.mock("@calcom/features/flags/server/utils", () => ({ vi.mock("@calcom/features/flags/server/utils", () => ({
getFeatureFlagMap: vi.fn().mockResolvedValue({ getFeatureFlag: vi.fn().mockReturnValue(true),
"calendar-cache": true,
}),
})); }));
vi.mock("./getGoogleAppKeys", () => ({ vi.mock("./getGoogleAppKeys", () => ({
@@ -6,7 +6,7 @@ import { RRule } from "rrule";
import { MeetLocationType } from "@calcom/app-store/locations"; import { MeetLocationType } from "@calcom/app-store/locations";
import dayjs from "@calcom/dayjs"; import dayjs from "@calcom/dayjs";
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils"; import { getFeatureFlag } from "@calcom/features/flags/server/utils";
import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser"; import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser";
import type CalendarService from "@calcom/lib/CalendarService"; import type CalendarService from "@calcom/lib/CalendarService";
import logger from "@calcom/lib/logger"; import logger from "@calcom/lib/logger";
@@ -264,6 +264,7 @@ export default class GoogleCalendarService implements Calendar {
calendarId: selectedCalendar, calendarId: selectedCalendar,
eventId: event.id || "", eventId: event.id || "",
requestBody: { requestBody: {
location: getLocation(calEventRaw),
description: getRichDescription({ description: getRichDescription({
...calEventRaw, ...calEventRaw,
}), }),
@@ -461,10 +462,10 @@ export default class GoogleCalendarService implements Calendar {
items: { id: string }[]; items: { id: string }[];
}): Promise<EventBusyDate[] | null> { }): Promise<EventBusyDate[] | null> {
const calendar = await this.authedCalendar(); const calendar = await this.authedCalendar();
const flags = await getFeatureFlagMap(prisma); const calendarCacheEnabled = await getFeatureFlag(prisma, "calendar-cache");
let freeBusyResult: calendar_v3.Schema$FreeBusyResponse = {}; let freeBusyResult: calendar_v3.Schema$FreeBusyResponse = {};
if (!flags["calendar-cache"]) { if (!calendarCacheEnabled) {
this.log.warn("Calendar Cache is disabled - Skipping"); this.log.warn("Calendar Cache is disabled - Skipping");
const { timeMin, timeMax, items } = args; const { timeMin, timeMax, items } = args;
const apires = await calendar.freebusy.query({ const apires = await calendar.freebusy.query({
@@ -21,6 +21,7 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
switchChecked={enabled} switchChecked={enabled}
teamId={eventType.team?.id || undefined}> teamId={eventType.team?.id || undefined}>
<TextField <TextField
dataTestid={app.slug}
name="Tracking ID" name="Tracking ID"
value={trackingId} value={trackingId}
disabled={disabled} disabled={disabled}
@@ -2,6 +2,7 @@
"name": "ICS Feed", "name": "ICS Feed",
"title": "ICS Feed", "title": "ICS Feed",
"slug": "ics-feed", "slug": "ics-feed",
"dirName":"ics-feedcalendar",
"type": "ics-feed_calendar", "type": "ics-feed_calendar",
"logo": "icon.svg", "logo": "icon.svg",
"variant": "calendar", "variant": "calendar",
@@ -23,6 +23,7 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
teamId={eventType.team?.id || undefined}> teamId={eventType.team?.id || undefined}>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<TextField <TextField
dataTestid={`${app.slug}-url`}
name="Matomo URL" name="Matomo URL"
placeholder="Enter your Matomo URL here" placeholder="Enter your Matomo URL here"
value={matomoUrl} value={matomoUrl}
@@ -32,6 +33,7 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
}} }}
/> />
<TextField <TextField
dataTestid={`${app.slug}-site-id`}
disabled={disabled} disabled={disabled}
name="Site ID" name="Site ID"
placeholder="Enter your Site ID" placeholder="Enter your Site ID"
@@ -19,6 +19,7 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
switchChecked={enabled} switchChecked={enabled}
teamId={eventType.team?.id || undefined}> teamId={eventType.team?.id || undefined}>
<TextField <TextField
dataTestid={app.slug}
name="Pixel ID" name="Pixel ID"
value={trackingId} value={trackingId}
disabled={disabled} disabled={disabled}
@@ -1,6 +1,7 @@
import type { Calendar as OfficeCalendar } from "@microsoft/microsoft-graph-types-beta"; import type { Calendar as OfficeCalendar } from "@microsoft/microsoft-graph-types-beta";
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { renewSelectedCalendarCredentialId } from "@calcom/lib/connectedCalendar";
import { WEBAPP_URL } from "@calcom/lib/constants"; import { WEBAPP_URL } from "@calcom/lib/constants";
import { handleErrorsJson } from "@calcom/lib/errors"; import { handleErrorsJson } from "@calcom/lib/errors";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
@@ -119,23 +120,36 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
appId: "office365-calendar", appId: "office365-calendar",
}, },
}); });
const selectedCalendarWhereUnique = {
userId: req.session?.user.id,
integration: "office365_calendar",
externalId: defaultCalendar.id,
};
// Wrapping in a try/catch to reduce chance of race conditions- // Wrapping in a try/catch to reduce chance of race conditions-
// also this improves performance for most of the happy-paths. // also this improves performance for most of the happy-paths.
try { try {
await prisma.selectedCalendar.create({ await prisma.selectedCalendar.create({
data: { data: {
userId: req.session?.user.id, ...selectedCalendarWhereUnique,
integration: "office365_calendar",
externalId: defaultCalendar.id,
credentialId: credential.id, credentialId: credential.id,
}, },
}); });
} catch (error) { } catch (error) {
await prisma.credential.delete({ where: { id: credential.id } });
let errorMessage = "something_went_wrong"; let errorMessage = "something_went_wrong";
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
// it is possible a selectedCalendar was orphaned, in this situation-
// we want to recover by connecting the existing selectedCalendar to the new Credential.
if (await renewSelectedCalendarCredentialId(selectedCalendarWhereUnique, credential.id)) {
res.redirect(
getSafeRedirectUrl(state?.returnTo) ??
getInstalledAppPath({ variant: "calendar", slug: "office365-calendar" })
);
return;
}
// else
errorMessage = "account_already_linked"; errorMessage = "account_already_linked";
} }
await prisma.credential.delete({ where: { id: credential.id } });
res.redirect( res.redirect(
`${ `${
getSafeRedirectUrl(state?.onErrorReturnTo) ?? getSafeRedirectUrl(state?.onErrorReturnTo) ??
@@ -146,8 +160,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
} }
} }
return res.redirect( res.redirect(
getSafeRedirectUrl(state?.returnTo) ?? getSafeRedirectUrl(state?.returnTo) ??
getInstalledAppPath({ variant: "calendar", slug: "office365-calendar" }) getInstalledAppPath({ variant: "calendar", slug: "office365-calendar" })
); );
return;
} }
@@ -23,6 +23,7 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
teamId={eventType.team?.id || undefined}> teamId={eventType.team?.id || undefined}>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<TextField <TextField
dataTestid={`${app.slug}-url`}
name="Plausible URL" name="Plausible URL"
defaultValue="https://plausible.io/js/script.js" defaultValue="https://plausible.io/js/script.js"
placeholder="https://plausible.io/js/script.js" placeholder="https://plausible.io/js/script.js"
@@ -33,6 +34,7 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
}} }}
/> />
<TextField <TextField
dataTestid={`${app.slug}-tracking-id`}
disabled={disabled} disabled={disabled}
name="Tracked Domain" name="Tracked Domain"
placeholder="yourdomain.com" placeholder="yourdomain.com"
@@ -1,6 +1,7 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { stringify } from "querystring"; import { stringify } from "querystring";
import { renewSelectedCalendarCredentialId } from "@calcom/lib/connectedCalendar";
import { WEBAPP_URL } from "@calcom/lib/constants"; import { WEBAPP_URL } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import logger from "@calcom/lib/logger"; import logger from "@calcom/lib/logger";
@@ -86,23 +87,36 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
appId: config.slug, appId: config.slug,
}, },
}); });
const selectedCalendarWhereUnique = {
userId: req.session?.user.id,
integration: config.type,
externalId: primaryCalendar.uid,
};
// Wrapping in a try/catch to reduce chance of race conditions- // Wrapping in a try/catch to reduce chance of race conditions-
// also this improves performance for most of the happy-paths. // also this improves performance for most of the happy-paths.
try { try {
await prisma.selectedCalendar.create({ await prisma.selectedCalendar.create({
data: { data: {
userId: req.session.user.id, ...selectedCalendarWhereUnique,
integration: config.type,
externalId: primaryCalendar.uid,
credentialId: credential.id, credentialId: credential.id,
}, },
}); });
} catch (error) { } catch (error) {
await prisma.credential.delete({ where: { id: credential.id } });
let errorMessage = "something_went_wrong"; let errorMessage = "something_went_wrong";
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
// it is possible a selectedCalendar was orphaned, in this situation-
// we want to recover by connecting the existing selectedCalendar to the new Credential.
if (await renewSelectedCalendarCredentialId(selectedCalendarWhereUnique, credential.id)) {
res.redirect(
getSafeRedirectUrl(state?.returnTo) ??
getInstalledAppPath({ variant: "calendar", slug: config.slug })
);
return;
}
// else
errorMessage = "account_already_linked"; errorMessage = "account_already_linked";
} }
await prisma.credential.delete({ where: { id: credential.id } });
res.redirect( res.redirect(
`${ `${
getSafeRedirectUrl(state?.onErrorReturnTo) ?? getSafeRedirectUrl(state?.onErrorReturnTo) ??
@@ -69,7 +69,7 @@ export const BookerWebWrapper = (props: BookerWebWrapperAtomProps) => {
name: name:
searchParams?.get("name") || searchParams?.get("name") ||
(firstNameQueryParam ? `${firstNameQueryParam} ${lastNameQueryParam}` : null), (firstNameQueryParam ? `${firstNameQueryParam} ${lastNameQueryParam}` : null),
guests: searchParams?.getAll("guests") || searchParams?.getAll("guest"), guests: (searchParams?.getAll("guests") || searchParams?.getAll("guest")) ?? [],
}; };
}, [searchParams, firstNameQueryParam, lastNameQueryParam]); }, [searchParams, firstNameQueryParam, lastNameQueryParam]);
+11 -4
View File
@@ -3,7 +3,7 @@ import { createTransport } from "nodemailer";
import { z } from "zod"; import { z } from "zod";
import dayjs from "@calcom/dayjs"; import dayjs from "@calcom/dayjs";
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils"; import { getFeatureFlag } from "@calcom/features/flags/server/utils";
import { getErrorFromUnknown } from "@calcom/lib/errors"; import { getErrorFromUnknown } from "@calcom/lib/errors";
import { serverConfig } from "@calcom/lib/serverConfig"; import { serverConfig } from "@calcom/lib/serverConfig";
import { setTestEmail } from "@calcom/lib/testEmails"; import { setTestEmail } from "@calcom/lib/testEmails";
@@ -28,9 +28,9 @@ export default class BaseEmail {
return {}; return {};
} }
public async sendEmail() { public async sendEmail() {
const featureFlags = await getFeatureFlagMap(prisma); const emailsDisabled = await getFeatureFlag(prisma, "emails");
/** If email kill switch exists and is active, we prevent emails being sent. */ /** If email kill switch exists and is active, we prevent emails being sent. */
if (featureFlags.emails) { if (emailsDisabled) {
console.warn("Skipped Sending Email due to active Kill Switch"); console.warn("Skipped Sending Email due to active Kill Switch");
return new Promise((r) => r("Skipped Sending Email due to active Kill Switch")); return new Promise((r) => r("Skipped Sending Email due to active Kill Switch"));
} }
@@ -65,7 +65,14 @@ export default class BaseEmail {
} }
} }
) )
).catch((e) => console.error("sendEmail", e)); ).catch((e) =>
console.error(
"sendEmail",
`from: ${"from" in payloadWithUnEscapedSubject ? payloadWithUnEscapedSubject.from : ""}`,
`subject: ${"subject" in payloadWithUnEscapedSubject ? payloadWithUnEscapedSubject.subject : ""}`,
e
)
);
return new Promise((resolve) => resolve("send mail async")); return new Promise((resolve) => resolve("send mail async"));
} }
protected getMailerOptions() { protected getMailerOptions() {
@@ -35,6 +35,27 @@ export type EventDataMap = {
}; };
confirmed: boolean; confirmed: boolean;
}; };
rescheduleBookingSuccessful: {
booking: unknown;
eventType: unknown;
date: string;
duration: number | undefined;
organizer: {
name: string;
email: string;
timeZone: string;
};
confirmed: boolean;
};
bookingCancelled: {
booking: unknown;
organizer: {
name: string;
email: string;
timeZone?: string | undefined;
};
eventType: unknown;
};
"*": Record<string, unknown>; "*": Record<string, unknown>;
__routeChanged: Record<string, never>; __routeChanged: Record<string, never>;
__windowLoadComplete: Record<string, never>; __windowLoadComplete: Record<string, never>;
+5 -5
View File
@@ -6,7 +6,7 @@ import {
sendEmailVerificationLink, sendEmailVerificationLink,
sendChangeOfEmailVerificationLink, sendChangeOfEmailVerificationLink,
} from "@calcom/emails/email-manager"; } from "@calcom/emails/email-manager";
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils"; import { getFeatureFlag } from "@calcom/features/flags/server/utils";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError"; import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { WEBAPP_URL } from "@calcom/lib/constants"; import { WEBAPP_URL } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger"; import logger from "@calcom/lib/logger";
@@ -24,9 +24,9 @@ interface VerifyEmailType {
export const sendEmailVerification = async ({ email, language, username }: VerifyEmailType) => { export const sendEmailVerification = async ({ email, language, username }: VerifyEmailType) => {
const token = randomBytes(32).toString("hex"); const token = randomBytes(32).toString("hex");
const translation = await getTranslation(language ?? "en", "common"); const translation = await getTranslation(language ?? "en", "common");
const flags = await getFeatureFlagMap(prisma); const emailVerification = await getFeatureFlag(prisma, "email-verification");
if (!flags["email-verification"]) { if (!emailVerification) {
log.warn("Email verification is disabled - Skipping"); log.warn("Email verification is disabled - Skipping");
return { ok: true, skipped: true }; return { ok: true, skipped: true };
} }
@@ -93,9 +93,9 @@ interface ChangeOfEmail {
export const sendChangeOfEmailVerification = async ({ user, language }: ChangeOfEmail) => { export const sendChangeOfEmailVerification = async ({ user, language }: ChangeOfEmail) => {
const token = randomBytes(32).toString("hex"); const token = randomBytes(32).toString("hex");
const translation = await getTranslation(language ?? "en", "common"); const translation = await getTranslation(language ?? "en", "common");
const flags = await getFeatureFlagMap(prisma); const emailVerification = await getFeatureFlag(prisma, "email-verification");
if (!flags["email-verification"]) { if (!emailVerification) {
log.warn("Email verification is disabled - Skipping"); log.warn("Email verification is disabled - Skipping");
return { ok: true, skipped: true }; return { ok: true, skipped: true };
} }
+2 -3
View File
@@ -123,7 +123,8 @@ const BookerComponent = ({
const { bookerFormErrorRef, key, formEmail, bookingForm, errors: formErrors } = bookerForm; const { bookerFormErrorRef, key, formEmail, bookingForm, errors: formErrors } = bookerForm;
const { handleBookEvent, hasInstantMeetingTokenExpired, errors, loadingStates, expiryTime } = bookings; const { handleBookEvent, errors, loadingStates, expiryTime } = bookings;
const { const {
isEmailVerificationModalVisible, isEmailVerificationModalVisible,
setEmailVerificationModalVisible, setEmailVerificationModalVisible,
@@ -180,7 +181,6 @@ const BookerComponent = ({
/> />
<RedirectToInstantMeetingModal <RedirectToInstantMeetingModal
expiryTime={expiryTime} expiryTime={expiryTime}
hasInstantMeetingTokenExpired={hasInstantMeetingTokenExpired}
bookingId={parseInt(getQueryParam("bookingId") || "0")} bookingId={parseInt(getQueryParam("bookingId") || "0")}
onGoBack={() => { onGoBack={() => {
onGoBackInstantMeeting(); onGoBackInstantMeeting();
@@ -203,7 +203,6 @@ const BookerComponent = ({
formErrors, formErrors,
handleBookEvent, handleBookEvent,
handleVerifyEmail, handleVerifyEmail,
hasInstantMeetingTokenExpired,
isEmailVerificationModalVisible, isEmailVerificationModalVisible,
key, key,
loadingStates, loadingStates,
@@ -55,13 +55,6 @@ export function Header({
if (isMobile || !enabledLayouts) return null; if (isMobile || !enabledLayouts) return null;
// Only reason we create this component, is because it is used 3 times in this component,
// and this way we can't forget to update one of the props in all places :)
const LayoutToggleWithData = () => {
return enabledLayouts.length <= 1 ? null : (
<LayoutToggle onLayoutToggle={onLayoutToggle} layout={layout} enabledLayouts={enabledLayouts} />
);
};
// In month view we only show the layout toggle. // In month view we only show the layout toggle.
if (isMonthView) { if (isMonthView) {
return ( return (
@@ -78,7 +71,11 @@ export function Header({
) : ( ) : (
renderOverlay?.() renderOverlay?.()
)} )}
<LayoutToggleWithData /> <LayoutToggleWithData
layout={layout}
enabledLayouts={enabledLayouts}
onLayoutToggle={onLayoutToggle}
/>
</div> </div>
); );
} }
@@ -140,7 +137,11 @@ export function Header({
{renderOverlay?.()} {renderOverlay?.()}
<TimeFormatToggle /> <TimeFormatToggle />
<div className="fixed top-4 ltr:right-4 rtl:left-4"> <div className="fixed top-4 ltr:right-4 rtl:left-4">
<LayoutToggleWithData /> <LayoutToggleWithData
layout={layout}
enabledLayouts={enabledLayouts}
onLayoutToggle={onLayoutToggle}
/>
</div> </div>
{/* {/*
This second layout toggle is hidden, but needed to reserve the correct spot in the DIV This second layout toggle is hidden, but needed to reserve the correct spot in the DIV
@@ -150,7 +151,11 @@ export function Header({
while it actually already was on place. That's why we have this element twice. while it actually already was on place. That's why we have this element twice.
*/} */}
<div className="pointer-events-none opacity-0" aria-hidden> <div className="pointer-events-none opacity-0" aria-hidden>
<LayoutToggleWithData /> <LayoutToggleWithData
layout={layout}
enabledLayouts={enabledLayouts}
onLayoutToggle={onLayoutToggle}
/>
</div> </div>
</div> </div>
</div> </div>
@@ -198,3 +203,17 @@ const LayoutToggle = ({
return <ToggleGroup onValueChange={onLayoutToggle} defaultValue={layout} options={layoutOptions} />; return <ToggleGroup onValueChange={onLayoutToggle} defaultValue={layout} options={layoutOptions} />;
}; };
const LayoutToggleWithData = ({
enabledLayouts,
onLayoutToggle,
layout,
}: {
enabledLayouts: BookerLayouts[];
onLayoutToggle: (layout: string) => void;
layout: string;
}) => {
return enabledLayouts.length <= 1 ? null : (
<LayoutToggle onLayoutToggle={onLayoutToggle} layout={layout} enabledLayouts={enabledLayouts} />
);
};
@@ -12,18 +12,17 @@ const handleBeforeUnload = (event: BeforeUnloadEvent) => {
}; };
export const RedirectToInstantMeetingModal = ({ export const RedirectToInstantMeetingModal = ({
hasInstantMeetingTokenExpired,
bookingId, bookingId,
onGoBack, onGoBack,
expiryTime, expiryTime,
}: { }: {
hasInstantMeetingTokenExpired: boolean;
bookingId: number; bookingId: number;
onGoBack: () => void; onGoBack: () => void;
expiryTime?: Date; expiryTime?: Date;
}) => { }) => {
const { t } = useLocale(); const { t } = useLocale();
const [timeRemaining, setTimeRemaining] = useState(calculateTimeRemaining()); const [timeRemaining, setTimeRemaining] = useState(calculateTimeRemaining());
const [hasInstantMeetingTokenExpired, setHasInstantMeetingTokenExpired] = useState(false);
function calculateTimeRemaining() { function calculateTimeRemaining() {
const now = dayjs(); const now = dayjs();
@@ -37,6 +36,7 @@ export const RedirectToInstantMeetingModal = ({
const timer = setInterval(() => { const timer = setInterval(() => {
setTimeRemaining(calculateTimeRemaining()); setTimeRemaining(calculateTimeRemaining());
setHasInstantMeetingTokenExpired(expiryTime && new Date(expiryTime) < new Date());
}, 1000); }, 1000);
return () => { return () => {
@@ -4,6 +4,7 @@ import { useRef, useState, useEffect } from "react";
import { createPaymentLink } from "@calcom/app-store/stripepayment/lib/client"; import { createPaymentLink } from "@calcom/app-store/stripepayment/lib/client";
import dayjs from "@calcom/dayjs"; import dayjs from "@calcom/dayjs";
import { sdkActionManager } from "@calcom/embed-core/embed-iframe";
import { useBookerStore } from "@calcom/features/bookings/Booker/store"; import { useBookerStore } from "@calcom/features/bookings/Booker/store";
import type { useEventReturnType } from "@calcom/features/bookings/Booker/utils/event"; import type { useEventReturnType } from "@calcom/features/bookings/Booker/utils/event";
import { updateQueryParam, getQueryParam } from "@calcom/features/bookings/Booker/utils/query-param"; import { updateQueryParam, getQueryParam } from "@calcom/features/bookings/Booker/utils/query-param";
@@ -18,6 +19,7 @@ import {
import { getFullName } from "@calcom/features/form-builder/utils"; import { getFullName } from "@calcom/features/form-builder/utils";
import { useBookingSuccessRedirect } from "@calcom/lib/bookingSuccessRedirect"; import { useBookingSuccessRedirect } from "@calcom/lib/bookingSuccessRedirect";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { BookingStatus } from "@calcom/prisma/enums";
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils"; import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
import { trpc } from "@calcom/trpc"; import { trpc } from "@calcom/trpc";
import { showToast } from "@calcom/ui"; import { showToast } from "@calcom/ui";
@@ -54,7 +56,7 @@ export const useBookings = ({ event, hashedLink, bookingForm, metadata }: IUseBo
const { t, i18n } = useLocale(); const { t, i18n } = useLocale();
const bookingSuccessRedirect = useBookingSuccessRedirect(); const bookingSuccessRedirect = useBookingSuccessRedirect();
const bookerFormErrorRef = useRef<HTMLDivElement>(null); const bookerFormErrorRef = useRef<HTMLDivElement>(null);
const [expiryTime, setExpiryTime] = useState<Date | undefined>(); const [instantMeetingTokenExpiryTime, setExpiryTime] = useState<Date | undefined>();
const recurringEventCount = useBookerStore((state) => state.recurringEventCount); const recurringEventCount = useBookerStore((state) => state.recurringEventCount);
const isInstantMeeting = useBookerStore((state) => state.isInstantMeeting); const isInstantMeeting = useBookerStore((state) => state.isInstantMeeting);
const duration = useBookerStore((state) => state.selectedDuration); const duration = useBookerStore((state) => state.selectedDuration);
@@ -64,13 +66,13 @@ export const useBookings = ({ event, hashedLink, bookingForm, metadata }: IUseBo
const isRescheduling = !!rescheduleUid && !!bookingData; const isRescheduling = !!rescheduleUid && !!bookingData;
const bookingId = parseInt(getQueryParam("bookingId") || "0"); const bookingId = parseInt(getQueryParam("bookingId") || "0");
const hasInstantMeetingTokenExpired = expiryTime && new Date(expiryTime) < new Date();
const _instantBooking = trpc.viewer.bookings.getInstantBookingLocation.useQuery( const _instantBooking = trpc.viewer.bookings.getInstantBookingLocation.useQuery(
{ {
bookingId: bookingId, bookingId: bookingId,
}, },
{ {
enabled: !!bookingId && !hasInstantMeetingTokenExpired, enabled: !!bookingId,
refetchInterval: 2000, refetchInterval: 2000,
refetchIntervalInBackground: true, refetchIntervalInBackground: true,
} }
@@ -78,6 +80,7 @@ export const useBookings = ({ event, hashedLink, bookingForm, metadata }: IUseBo
useEffect( useEffect(
function refactorMeWithoutEffect() { function refactorMeWithoutEffect() {
const data = _instantBooking.data; const data = _instantBooking.data;
if (!data) return; if (!data) return;
try { try {
showToast(t("something_went_wrong_on_our_end"), "error"); showToast(t("something_went_wrong_on_our_end"), "error");
@@ -103,6 +106,45 @@ export const useBookings = ({ event, hashedLink, bookingForm, metadata }: IUseBo
onSuccess: (responseData) => { onSuccess: (responseData) => {
const { uid, paymentUid } = responseData; const { uid, paymentUid } = responseData;
const fullName = getFullName(bookingForm.getValues("responses.name")); const fullName = getFullName(bookingForm.getValues("responses.name"));
const users = !!event.data?.hosts?.length
? event.data?.hosts.map((host) => host.user)
: event.data?.users;
const validDuration = event.data?.isDynamic
? duration || event.data?.length
: duration && event.data?.metadata?.multipleDuration?.includes(duration)
? duration
: event.data?.length;
if (isRescheduling) {
sdkActionManager?.fire("rescheduleBookingSuccessful", {
booking: responseData,
eventType: event.data,
date: responseData?.startTime?.toString() || "",
duration: validDuration,
organizer: {
name: users?.[0].name || "Nameless",
email: responseData?.userPrimaryEmail || responseData.user?.email || "Email-less",
timeZone: responseData.user?.timeZone || "Europe/London",
},
confirmed: !(responseData.status === BookingStatus.PENDING && event.data?.requiresConfirmation),
});
} else {
sdkActionManager?.fire("bookingSuccessful", {
booking: responseData,
eventType: event.data,
date: responseData?.startTime?.toString() || "",
duration: validDuration,
organizer: {
name: users?.[0].name || "Nameless",
email: responseData?.userPrimaryEmail || responseData.user?.email || "Email-less",
timeZone: responseData.user?.timeZone || "Europe/London",
},
confirmed: !(responseData.status === BookingStatus.PENDING && event.data?.requiresConfirmation),
});
}
if (paymentUid) { if (paymentUid) {
router.push( router.push(
createPaymentLink({ createPaymentLink({
@@ -260,11 +302,10 @@ export const useBookings = ({ event, hashedLink, bookingForm, metadata }: IUseBo
return { return {
handleBookEvent, handleBookEvent,
expiryTime, expiryTime: instantMeetingTokenExpiryTime,
bookingForm, bookingForm,
bookerFormErrorRef, bookerFormErrorRef,
errors, errors,
loadingStates, loadingStates,
hasInstantMeetingTokenExpired: Boolean(hasInstantMeetingTokenExpired),
}; };
}; };
@@ -379,7 +379,7 @@ export async function ensureAvailableUsers(
safeStringify({ safeStringify({
startDateTimeUtc, startDateTimeUtc,
endDateTimeUtc, endDateTimeUtc,
timeZone: input.timeZone, input,
}) })
); );
continue; continue;
@@ -406,7 +406,7 @@ export async function ensureAvailableUsers(
safeStringify({ safeStringify({
startDateTimeUtc, startDateTimeUtc,
endDateTimeUtc, endDateTimeUtc,
timeZone: input.timeZone, input,
}) })
); );
continue; continue;
@@ -428,7 +428,7 @@ export async function ensureAvailableUsers(
safeStringify({ safeStringify({
startDateTimeUtc, startDateTimeUtc,
endDateTimeUtc, endDateTimeUtc,
timeZone: input.timeZone, input,
}) })
); );
throw new Error(ErrorCode.NoAvailableUsersFound); throw new Error(ErrorCode.NoAvailableUsersFound);
@@ -1289,7 +1289,7 @@ async function handler(
} }
// Pushing fixed user before the luckyUser guarantees the (first) fixed user as the organizer. // Pushing fixed user before the luckyUser guarantees the (first) fixed user as the organizer.
users = [...availableUsers.filter((user) => user.isFixed), ...luckyUsers]; users = [...availableUsers.filter((user) => user.isFixed), ...luckyUsers];
luckyUserResponse = { luckyUsers }; luckyUserResponse = { luckyUsers: luckyUsers.map((u) => u.id) };
} else if (req.body.allRecurringDates && eventType.schedulingType === SchedulingType.ROUND_ROBIN) { } else if (req.body.allRecurringDates && eventType.schedulingType === SchedulingType.ROUND_ROBIN) {
// all recurring slots except the first one // all recurring slots except the first one
const luckyUsersFromFirstBooking = luckyUsers const luckyUsersFromFirstBooking = luckyUsers
@@ -1576,7 +1576,18 @@ async function handler(
}); });
if (newBooking) { if (newBooking) {
req.statusCode = 201; req.statusCode = 201;
return { ...newBooking, ...luckyUserResponse }; const bookingResponse = {
...newBooking,
user: {
...newBooking.user,
email: null,
},
};
return {
...bookingResponse,
...luckyUserResponse,
};
} }
} }
if (isTeamEventType) { if (isTeamEventType) {
@@ -1616,6 +1627,19 @@ async function handler(
}) })
); );
// update original rescheduled booking (no seats event)
if (!eventType.seatsPerTimeSlot && originalRescheduledBooking?.uid) {
await prisma.booking.update({
where: {
id: originalRescheduledBooking.id,
},
data: {
rescheduled: true,
status: BookingStatus.CANCELLED,
},
});
}
try { try {
booking = await createBooking({ booking = await createBooking({
originalRescheduledBooking, originalRescheduledBooking,
@@ -1683,7 +1707,7 @@ async function handler(
err.message err.message
); );
if (err.code === "P2002") { if (err.code === "P2002") {
throw new HttpError({ statusCode: 409, message: "booking.conflict" }); throw new HttpError({ statusCode: 409, message: "booking_conflict" });
} }
throw err; throw err;
} }
@@ -1709,20 +1733,7 @@ async function handler(
evt = addVideoCallDataToEvent(originalRescheduledBooking.references, evt); evt = addVideoCallDataToEvent(originalRescheduledBooking.references, evt);
//update original rescheduled booking (no seats event) const newDestinationCalendar = evt.destinationCalendar;
if (!eventType.seatsPerTimeSlot) {
await prisma.booking.update({
where: {
id: originalRescheduledBooking.id,
},
data: {
rescheduled: true,
status: BookingStatus.CANCELLED,
},
});
}
const newDesinationCalendar = evt.destinationCalendar;
evt.destinationCalendar = originalRescheduledBooking?.destinationCalendar evt.destinationCalendar = originalRescheduledBooking?.destinationCalendar
? [originalRescheduledBooking?.destinationCalendar] ? [originalRescheduledBooking?.destinationCalendar]
@@ -1741,7 +1752,7 @@ async function handler(
originalRescheduledBooking.uid, originalRescheduledBooking.uid,
undefined, undefined,
changedOrganizer, changedOrganizer,
newDesinationCalendar newDestinationCalendar
); );
// This gets overridden when updating the event - to check if notes have been hidden or not. We just reset this back // This gets overridden when updating the event - to check if notes have been hidden or not. We just reset this back
@@ -2170,8 +2181,18 @@ async function handler(
}); });
req.statusCode = 201; req.statusCode = 201;
return { // TODO: Refactor better so this booking object is not passed
// all around and instead the individual fields are sent as args.
const bookingReponse = {
...booking, ...booking,
user: {
...booking.user,
email: null,
},
};
return {
...bookingReponse,
...luckyUserResponse, ...luckyUserResponse,
message: "Payment required", message: "Payment required",
paymentUid: payment?.uid, paymentUid: payment?.uid,
@@ -2299,8 +2320,19 @@ async function handler(
// booking successful // booking successful
req.statusCode = 201; req.statusCode = 201;
return {
// TODO: Refactor better so this booking object is not passed
// all around and instead the individual fields are sent as args.
const bookingResponse = {
...booking, ...booking,
user: {
...booking.user,
email: null,
},
};
return {
...bookingResponse,
...luckyUserResponse, ...luckyUserResponse,
references: referencesToCreate, references: referencesToCreate,
seatReferenceUid: evt.attendeeSeatId, seatReferenceUid: evt.attendeeSeatId,
@@ -0,0 +1,206 @@
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { describe, expect } from "vitest";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import dayjs from "@calcom/dayjs";
import { BookingStatus } from "@calcom/prisma/enums";
import { test } from "@calcom/web/test/fixtures/fixtures";
import {
createBookingScenario,
getGoogleCalendarCredential,
TestData,
getDate,
getOrganizer,
getBooker,
getScenarioData,
mockSuccessfulVideoMeetingCreation,
mockCalendarToHaveNoBusySlots,
BookingLocations,
} from "@calcom/web/test/utils/bookingScenario/bookingScenario";
import { createMockNextJsRequest } from "@calcom/web/test/utils/bookingScenario/createMockNextJsRequest";
import {
expectSuccessfulBookingCreationEmails,
expectBookingToBeInDatabase,
expectSuccessfulCalendarEventCreationInCalendar,
expectICalUIDAsString,
} from "@calcom/web/test/utils/bookingScenario/expects";
import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking";
import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown";
export const Timezones = {
"-05:00": "America/New_York",
"00:00": "Europe/London",
};
export type CustomNextApiRequest = NextApiRequest & Request;
export type CustomNextApiResponse = NextApiResponse & Response;
// Local test runs sometime gets too slow
const timeout = process.env.CI ? 5000 : 20000;
describe("handleNewBooking", () => {
setupAndTeardown();
describe("Complex schedules:", () => {
test(
`should be able to book the last slot before midnight`,
async ({ emails }) => {
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const newYorkTimeZone = Timezones["-05:00"];
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
});
// Using .endOf("day") here to ensure our date doesn't change when we set the time zone
const startDateTimeOrganizerTz = dayjs(plus1DateString)
.endOf("day")
.tz(newYorkTimeZone)
.hour(23)
.minute(0)
.second(0);
const endDateTimeOrganizerTz = dayjs(plus1DateString)
.endOf("day")
.tz(newYorkTimeZone)
.startOf("day")
.add(1, "day");
const schedule = {
name: "4:00PM to 11:59PM in New York",
availability: [
{
days: [0, 1, 2, 3, 4, 5, 6],
startTime: dayjs("1970-01-01").utc().hour(16).toDate(), // These times are stored with Z offset
endTime: dayjs("1970-01-01").utc().hour(23).minute(59).toDate(), // These times are stored with Z offset
date: null,
},
],
timeZone: newYorkTimeZone,
};
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
schedules: [schedule],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
});
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: 60,
length: 60,
users: [
{
id: 101,
},
],
},
],
organizer,
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
})
);
mockSuccessfulVideoMeetingCreation({
metadataLookupKey: "dailyvideo",
videoMeetingData: {
id: "MOCK_ID",
password: "MOCK_PASS",
url: `http://mock-dailyvideo.example.com/meeting-1`,
},
});
// Mock a Scenario where iCalUID isn't returned by Google Calendar in which case booking UID is used as the ics UID
const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", {
create: {
id: "GOOGLE_CALENDAR_EVENT_ID",
uid: "MOCK_ID",
},
});
const mockBookingData = getMockRequestDataForBooking({
data: {
eventTypeId: 1,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: BookingLocations.CalVideo },
},
start: startDateTimeOrganizerTz.format(),
end: endDateTimeOrganizerTz.format(),
timeZone: Timezones["-05:00"],
},
});
const { req } = createMockNextJsRequest({
method: "POST",
body: mockBookingData,
});
const createdBooking = await handleNewBooking(req);
expect(createdBooking.responses).toContain({
email: booker.email,
name: booker.name,
});
expect(createdBooking).toContain({
location: BookingLocations.CalVideo,
});
await expectBookingToBeInDatabase({
description: "",
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
uid: createdBooking.uid!,
eventTypeId: mockBookingData.eventTypeId,
status: BookingStatus.ACCEPTED,
references: [
{
type: appStoreMetadata.dailyvideo.type,
uid: "MOCK_ID",
meetingId: "MOCK_ID",
meetingPassword: "MOCK_PASS",
meetingUrl: "http://mock-dailyvideo.example.com/meeting-1",
},
{
type: appStoreMetadata.googlecalendar.type,
uid: "GOOGLE_CALENDAR_EVENT_ID",
meetingId: "GOOGLE_CALENDAR_EVENT_ID",
meetingPassword: "MOCK_PASSWORD",
meetingUrl: "https://UNUSED_URL",
},
],
iCalUID: createdBooking.iCalUID,
});
expectSuccessfulCalendarEventCreationInCalendar(calendarMock, {
videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1",
// We won't be sending evt.destinationCalendar in this case.
// Google Calendar in this case fallbacks to the "primary" calendar - https://github.com/calcom/cal.com/blob/7d5dad7fea78ff24dddbe44f1da5d7e08e1ff568/packages/app-store/googlecalendar/lib/CalendarService.ts#L217
// Not sure if it's the correct behaviour. Right now, it isn't possible to have an organizer with connected calendar but no destination calendar - As soon as the Google Calendar app is installed, a destination calendar is created.
calendarId: null,
});
const iCalUID = expectICalUIDAsString(createdBooking.iCalUID);
expectSuccessfulBookingCreationEmails({
booking: {
uid: createdBooking.uid!,
},
booker,
organizer,
emails,
iCalUID,
});
},
timeout
);
});
});
@@ -40,7 +40,7 @@ export const handleNewRecurringBooking = async (
}; };
const firstBookingResult = await handleNewBooking(recurringEventReq); const firstBookingResult = await handleNewBooking(recurringEventReq);
luckyUsers = firstBookingResult.luckyUsers?.map((user) => user.id); luckyUsers = firstBookingResult.luckyUsers;
} }
for (let key = isRoundRobin ? 1 : 0; key < data.length; key++) { for (let key = isRoundRobin ? 1 : 0; key < data.length; key++) {
@@ -1,14 +1,14 @@
import type { GetServerSidePropsContext } from "next"; import type { GetServerSidePropsContext } from "next";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils"; import { getFeatureFlag } from "@calcom/features/flags/server/utils";
import { MembershipRole } from "@calcom/prisma/client"; import { MembershipRole } from "@calcom/prisma/client";
export const getServerSideProps = async ({ req, res }: GetServerSidePropsContext) => { export const getServerSideProps = async ({ req, res }: GetServerSidePropsContext) => {
const prisma = await import("@calcom/prisma").then((mod) => mod.default); const prisma = await import("@calcom/prisma").then((mod) => mod.default);
const flags = await getFeatureFlagMap(prisma); const organizationsEnabled = await getFeatureFlag(prisma, "organizations");
// Check if organizations are enabled // Check if organizations are enabled
if (flags["organizations"] !== true) { if (!organizationsEnabled) {
return { return {
notFound: true, notFound: true,
}; };
@@ -116,7 +116,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
switch (reminder.workflowStep.action) { switch (reminder.workflowStep.action) {
case WorkflowActions.EMAIL_HOST: case WorkflowActions.EMAIL_HOST:
sendTo = reminder.booking.user?.email; sendTo = reminder.booking?.userPrimaryEmail ?? reminder.booking.user?.email;
break; break;
case WorkflowActions.EMAIL_ATTENDEE: case WorkflowActions.EMAIL_ATTENDEE:
sendTo = reminder.booking.attendees[0].email; sendTo = reminder.booking.attendees[0].email;
@@ -227,7 +227,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
html: emailContent.emailBody, html: emailContent.emailBody,
batchId: batchId, batchId: batchId,
sendAt: dayjs(reminder.scheduledDate).unix(), sendAt: dayjs(reminder.scheduledDate).unix(),
replyTo: reminder.booking.user?.email, replyTo: reminder.booking?.userPrimaryEmail ?? reminder.booking.user?.email,
attachments: reminder.workflowStep.includeCalendarEvent attachments: reminder.workflowStep.includeCalendarEvent
? [ ? [
{ {
@@ -297,7 +297,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
html: emailContent.emailBody, html: emailContent.emailBody,
batchId: batchId, batchId: batchId,
sendAt: dayjs(reminder.scheduledDate).unix(), sendAt: dayjs(reminder.scheduledDate).unix(),
replyTo: reminder.booking.user?.email, replyTo: reminder.booking?.userPrimaryEmail ?? reminder.booking.user?.email,
}, },
{ sender: reminder.workflowStep?.sender } { sender: reminder.workflowStep?.sender }
) )
@@ -23,6 +23,7 @@ type PartialBooking =
| "responses" | "responses"
| "uid" | "uid"
| "attendees" | "attendees"
| "userPrimaryEmail"
> & { eventType: Partial<EventType> | null } & { user: Partial<User> | null }) > & { eventType: Partial<EventType> | null } & { user: Partial<User> | null })
| null; | null;
@@ -134,6 +135,7 @@ export async function getAllUnscheduledReminders(): Promise<PartialWorkflowRemin
endTime: true, endTime: true,
location: true, location: true,
description: true, description: true,
userPrimaryEmail: true,
user: { user: {
select: { select: {
email: true, email: true,
@@ -25,6 +25,26 @@ import {
} from "@calcom/prisma/zod-utils"; } from "@calcom/prisma/zod-utils";
import type { UserProfile } from "@calcom/types/UserProfile"; import type { UserProfile } from "@calcom/types/UserProfile";
const userSelect = Prisma.validator<Prisma.UserSelect>()({
id: true,
avatarUrl: true,
username: true,
name: true,
weekStart: true,
brandColor: true,
darkBrandColor: true,
theme: true,
metadata: true,
organization: {
select: {
id: true,
name: true,
slug: true,
calVideoLogo: true,
},
},
});
const publicEventSelect = Prisma.validator<Prisma.EventTypeSelect>()({ const publicEventSelect = Prisma.validator<Prisma.EventTypeSelect>()({
id: true, id: true,
title: true, title: true,
@@ -78,32 +98,12 @@ const publicEventSelect = Prisma.validator<Prisma.EventTypeSelect>()({
hosts: { hosts: {
select: { select: {
user: { user: {
select: { select: userSelect,
id: true,
avatarUrl: true,
username: true,
name: true,
weekStart: true,
brandColor: true,
darkBrandColor: true,
theme: true,
metadata: true,
},
}, },
}, },
}, },
owner: { owner: {
select: { select: userSelect,
id: true,
avatarUrl: true,
weekStart: true,
username: true,
name: true,
theme: true,
metadata: true,
brandColor: true,
darkBrandColor: true,
},
}, },
hidden: true, hidden: true,
assignAllTeamMembers: true, assignAllTeamMembers: true,
@@ -291,12 +291,12 @@ type Event = Prisma.EventTypeGetPayload<typeof eventData>;
function getProfileFromEvent(event: Event) { function getProfileFromEvent(event: Event) {
const { team, hosts, owner } = event; const { team, hosts, owner } = event;
const profile = team || hosts?.[0]?.user || owner; const nonTeamprofile = hosts?.[0]?.user || owner;
const profile = team || nonTeamprofile;
if (!profile) throw new Error("Event has no owner"); if (!profile) throw new Error("Event has no owner");
const username = "username" in profile ? profile.username : team?.slug; const username = "username" in profile ? profile.username : team?.slug;
const weekStart = hosts?.[0]?.user?.weekStart || owner?.weekStart || "Monday"; const weekStart = hosts?.[0]?.user?.weekStart || owner?.weekStart || "Monday";
const basePath = team ? `/team/${username}` : `/${username}`;
const eventMetaData = EventTypeMetaDataSchema.parse(event.metadata || {}); const eventMetaData = EventTypeMetaDataSchema.parse(event.metadata || {});
const userMetaData = userMetadataSchema.parse(profile.metadata || {}); const userMetaData = userMetadataSchema.parse(profile.metadata || {});
@@ -304,7 +304,22 @@ function getProfileFromEvent(event: Event) {
username, username,
name: profile.name, name: profile.name,
weekStart, weekStart,
image: team ? undefined : `${basePath}/avatar.png`, image: team
? undefined
: // TODO: There must be a better way to do this, maybe a prisma middleware?
// This should come pre-proccessed from the database IMO instead of replacing everywhere
getUserAvatarUrl({
username: username || "",
profile: {
id: nonTeamprofile?.id || null,
username: username || null,
organizationId: nonTeamprofile?.organization?.id || null,
organization: nonTeamprofile?.organization
? { ...nonTeamprofile?.organization, requestedSlug: null }
: null,
},
avatarUrl: nonTeamprofile?.avatarUrl,
}),
logo: !team ? undefined : team.logo, logo: !team ? undefined : team.logo,
brandColor: profile.brandColor, brandColor: profile.brandColor,
darkBrandColor: profile.darkBrandColor, darkBrandColor: profile.darkBrandColor,
+52
View File
@@ -12,3 +12,55 @@ export async function getFeatureFlagMap(prisma: PrismaClient) {
return acc; return acc;
}, {} as Partial<AppFlags>); }, {} as Partial<AppFlags>);
} }
interface CacheEntry {
value: boolean; // adapt to other supported value types in the future
expiry: number;
}
interface CacheOptions {
ttl: number; // time in ms
}
const featureFlagCache = new Map<keyof AppFlags, CacheEntry>();
const isExpired = (entry: CacheEntry): boolean => {
return Date.now() > entry.expiry;
};
export const getFeatureFlag = async (
prisma: PrismaClient,
slug: keyof AppFlags,
options: CacheOptions = { ttl: 5 * 60 * 1000 }
): Promise<boolean> => {
// pre-compute all app flags, each one will independelty reload it's own state after expiry.
if (featureFlagCache.size === 0) {
const flags = await prisma.feature.findMany({ orderBy: { slug: "asc" } });
flags.forEach((flag) => {
featureFlagCache.set(flag.slug as keyof AppFlags, {
value: flag.enabled,
expiry: Date.now() + options.ttl,
});
});
}
const cacheEntry = featureFlagCache.get(slug);
if (cacheEntry && !isExpired(cacheEntry)) {
return cacheEntry.value;
}
const flag = await prisma.feature.findUnique({
where: {
slug,
},
});
const isEnabled = Boolean(flag && flag.enabled);
const expiry = Date.now() + options.ttl;
featureFlagCache.set(slug, { value: isEnabled, expiry });
return isEnabled;
};
+6 -5
View File
@@ -4,23 +4,24 @@ interface ThemeLabelProps {
label: string; label: string;
defaultChecked?: boolean; defaultChecked?: boolean;
register: any; register: any;
fieldName?: string;
} }
export default function ThemeLabel(props: ThemeLabelProps) { export default function ThemeLabel(props: ThemeLabelProps) {
const { variant, label, value, defaultChecked, register } = props; const { variant, label, value, defaultChecked, register, fieldName = "theme" } = props;
return ( return (
<label <label
className="relative mb-4 flex-1 cursor-pointer text-center last:mb-0 last:mr-0 sm:mb-0 sm:mr-4" className="relative mb-4 flex-1 cursor-pointer text-center last:mb-0 last:mr-0 sm:mb-0 sm:mr-4"
htmlFor={`theme-${variant}`} htmlFor={`${fieldName}-${variant}`}
data-testid={`theme-${variant}`}> data-testid={`${fieldName}-${variant}`}>
<input <input
className="peer absolute left-8 top-8" className="peer absolute left-8 top-8"
type="radio" type="radio"
value={value} value={value}
id={`theme-${variant}`} id={`${fieldName}-${variant}`}
defaultChecked={defaultChecked} defaultChecked={defaultChecked}
{...register("theme")} {...register(fieldName)}
/> />
<div className="ring-inverted relative z-10 rounded-lg ring-offset-2 transition-all peer-checked:ring-2"> <div className="ring-inverted relative z-10 rounded-lg ring-offset-2 transition-all peer-checked:ring-2">
<img <img
+9 -6
View File
@@ -48,6 +48,7 @@ import { useFormbricks } from "@calcom/lib/formbricks-client";
import getBrandColours from "@calcom/lib/getBrandColours"; import getBrandColours from "@calcom/lib/getBrandColours";
import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl"; import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import useTheme from "@calcom/lib/hooks/useTheme";
import { isKeyInObject } from "@calcom/lib/isKeyInObject"; import { isKeyInObject } from "@calcom/lib/isKeyInObject";
import type { User } from "@calcom/prisma/client"; import type { User } from "@calcom/prisma/client";
import { trpc } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react";
@@ -329,13 +330,14 @@ type LayoutProps = {
hideHeadingOnMobile?: boolean; hideHeadingOnMobile?: boolean;
}; };
const useBrandColors = () => { const useAppTheme = () => {
const { data: user } = useMeQuery(); const { data: user } = useMeQuery();
const brandTheme = getBrandColours({ const brandTheme = getBrandColours({
lightVal: user?.brandColor, lightVal: user?.brandColor,
darkVal: user?.darkBrandColor, darkVal: user?.darkBrandColor,
}); });
useCalcomTheme(brandTheme); useCalcomTheme(brandTheme);
useTheme(user?.appTheme);
}; };
const KBarWrapper = ({ children, withKBar = false }: { withKBar: boolean; children: React.ReactNode }) => const KBarWrapper = ({ children, withKBar = false }: { withKBar: boolean; children: React.ReactNode }) =>
@@ -361,9 +363,7 @@ export default function Shell(props: LayoutProps) {
// if a page is unauthed and isPublic is true, the redirect does not happen. // if a page is unauthed and isPublic is true, the redirect does not happen.
useRedirectToLoginIfUnauthenticated(props.isPublic); useRedirectToLoginIfUnauthenticated(props.isPublic);
useRedirectToOnboardingIfNeeded(); useRedirectToOnboardingIfNeeded();
// System Theme is automatically supported using ThemeProvider. If we intend to use user theme throughout the app we need to uncomment this. useAppTheme();
// useTheme(profile.theme);
useBrandColors();
return !props.isPublic ? ( return !props.isPublic ? (
<KBarWrapper withKBar> <KBarWrapper withKBar>
@@ -462,8 +462,10 @@ function UserDropdown({ small }: UserDropdownProps) {
</span> </span>
{!small && ( {!small && (
<span className="flex flex-grow items-center gap-2"> <span className="flex flex-grow items-center gap-2">
<span className="line-clamp-1 flex-grow text-sm leading-none"> <span className="w-24 flex-shrink-0 text-sm leading-none">
<span className="text-emphasis block font-medium">{user.name || "Nameless User"}</span> <span className="text-emphasis block truncate font-medium">
{user.name || "Nameless User"}
</span>
</span> </span>
<ChevronDown <ChevronDown
className="group-hover:text-subtle text-muted h-4 w-4 flex-shrink-0 rtl:mr-4" className="group-hover:text-subtle text-muted h-4 w-4 flex-shrink-0 rtl:mr-4"
@@ -729,6 +731,7 @@ const NavigationItem: React.FC<{
<Fragment> <Fragment>
<Tooltip side="right" content={t(item.name)} className="lg:hidden"> <Tooltip side="right" content={t(item.name)} className="lg:hidden">
<Link <Link
data-test-id={item.name}
href={item.href} href={item.href}
aria-label={t(item.name)} aria-label={t(item.name)}
className={classNames( className={classNames(
@@ -30,6 +30,7 @@ type MembershipOption = {
const editSchema = z.object({ const editSchema = z.object({
name: z.string(), name: z.string(),
username: z.string(),
email: z.string().email(), email: z.string().email(),
avatar: z.string(), avatar: z.string(),
bio: z.string(), bio: z.string(),
@@ -59,6 +60,7 @@ export function EditForm({
resolver: zodResolver(editSchema), resolver: zodResolver(editSchema),
defaultValues: { defaultValues: {
name: selectedUser?.name ?? "", name: selectedUser?.name ?? "",
username: selectedUser?.username ?? "",
email: selectedUser?.email ?? "", email: selectedUser?.email ?? "",
avatar: avatarUrl, avatar: avatarUrl,
bio: selectedUser?.bio ?? "", bio: selectedUser?.bio ?? "",
@@ -121,6 +123,7 @@ export function EditForm({
mutation.mutate({ mutation.mutate({
userId: selectedUser?.id ?? "", userId: selectedUser?.id ?? "",
role: values.role, role: values.role,
username: values.username,
name: values.name, name: values.name,
email: values.email, email: values.email,
avatar: values.avatar, avatar: values.avatar,
@@ -157,6 +160,7 @@ export function EditForm({
</div> </div>
</div> </div>
<div className="mt-6 flex flex-col space-y-3"> <div className="mt-6 flex flex-col space-y-3">
<TextField label={t("username")} {...form.register("username")} />
<TextField label={t("name")} {...form.register("name")} /> <TextField label={t("name")} {...form.register("name")} />
<TextField label={t("email")} {...form.register("email")} /> <TextField label={t("email")} {...form.register("email")} />
+3 -1
View File
@@ -46,6 +46,8 @@ const joinMultipleNames = (names: string[] = []) => {
return `${names.length > 0 ? `${names.join(", ")} & ${lastName}` : lastName}`; return `${names.length > 0 ? `${names.join(", ")} & ${lastName}` : lastName}`;
}; };
const makeAbsoluteUrl = (url: string) => (/^https?:\/\//.test(url) ? url : `${CAL_URL}${url}`);
/** /**
* Test urls: * Test urls:
* 1. 1 user http://localhost:3000/api/social/og/image?type=meeting&title=super%20long%20event%20title%20for%20testing%20purposes&meetingProfileName=Pro%20Example&meetingImage=http://localhost:3000/pro/avatar.png&names=Pro%20Example&usernames=pro * 1. 1 user http://localhost:3000/api/social/og/image?type=meeting&title=super%20long%20event%20title%20for%20testing%20purposes&meetingProfileName=Pro%20Example&meetingImage=http://localhost:3000/pro/avatar.png&names=Pro%20Example&usernames=pro
@@ -62,7 +64,7 @@ export const constructMeetingImage = (
`?type=meeting`, `?type=meeting`,
`&title=${encodeURIComponent(title)}`, `&title=${encodeURIComponent(title)}`,
`&meetingProfileName=${encodeURIComponent(profile.name)}`, `&meetingProfileName=${encodeURIComponent(profile.name)}`,
profile.image && `&meetingImage=${encodeURIComponent(profile.image)}`, profile.image && `&meetingImage=${encodeURIComponent(makeAbsoluteUrl(profile.image))}`,
`${users.map((user) => `&names=${encodeURIComponent(user.name)}`).join("")}`, `${users.map((user) => `&names=${encodeURIComponent(user.name)}`).join("")}`,
`${users.map((user) => `&usernames=${encodeURIComponent(user.username)}`).join("")}`, `${users.map((user) => `&usernames=${encodeURIComponent(user.username)}`).join("")}`,
// Joining a multiline string for readability. // Joining a multiline string for readability.
+29
View File
@@ -0,0 +1,29 @@
import prisma from "@calcom/prisma";
export async function renewSelectedCalendarCredentialId(
selectedCalendarWhereUnique: {
userId: number;
integration: string;
externalId: string;
},
credentialId: number
): Promise<boolean> /* True if renewed, false if not */ {
const selectedCalendar = await prisma.selectedCalendar.findFirst({
where: {
...selectedCalendarWhereUnique,
credentialId: null,
},
});
if (selectedCalendar) {
await prisma.selectedCalendar.update({
where: {
userId_integration_externalId: selectedCalendarWhereUnique,
},
data: {
credentialId: credentialId,
},
});
return true;
}
return false;
}
+1 -1
View File
@@ -83,7 +83,7 @@ export const SEO_IMG_DEFAULT = `${CAL_URL}/og-image.png`;
// parameters you pass to the /api/social/og/image endpoint, you wrap them in encodeURIComponent // parameters you pass to the /api/social/og/image endpoint, you wrap them in encodeURIComponent
// as well, otherwise the URL won't be valid. // as well, otherwise the URL won't be valid.
export const SEO_IMG_OGIMG = `${CAL_URL}/_next/image?w=1200&q=100&url=${encodeURIComponent( export const SEO_IMG_OGIMG = `${CAL_URL}/_next/image?w=1200&q=100&url=${encodeURIComponent(
"/api/social/og/image" `/api/social/og/image`
)}`; )}`;
export const SEO_IMG_OGIMG_VIDEO = `${CAL_URL}/video-og-image.png`; export const SEO_IMG_OGIMG_VIDEO = `${CAL_URL}/video-og-image.png`;
export const IS_STRIPE_ENABLED = !!( export const IS_STRIPE_ENABLED = !!(
+7 -1
View File
@@ -46,7 +46,13 @@ export function processWorkingHours({
end = end.add(offsetDiff, "minute"); end = end.add(offsetDiff, "minute");
const startResult = dayjs.max(start, dateFrom); const startResult = dayjs.max(start, dateFrom);
const endResult = dayjs.min(end, dateTo.tz(timeZone)); let endResult = dayjs.min(end, dateTo.tz(timeZone));
// INFO: We only allow users to set availability up to 11:59PM which ends up not making them available
// up to midnight.
if (endResult.hour() === 23 && endResult.minute() === 59) {
endResult = endResult.add(1, "minute");
}
if (endResult.isBefore(startResult)) { if (endResult.isBefore(startResult)) {
// if an event ends before start, it's not a result. // if an event ends before start, it's not a result.
+2 -1
View File
@@ -4,6 +4,7 @@ import { ProfileRepository } from "./repository/profile";
export async function checkRegularUsername(_username: string, currentOrgDomain?: string | null) { export async function checkRegularUsername(_username: string, currentOrgDomain?: string | null) {
const username = slugify(_username); const username = slugify(_username);
const premium = !!process.env.NEXT_PUBLIC_IS_E2E && username.length < 5; const premium = !!process.env.NEXT_PUBLIC_IS_E2E && username.length < 5;
const profiles = currentOrgDomain const profiles = currentOrgDomain
@@ -13,7 +14,7 @@ export async function checkRegularUsername(_username: string, currentOrgDomain?:
}) })
: null; : null;
const user = profiles ? profiles[0].user : null; const user = profiles?.length ? profiles[0].user : null;
if (user) { if (user) {
return { return {
+2
View File
@@ -38,6 +38,7 @@ export const buildBooking = (booking?: Partial<Booking>): Booking => {
uid, uid,
userId: null, userId: null,
eventTypeId: null, eventTypeId: null,
idempotencyKey: null,
userPrimaryEmail: null, userPrimaryEmail: null,
title: faker.lorem.sentence(), title: faker.lorem.sentence(),
description: faker.lorem.paragraph(), description: faker.lorem.paragraph(),
@@ -234,6 +235,7 @@ export const buildUser = <T extends Partial<UserPayload>>(
selectedCalendars: [], selectedCalendars: [],
startTime: 0, startTime: 0,
theme: null, theme: null,
appTheme: null,
timeFormat: null, timeFormat: null,
trialEndsAt: null, trialEndsAt: null,
twoFactorEnabled: false, twoFactorEnabled: false,
@@ -0,0 +1,38 @@
import { Prisma } from "@prisma/client";
import { v5 as uuidv5 } from "uuid";
import { BookingStatus } from "@calcom/prisma/enums";
export function bookingIdempotencyKeyExtension() {
return Prisma.defineExtension({
query: {
booking: {
async create({ args, query }) {
const uniqueEmailJoinInput: string[] = [];
if (args.data.attendees?.create && !Array.isArray(args.data.attendees?.create)) {
uniqueEmailJoinInput.push(args.data.attendees?.create.email);
}
if (args.data.attendees?.createMany && Array.isArray(args.data.attendees?.createMany.data)) {
uniqueEmailJoinInput.push(...args.data.attendees?.createMany.data.map((record) => record.email));
}
const idempotencyKey = uuidv5(
`${
args.data.eventType?.connect?.id
}.${args.data.startTime.valueOf()}.${args.data.endTime.valueOf()}.${uniqueEmailJoinInput.join(
","
)}`,
uuidv5.URL
);
args.data.idempotencyKey = idempotencyKey;
return query(args);
},
async update({ args, query }) {
if (args.data.status === BookingStatus.CANCELLED || args.data.status === BookingStatus.REJECTED) {
args.data.idempotencyKey = null;
}
return query(args);
},
},
},
});
}
+3
View File
@@ -2,6 +2,7 @@ import type { Prisma } from "@prisma/client";
import { PrismaClient as PrismaClientWithoutExtension } from "@prisma/client"; import { PrismaClient as PrismaClientWithoutExtension } from "@prisma/client";
import { withAccelerate } from "@prisma/extension-accelerate"; import { withAccelerate } from "@prisma/extension-accelerate";
import { bookingIdempotencyKeyExtension } from "./extensions/booking-idempotency-key";
import { excludePendingPaymentsExtension } from "./extensions/exclude-pending-payment-teams"; import { excludePendingPaymentsExtension } from "./extensions/exclude-pending-payment-teams";
import { bookingReferenceMiddleware } from "./middleware"; import { bookingReferenceMiddleware } from "./middleware";
@@ -21,6 +22,7 @@ const prismaWithoutClientExtensions =
export const customPrisma = (options?: Prisma.PrismaClientOptions) => export const customPrisma = (options?: Prisma.PrismaClientOptions) =>
new PrismaClientWithoutExtension({ ...prismaOptions, ...options }) new PrismaClientWithoutExtension({ ...prismaOptions, ...options })
.$extends(excludePendingPaymentsExtension()) .$extends(excludePendingPaymentsExtension())
.$extends(bookingIdempotencyKeyExtension())
.$extends(withAccelerate()); .$extends(withAccelerate());
// If any changed on middleware server restart is required // If any changed on middleware server restart is required
@@ -31,6 +33,7 @@ bookingReferenceMiddleware(prismaWithoutClientExtensions);
// Specifically we get errors like `Type 'string | Date | null | undefined' is not assignable to type 'Exact<string | Date | null | undefined, string | Date | null | undefined>'` // Specifically we get errors like `Type 'string | Date | null | undefined' is not assignable to type 'Exact<string | Date | null | undefined, string | Date | null | undefined>'`
const prismaWithClientExtensions = prismaWithoutClientExtensions const prismaWithClientExtensions = prismaWithoutClientExtensions
.$extends(excludePendingPaymentsExtension()) .$extends(excludePendingPaymentsExtension())
.$extends(bookingIdempotencyKeyExtension())
.$extends(withAccelerate()); .$extends(withAccelerate());
export const prisma = globalForPrisma.prismaWithClientExtensions || prismaWithClientExtensions; export const prisma = globalForPrisma.prismaWithClientExtensions || prismaWithClientExtensions;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "appTheme" TEXT;
@@ -0,0 +1,11 @@
/*
Warnings:
- A unique constraint covering the columns `[idempotencyKey]` on the table `Booking` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "Booking" ADD COLUMN "idempotencyKey" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "Booking_idempotencyKey_key" ON "Booking"("idempotencyKey");
+4
View File
@@ -221,7 +221,9 @@ model User {
// </DEPRECATED> // </DEPRECATED>
bufferTime Int @default(0) bufferTime Int @default(0)
hideBranding Boolean @default(false) hideBranding Boolean @default(false)
// TODO: should be renamed since it only affects the booking page
theme String? theme String?
appTheme String?
createdDate DateTime @default(now()) @map(name: "created") createdDate DateTime @default(now()) @map(name: "created")
trialEndsAt DateTime? trialEndsAt DateTime?
eventTypes EventType[] @relation("user_eventtype") eventTypes EventType[] @relation("user_eventtype")
@@ -460,6 +462,8 @@ enum BookingStatus {
model Booking { model Booking {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
uid String @unique uid String @unique
// (optional) UID based on slot start/end time & email against duplicates
idempotencyKey String? @unique
user User? @relation(fields: [userId], references: [id], onDelete: Cascade) user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int? userId Int?
// User's email at the time of booking // User's email at the time of booking
+17 -6
View File
@@ -38,13 +38,9 @@ export async function createUserAndEventType({
appId: string; appId: string;
} | null)[]; } | null)[];
}) { }) {
const { password, ...restOfUser } = user;
const userData = { const userData = {
...user, ...restOfUser,
password: {
create: {
hash: await hashPassword(user.password),
},
},
emailVerified: new Date(), emailVerified: new Date(),
completedOnboarding: user.completedOnboarding ?? true, completedOnboarding: user.completedOnboarding ?? true,
locale: "en", locale: "en",
@@ -69,6 +65,21 @@ export async function createUserAndEventType({
create: userData, create: userData,
}); });
await prisma.userPassword.upsert({
where: { userId: theUser.id },
update: {
hash: await hashPassword(user.password),
},
create: {
hash: await hashPassword(user.password),
user: {
connect: {
id: theUser.id,
},
},
},
});
console.log( console.log(
`👤 Upserted '${user.username}' with email "${user.email}" & password "${user.password}". Booking page 👉 ${process.env.NEXT_PUBLIC_WEBAPP_URL}/${user.username}` `👤 Upserted '${user.username}' with email "${user.email}" & password "${user.password}". Booking page 👉 ${process.env.NEXT_PUBLIC_WEBAPP_URL}/${user.username}`
); );
@@ -45,6 +45,7 @@ export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe<S
defaultScheduleId: true, defaultScheduleId: true,
bufferTime: true, bufferTime: true,
theme: true, theme: true,
appTheme: true,
createdDate: true, createdDate: true,
hideBranding: true, hideBranding: true,
twoFactorEnabled: true, twoFactorEnabled: true,
@@ -54,6 +54,7 @@ export const meHandler = async ({ ctx }: MeOptions) => {
bio: user.bio, bio: user.bio,
weekStart: user.weekStart, weekStart: user.weekStart,
theme: user.theme, theme: user.theme,
appTheme: user.appTheme,
hideBranding: user.hideBranding, hideBranding: user.hideBranding,
metadata: user.metadata, metadata: user.metadata,
defaultBookerLayouts: user.defaultBookerLayouts, defaultBookerLayouts: user.defaultBookerLayouts,
@@ -6,7 +6,7 @@ import stripe from "@calcom/app-store/stripepayment/lib/server";
import { getPremiumMonthlyPlanPriceId } from "@calcom/app-store/stripepayment/lib/utils"; import { getPremiumMonthlyPlanPriceId } from "@calcom/app-store/stripepayment/lib/utils";
import { passwordResetRequest } from "@calcom/features/auth/lib/passwordResetRequest"; import { passwordResetRequest } from "@calcom/features/auth/lib/passwordResetRequest";
import { sendChangeOfEmailVerification } from "@calcom/features/auth/lib/verifyEmail"; import { sendChangeOfEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils"; import { getFeatureFlag } from "@calcom/features/flags/server/utils";
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata"; import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
import { HttpError } from "@calcom/lib/http-error"; import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger"; import logger from "@calcom/lib/logger";
@@ -63,7 +63,7 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
const { user } = ctx; const { user } = ctx;
const userMetadata = handleUserMetadata({ ctx, input }); const userMetadata = handleUserMetadata({ ctx, input });
const locale = input.locale || user.locale; const locale = input.locale || user.locale;
const flags = await getFeatureFlagMap(prisma); const emailVerification = await getFeatureFlag(prisma, "email-verification");
const data: Prisma.UserUpdateInput = { const data: Prisma.UserUpdateInput = {
...input, ...input,
@@ -135,10 +135,8 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
hasEmailBeenChanged && user.identityProvider !== IdentityProvider.CAL; hasEmailBeenChanged && user.identityProvider !== IdentityProvider.CAL;
const hasEmailChangedOnCalProvider = hasEmailBeenChanged && user.identityProvider === IdentityProvider.CAL; const hasEmailChangedOnCalProvider = hasEmailBeenChanged && user.identityProvider === IdentityProvider.CAL;
const sendEmailVerification = flags["email-verification"];
if (hasEmailBeenChanged) { if (hasEmailBeenChanged) {
if (sendEmailVerification && hasEmailChangedOnCalProvider) { if (emailVerification && hasEmailChangedOnCalProvider) {
// Set metadata of the user so we can set it to this updated email once it is confirmed // Set metadata of the user so we can set it to this updated email once it is confirmed
data.metadata = { data.metadata = {
...userMetadata, ...userMetadata,
@@ -284,12 +282,12 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
return { return {
...input, ...input,
email: sendEmailVerification && hasEmailChangedOnCalProvider ? user.email : input.email, email: emailVerification && hasEmailChangedOnCalProvider ? user.email : input.email,
signOutUser, signOutUser,
passwordReset, passwordReset,
avatarUrl: updatedUser.avatarUrl, avatarUrl: updatedUser.avatarUrl,
hasEmailBeenChanged, hasEmailBeenChanged,
sendEmailVerification, sendEmailVerification: emailVerification,
}; };
}; };
@@ -23,6 +23,7 @@ export const ZUpdateProfileInputSchema = z.object({
brandColor: z.string().optional(), brandColor: z.string().optional(),
darkBrandColor: z.string().optional(), darkBrandColor: z.string().optional(),
theme: z.string().optional().nullable(), theme: z.string().optional().nullable(),
appTheme: z.string().optional().nullable(),
completedOnboarding: z.boolean().optional(), completedOnboarding: z.boolean().optional(),
locale: z.string().optional(), locale: z.string().optional(),
timeFormat: z.number().optional(), timeFormat: z.number().optional(),
@@ -1,7 +1,11 @@
import type { Prisma, PrismaPromise, User, Membership, Profile } from "@prisma/client";
import { checkRegularUsername } from "@calcom/lib/server/checkRegularUsername";
import { isOrganisationAdmin, isOrganisationOwner } from "@calcom/lib/server/queries/organisations"; import { isOrganisationAdmin, isOrganisationOwner } from "@calcom/lib/server/queries/organisations";
import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image"; import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image";
import { prisma } from "@calcom/prisma"; import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums"; import { MembershipRole } from "@calcom/prisma/enums";
import { uploadAvatar } from "@calcom/trpc/server/routers/loggedInViewer/updateProfile.handler";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
@@ -32,6 +36,7 @@ const applyRoleToAllTeams = async (userId: number, teamIds: number[], role: Memb
export const updateUserHandler = async ({ ctx, input }: UpdateUserOptions) => { export const updateUserHandler = async ({ ctx, input }: UpdateUserOptions) => {
const { user } = ctx; const { user } = ctx;
const { id: userId, organizationId } = user; const { id: userId, organizationId } = user;
if (!organizationId) if (!organizationId)
throw new TRPCError({ code: "UNAUTHORIZED", message: "You must be a memeber of an organizaiton" }); throw new TRPCError({ code: "UNAUTHORIZED", message: "You must be a memeber of an organizaiton" });
@@ -74,30 +79,58 @@ export const updateUserHandler = async ({ ctx, input }: UpdateUserOptions) => {
}, },
}, },
}, },
user: {
select: {
username: true,
},
},
}, },
}); });
if (!requestedMember) if (!requestedMember)
throw new TRPCError({ code: "UNAUTHORIZED", message: "User does not belong to your organization" }); throw new TRPCError({ code: "UNAUTHORIZED", message: "User does not belong to your organization" });
let avatar = input.avatar; const hasUsernameUpdated = input.username !== requestedMember.user.username;
if (input.avatar) {
avatar = await resizeBase64Image(input.avatar); if (input.username && hasUsernameUpdated && user.profile.organization?.slug) {
const checkRegularUsernameRes = await checkRegularUsername(
input.username,
user.profile.organization.slug
);
if (!checkRegularUsernameRes.available) {
throw new TRPCError({ code: "BAD_REQUEST", message: checkRegularUsernameRes.message });
}
}
const data: Prisma.UserUpdateInput = {
bio: input.bio,
email: input.email,
name: input.name,
timeZone: input.timeZone,
username: input.username,
avatar: undefined,
};
if (input.avatar && input.avatar.startsWith("data:image/png;base64,")) {
const avatar = await resizeBase64Image(input.avatar);
data.avatarUrl = await uploadAvatar({
avatar,
userId: user.id,
});
data.avatar = avatar;
}
if (input.avatar === "") {
data.avatarUrl = null;
data.avatar = null;
} }
// Update user // Update user
await prisma.$transaction([ const transactions: PrismaPromise<User | Membership | Profile>[] = [
prisma.user.update({ prisma.user.update({
where: { where: {
id: input.userId, id: input.userId,
}, },
data: { data,
bio: input.bio,
email: input.email,
name: input.name,
timeZone: input.timeZone,
avatar,
},
}), }),
prisma.membership.update({ prisma.membership.update({
where: { where: {
@@ -110,7 +143,25 @@ export const updateUserHandler = async ({ ctx, input }: UpdateUserOptions) => {
role: input.role, role: input.role,
}, },
}), }),
]); ];
if (hasUsernameUpdated) {
transactions.push(
prisma.profile.update({
where: {
userId_organizationId: {
userId: input.userId,
organizationId,
},
},
data: {
username: input.username,
},
})
);
}
await prisma.$transaction(transactions);
if (input.role === MembershipRole.ADMIN || input.role === MembershipRole.OWNER) { if (input.role === MembershipRole.ADMIN || input.role === MembershipRole.OWNER) {
const teamIds = requestedMember.team.children const teamIds = requestedMember.team.children
@@ -2,6 +2,7 @@ import { z } from "zod";
export const ZUpdateUserInputSchema = z.object({ export const ZUpdateUserInputSchema = z.object({
userId: z.number(), userId: z.number(),
username: z.string().optional(),
bio: z.string().optional(), bio: z.string().optional(),
name: z.string().optional(), name: z.string().optional(),
email: z.string().optional(), email: z.string().optional(),
@@ -515,22 +515,20 @@ export async function getAvailableSlots({ input, ctx }: GetScheduleOptions) {
); );
if (occupiedSeats?.length) { if (occupiedSeats?.length) {
const addedToCurrentSeats: string[] = []; const addedToCurrentSeats: string[] = [];
if (typeof availabilityCheckProps.currentSeats !== undefined) { if (typeof availabilityCheckProps.currentSeats !== "undefined") {
availabilityCheckProps.currentSeats = (availabilityCheckProps.currentSeats as CurrentSeats).map( availabilityCheckProps.currentSeats = availabilityCheckProps.currentSeats.map((item) => {
(item) => { const attendees =
const attendees = occupiedSeats.filter(
occupiedSeats.filter( (seat) => seat.slotUtcStartDate.toISOString() === item.startTime.toISOString()
(seat) => seat.slotUtcStartDate.toISOString() === item.startTime.toISOString() )?.length || 0;
)?.length || 0; if (attendees) addedToCurrentSeats.push(item.startTime.toISOString());
if (attendees) addedToCurrentSeats.push(item.startTime.toISOString()); return {
return { ...item,
...item, _count: {
_count: { attendees: item._count.attendees + attendees,
attendees: item._count.attendees + attendees, },
}, };
}; });
}
) as CurrentSeats;
occupiedSeats = occupiedSeats.filter( occupiedSeats = occupiedSeats.filter(
(item) => !addedToCurrentSeats.includes(item.slotUtcStartDate.toISOString()) (item) => !addedToCurrentSeats.includes(item.slotUtcStartDate.toISOString())
); );
@@ -23,17 +23,20 @@ export const deleteHandler = async ({ ctx, input }: DeleteOptions) => {
if (IS_TEAM_BILLING_ENABLED) await cancelTeamSubscriptionFromStripe(input.teamId); if (IS_TEAM_BILLING_ENABLED) await cancelTeamSubscriptionFromStripe(input.teamId);
// delete all memberships const deletedTeam = await prisma.$transaction(async (tx) => {
await prisma.membership.deleteMany({ // delete all memberships
where: { await tx.membership.deleteMany({
teamId: input.teamId, where: {
}, teamId: input.teamId,
}); },
});
const deletedTeam = await prisma.team.delete({ const deletedTeam = await tx.team.delete({
where: { where: {
id: input.teamId, id: input.teamId,
}, },
});
return deletedTeam;
}); });
const deletedTeamMetadata = teamMetadataSchema.parse(deletedTeam.metadata); const deletedTeamMetadata = teamMetadataSchema.parse(deletedTeam.metadata);
@@ -285,7 +285,7 @@ export async function createNewUsersConnectToOrgIfExists({
data: { data: {
teamId: parentId, teamId: parentId,
userId: createdUser.id, userId: createdUser.id,
role: input.role as MembershipRole, role: MembershipRole.MEMBER,
accepted: autoAccept, accepted: autoAccept,
}, },
}); });
@@ -332,7 +332,7 @@ export async function createMemberships({
accepted, accepted,
teamId: parentId, teamId: parentId,
userId: invitee.id, userId: invitee.id,
role: input.role as MembershipRole, role: MembershipRole.MEMBER,
}); });
} }
return data; return data;
@@ -43,25 +43,26 @@ export const removeMemberHandler = async ({ ctx, input }: RemoveMemberOptions) =
message: "You can not remove yourself from a team you own.", message: "You can not remove yourself from a team you own.",
}); });
const membership = await ctx.prisma.membership.delete({ const [membership] = await ctx.prisma.$transaction([
where: { ctx.prisma.membership.delete({
userId_teamId: { userId: input.memberId, teamId: input.teamId }, where: {
}, userId_teamId: { userId: input.memberId, teamId: input.teamId },
include: {
user: true,
team: true,
},
});
// remove user as host from team events associated with this membership
await ctx.prisma.host.deleteMany({
where: {
userId: input.memberId,
eventType: {
teamId: input.teamId,
}, },
}, include: {
}); user: true,
team: true,
},
}),
// remove user as host from team events associated with this membership
ctx.prisma.host.deleteMany({
where: {
userId: input.memberId,
eventType: {
teamId: input.teamId,
},
},
}),
]);
if (input.isOrg) { if (input.isOrg) {
log.debug("Removing a member from the organization"); log.debug("Removing a member from the organization");
@@ -81,6 +81,7 @@ export const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function
onClickAddon, onClickAddon,
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
t: __t, t: __t,
dataTestid,
...passThrough ...passThrough
} = props; } = props;
@@ -114,6 +115,7 @@ export const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function
</Addon> </Addon>
)} )}
<Input <Input
data-testid={`${dataTestid}-input` ?? "input-field"}
id={id} id={id}
type={type} type={type}
placeholder={placeholder} placeholder={placeholder}
+1
View File
@@ -17,6 +17,7 @@ export type InputFieldProps = {
containerClassName?: string; containerClassName?: string;
showAsteriskIndicator?: boolean; showAsteriskIndicator?: boolean;
t?: (key: string) => string; t?: (key: string) => string;
dataTestid?: string;
onClickAddon?: () => void; onClickAddon?: () => void;
} & React.ComponentProps<typeof Input> & { } & React.ComponentProps<typeof Input> & {
labelProps?: React.ComponentProps<typeof Label>; labelProps?: React.ComponentProps<typeof Label>;
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff. Show more