2
0

first commit

This commit is contained in:
2024-08-09 00:39:27 +02:00
commit 79688abe2e
5698 changed files with 497838 additions and 0 deletions

View File

@@ -0,0 +1,42 @@
The Cal.com Commercial License (the “Commercial License”)
Copyright (c) 2020-present Cal.com, Inc
With regard to the Cal.com Software:
This software and associated documentation files (the "Software") may only be
used in production, if you (and any entity that you represent) have agreed to,
and are in compliance with, the Cal.com Subscription Terms available
at https://cal.com/terms, or other agreements governing
the use of the Software, as mutually agreed by you and Cal.com, Inc ("Cal.com"),
and otherwise have a valid Cal.com Enterprise Edition subscription ("Commercial Subscription")
for the correct number of hosts as defined in the "Commercial Terms ("Hosts"). Subject to the foregoing sentence,
you are free to modify this Software and publish patches to the Software. You agree
that Cal.com and/or its licensors (as applicable) retain all right, title and interest in
and to all such modifications and/or patches, and all such modifications and/or
patches may only be used, copied, modified, displayed, distributed, or otherwise
exploited with a valid Commercial Subscription for the correct number of hosts.
Notwithstanding the foregoing, you may copy and modify the Software for development
and testing purposes, without requiring a subscription. You agree that Cal.com and/or
its licensors (as applicable) retain all right, title and interest in and to all such
modifications. You are not granted any other rights beyond what is expressly stated herein.
Subject to the foregoing, it is forbidden to copy, merge, publish, distribute, sublicense,
and/or sell the Software.
This Commercial License applies only to the part of this Software that is not distributed under
the AGPLv3 license. Any part of this Software distributed under the MIT license or which
is served client-side as an image, font, cascading stylesheet (CSS), file which produces
or is compiled, arranged, augmented, or combined into client-side JavaScript, in whole or
in part, is copyrighted under the AGPLv3 license. The full text of this Commercial License shall
be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
For all third party components incorporated into the Cal.com Software, those
components are licensed under the original license provided by the owner of the
applicable component.

View File

@@ -0,0 +1,38 @@
<!-- PROJECT LOGO -->
<div align="center">
<a href="https://cal.com/enterprise">
<img src="https://user-images.githubusercontent.com/8019099/133430653-24422d2a-3c8d-4052-9ad6-0580597151ee.png" alt="Logo">
</a>
<a href="https://cal.com/sales">Get a License Key</a>
</div>
# Enterprise Edition
Welcome to the Enterprise Edition ("/ee") of Cal.com.
The [/ee](https://github.com/calcom/cal.com/tree/main/packages/features/ee) subfolder is the place for all the **Enterprise Edition** features from our [hosted](https://cal.com/pricing) plan and enterprise-grade features for [Enterprise](https://cal.com/enterprise) such as SSO, SAML, OIDC, SCIM, SIEM and much more or [Platform](https://cal.com/platform) plan to build a marketplace.
> _❗ WARNING: This repository is copyrighted (unlike our [main repo](https://github.com/calcom/cal.com)). You are not allowed to use this code to host your own version of app.cal.com without obtaining a proper [license](https://cal.com/sales) first❗_
## Setting up Stripe
1. Create a stripe account or use an existing one. For testing, you should use all stripe dashboard functions with the Test-Mode toggle in the top right activated.
2. Open [Stripe ApiKeys](https://dashboard.stripe.com/apikeys) save the token starting with `pk_...` to `NEXT_PUBLIC_STRIPE_PUBLIC_KEY` and `sk_...` to `STRIPE_PRIVATE_KEY` in the .env file.
3. Open [Stripe Connect Settings](https://dashboard.stripe.com/settings/connect) and activate OAuth for Standard Accounts
4. Add `<CALENDSO URL>/api/integrations/stripepayment/callback` as redirect URL.
5. Copy your client*id (`ca*...`) to `STRIPE_CLIENT_ID` in the .env file.
6. Open [Stripe Webhooks](https://dashboard.stripe.com/webhooks) and add `<CALENDSO URL>/api/integrations/stripepayment/webhook` as webhook for connected applications.
7. Select all `payment_intent` events for the webhook.
8. Copy the webhook secret (`whsec_...`) to `STRIPE_WEBHOOK_SECRET` in the .env file.
## Setting up SAML login
1. Set SAML_DATABASE_URL to a postgres database. Please use a different database than the main Cal instance since the migrations are separate for this database. For example `postgresql://postgres:@localhost:5450/cal-saml`
2. Set SAML_ADMINS to a comma separated list of admin emails from where the SAML metadata can be uploaded and configured.
3. Create a SAML application with your Identity Provider (IdP) using the instructions here - [SAML Setup](../../apps/web/docs/saml-setup.md)
4. Remember to configure access to the IdP SAML app for all your users (who need access to Cal).
5. You will need the XML metadata from your IdP later, so keep it accessible.
6. Log in to one of the admin accounts configured in SAML_ADMINS and then navigate to Settings -> Security.
7. You should see a SAML configuration section, copy and paste the XML metadata from step 5 and click on Save.
8. Your provisioned users can now log into Cal using SAML.

View File

@@ -0,0 +1,225 @@
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import dayjs from "@calcom/dayjs";
import type { TApiKeys } from "@calcom/ee/api-keys/components/ApiKeyListItem";
import LicenseRequired from "@calcom/ee/common/components/LicenseRequired";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Button, DialogFooter, Form, SelectField, showToast, Switch, TextField, Tooltip } from "@calcom/ui";
export default function ApiKeyDialogForm({
defaultValues,
handleClose,
}: {
defaultValues?: Omit<TApiKeys, "userId" | "createdAt" | "lastUsedAt"> & { neverExpires?: boolean };
handleClose: () => void;
}) {
const { t } = useLocale();
const utils = trpc.useUtils();
const updateApiKeyMutation = trpc.viewer.apiKeys.edit.useMutation({
onSuccess() {
utils.viewer.apiKeys.list.invalidate();
showToast(t("api_key_updated"), "success");
handleClose();
},
onError() {
showToast(t("api_key_update_failed"), "error");
},
});
type Option = { value: Date | null | undefined; label: string };
const [apiKey, setApiKey] = useState("");
const [expiryDate, setExpiryDate] = useState<Date | null | undefined>(
() => defaultValues?.expiresAt || dayjs().add(30, "day").toDate()
);
const [successfulNewApiKeyModal, setSuccessfulNewApiKeyModal] = useState(false);
const [apiKeyDetails, setApiKeyDetails] = useState({
expiresAt: null as Date | null,
note: "" as string | null,
neverExpires: false,
});
const form = useForm({
defaultValues: {
note: defaultValues?.note || "",
neverExpires: defaultValues?.neverExpires || false,
expiresAt: defaultValues?.expiresAt || dayjs().add(30, "day").toDate(),
},
});
const watchNeverExpires = form.watch("neverExpires");
const expiresAtOptions: Option[] = [
{
label: t("seven_days"),
value: dayjs().add(7, "day").toDate(),
},
{
label: t("thirty_days"),
value: dayjs().add(30, "day").toDate(),
},
{
label: t("three_months"),
value: dayjs().add(3, "month").toDate(),
},
{
label: t("one_year"),
value: dayjs().add(1, "year").toDate(),
},
];
return (
<LicenseRequired>
{successfulNewApiKeyModal ? (
<>
<div className="mb-6">
<h2 className="font-semi-bold font-cal text-emphasis mb-2 text-xl tracking-wide">
{t("success_api_key_created")}
</h2>
<div className="text-emphasis text-sm">
<span className="font-semibold">{t("success_api_key_created_bold_tagline")}</span>{" "}
{t("you_will_only_view_it_once")}
</div>
</div>
<div>
<div className="flex">
<code className="bg-subtle text-default w-full truncate rounded-md rounded-r-none py-[6px] pl-2 pr-2 align-middle font-mono">
{" "}
{apiKey}
</code>
<Tooltip side="top" content={t("copy_to_clipboard")}>
<Button
onClick={() => {
navigator.clipboard.writeText(apiKey);
showToast(t("api_key_copied"), "success");
}}
type="button"
className="rounded-l-none text-base"
StartIcon="clipboard">
{t("copy")}
</Button>
</Tooltip>
</div>
<span className="text-muted text-sm">
{apiKeyDetails.neverExpires
? t("never_expires")
: `${t("expires")} ${apiKeyDetails?.expiresAt?.toLocaleDateString()}`}
</span>
</div>
<DialogFooter showDivider className="relative">
<Button type="button" color="secondary" onClick={handleClose} tabIndex={-1}>
{t("done")}
</Button>
</DialogFooter>
</>
) : (
<Form
form={form}
handleSubmit={async (event) => {
if (defaultValues) {
console.log("Name changed");
await updateApiKeyMutation.mutate({ id: defaultValues.id, note: event.note });
} else {
const apiKey = await utils.client.viewer.apiKeys.create.mutate(event);
setApiKey(apiKey);
setApiKeyDetails({ ...event });
await utils.viewer.apiKeys.list.invalidate();
setSuccessfulNewApiKeyModal(true);
}
}}
className="space-y-4">
<div className="mb-4 mt-1">
<h2 className="font-semi-bold font-cal text-emphasis text-xl tracking-wide">
{defaultValues ? t("edit_api_key") : t("create_api_key")}
</h2>
<p className="text-subtle mb-5 mt-1 text-sm">{t("api_key_modal_subtitle")}</p>
</div>
<div>
<Controller
name="note"
control={form.control}
render={({ field: { onChange, value } }) => (
<TextField
name="note"
label={t("personal_note")}
placeholder={t("personal_note_placeholder")}
value={value}
onChange={(e) => {
form.setValue("note", e?.target.value);
}}
type="text"
/>
)}
/>
</div>
{!defaultValues && (
<div className="flex flex-col">
<div className="flex justify-between py-2">
<span className="text-default block text-sm font-medium">{t("expire_date")}</span>
<Controller
name="neverExpires"
control={form.control}
render={({ field: { onChange, value } }) => (
<Switch
label={t("never_expires")}
onCheckedChange={onChange}
checked={value}
disabled={!!defaultValues}
/>
)}
/>
</div>
<Controller
name="expiresAt"
render={({ field: { onChange, value } }) => {
const defaultValue = expiresAtOptions[1];
return (
<SelectField
styles={{
singleValue: (baseStyles) => ({
...baseStyles,
fontSize: "14px",
}),
option: (baseStyles) => ({
...baseStyles,
fontSize: "14px",
}),
}}
isDisabled={watchNeverExpires || !!defaultValues}
containerClassName="data-testid-field-type"
options={expiresAtOptions}
onChange={(option) => {
if (!option) {
return;
}
onChange(option.value);
setExpiryDate(option.value);
}}
defaultValue={defaultValue}
/>
);
}}
/>
{!watchNeverExpires && (
<span className="text-subtle mt-2 text-xs">
{t("api_key_expires_on")}
<span className="font-bold"> {dayjs(expiryDate).format("DD-MM-YYYY")}</span>
</span>
)}
</div>
)}
<DialogFooter showDivider className="relative">
<Button type="button" color="secondary" onClick={handleClose} tabIndex={-1}>
{t("cancel")}
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
{apiKeyDetails ? t("save") : t("create")}
</Button>
</DialogFooter>
</Form>
)}
</LicenseRequired>
);
}

View File

@@ -0,0 +1,101 @@
import dayjs from "@calcom/dayjs";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import {
Badge,
Button,
Dropdown,
DropdownItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
showToast,
} from "@calcom/ui";
export type TApiKeys = RouterOutputs["viewer"]["apiKeys"]["list"][number];
const ApiKeyListItem = ({
apiKey,
lastItem,
onEditClick,
}: {
apiKey: TApiKeys;
lastItem: boolean;
onEditClick: () => void;
}) => {
const { t } = useLocale();
const utils = trpc.useUtils();
const isExpired = apiKey?.expiresAt ? apiKey.expiresAt < new Date() : null;
const neverExpires = apiKey?.expiresAt === null;
const deleteApiKey = trpc.viewer.apiKeys.delete.useMutation({
async onSuccess() {
await utils.viewer.apiKeys.list.invalidate();
showToast(t("api_key_deleted"), "success");
},
onError(err) {
console.log(err);
showToast(t("something_went_wrong"), "error");
},
});
return (
<div
key={apiKey.id}
className={classNames(
"flex w-full justify-between px-4 py-4 sm:px-6",
lastItem ? "" : "border-subtle border-b"
)}>
<div>
<div className="flex gap-1">
<p className="text-sm font-semibold"> {apiKey?.note ? apiKey.note : t("api_key_no_note")}</p>
{!neverExpires && isExpired && <Badge variant="red">{t("expired")}</Badge>}
{!isExpired && <Badge variant="green">{t("active")}</Badge>}
</div>
<div className="mt-1 flex items-center space-x-3.5">
<p className="text-default text-sm">
{neverExpires ? (
<div className="flex flex-row space-x-3">{t("api_key_never_expires")}</div>
) : (
`${isExpired ? t("expired") : t("expires")} ${dayjs(apiKey?.expiresAt?.toString()).fromNow()}`
)}
</p>
</div>
</div>
<div>
<Dropdown>
<DropdownMenuTrigger asChild>
<Button type="button" variant="icon" color="secondary" StartIcon="ellipsis" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<DropdownItem type="button" onClick={onEditClick} StartIcon="pencil">
{t("edit") as string}
</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem
type="button"
color="destructive"
disabled={deleteApiKey.isPending}
onClick={() =>
deleteApiKey.mutate({
id: apiKey.id,
})
}
StartIcon="trash">
{t("delete") as string}
</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</div>
</div>
);
};
export default ApiKeyListItem;

View File

@@ -0,0 +1,10 @@
import { randomBytes, createHash } from "crypto";
// Hash the API key to check against when veriying it. so we don't have to store the key in plain text.
export const hashAPIKey = (apiKey: string): string => createHash("sha256").update(apiKey).digest("hex");
// Generate a random API key. Prisma already makes sure it's unique. So no need to add salts like with passwords.
export const generateUniqueAPIKey = (apiKey = randomBytes(16).toString("hex")) => [
hashAPIKey(apiKey),
apiKey,
];

View File

@@ -0,0 +1,26 @@
import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys";
import prisma from "@calcom/prisma";
const findValidApiKey = async (apiKey: string, appId?: string) => {
const hashedKey = hashAPIKey(apiKey.substring(process.env.API_KEY_PREFIX?.length || 0));
const validKey = await prisma.apiKey.findFirst({
where: {
hashedKey,
appId,
OR: [
{
expiresAt: {
gte: new Date(Date.now()),
},
},
{
expiresAt: null,
},
],
},
});
return validKey;
};
export default findValidApiKey;

View File

@@ -0,0 +1,78 @@
import { useSession } from "next-auth/react";
import type { AriaRole, ComponentType } from "react";
import React, { Fragment, useEffect } from "react";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { EmptyScreen, Alert, Button } from "@calcom/ui";
type LicenseRequiredProps = {
as?: keyof JSX.IntrinsicElements | "";
className?: string;
role?: AriaRole | undefined;
children: React.ReactNode;
};
const LicenseRequired = ({ children, as = "", ...rest }: LicenseRequiredProps) => {
const session = useSession();
const { t } = useLocale();
const Component = as || Fragment;
const hasValidLicense = session.data ? session.data.hasValidLicense : null;
useEffect(() => {
if (process.env.NODE_ENV === "development" && hasValidLicense === false) {
// Very few people will see this, so we don't need to translate it
console.info(
`You're using a feature that requires a valid license. Please go to ${WEBAPP_URL}/auth/setup to enter a license key.`
);
}
}, []);
return (
<Component {...rest}>
{hasValidLicense === null || hasValidLicense ? (
children
) : process.env.NODE_ENV === "development" ? (
/** We only show a warning in development mode, but allow the feature to be displayed for development/testing purposes */
<>
<Alert
className="mb-4"
severity="warning"
title={
<>
{t("enterprise_license_locally")} {t("enterprise_license_sales")}{" "}
<a className="underline" href="https://bls.media/kontakt">
{t("contact_sales")}
</a>
</>
}
/>
{children}
</>
) : (
<EmptyScreen
Icon="triangle-alert"
headline={t("enterprise_license")}
buttonRaw={
<Button color="secondary" href="https://bls.media/kontakt">
{t(`contact_sales`)}
</Button>
}
description={t("enterprise_license_sales")}
/>
)}
</Component>
);
};
export const withLicenseRequired =
<T extends JSX.IntrinsicAttributes>(Component: ComponentType<T>) =>
// eslint-disable-next-line react/display-name
(hocProps: T) =>
(
<LicenseRequired>
<Component {...hocProps} />
</LicenseRequired>
);
export default LicenseRequired;

View File

@@ -0,0 +1,25 @@
import { WEBSITE_URL } from "@calcom/lib/constants";
import slugify from "@calcom/lib/slugify";
interface ResponseUsernameApi {
available: boolean;
premium: boolean;
message?: string;
suggestion?: string;
}
export async function checkPremiumUsername(_username: string): Promise<ResponseUsernameApi> {
const username = slugify(_username);
const response = await fetch(`${WEBSITE_URL}/api/username`, {
credentials: "include",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ username }),
method: "POST",
mode: "cors",
});
const json = await response.json();
return json;
}

View File

@@ -0,0 +1,52 @@
import cache from "memory-cache";
import { z } from "zod";
import { CONSOLE_URL } from "@calcom/lib/constants";
import type { PrismaClient } from "@calcom/prisma";
const CACHING_TIME = 86400000; // 24 hours in milliseconds
const schemaLicenseKey = z
.string()
// .uuid() exists but I'd to fix the situation where the CALCOM_LICENSE_KEY is wrapped in quotes
.regex(/^\"?[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}\"?$/, {
message: "License key must follow UUID format: 8-4-4-4-12",
})
.transform((v) => {
// Remove the double quotes from the license key, as they 404 the fetch.
return v != null && v.length >= 2 && v.charAt(0) == '"' && v.charAt(v.length - 1) == '"'
? v.substring(1, v.length - 1)
: v;
});
async function checkLicense(
/** The prisma client to use (necessary for public API to handle custom prisma instances) */
prisma: PrismaClient
): Promise<boolean> {
/** We skip for E2E testing */
if (!!process.env.NEXT_PUBLIC_IS_E2E) return true;
/** We check first on env */
let licenseKey = process.env.CALCOM_LICENSE_KEY;
if (!licenseKey) {
/** We try to check on DB only if env is undefined */
const deployment = await prisma.deployment.findFirst({ where: { id: 1 } });
licenseKey = deployment?.licenseKey ?? undefined;
}
if (!licenseKey) return false;
const url = `${CONSOLE_URL}/api/license?key=${schemaLicenseKey.parse(licenseKey)}`;
const cachedResponse = cache.get(url);
if (cachedResponse) {
return cachedResponse;
} else {
try {
const response = await fetch(url, { mode: "cors" });
const data = await response.json();
cache.put(url, data.valid, CACHING_TIME);
return data.valid;
} catch (error) {
return false;
}
}
}
export default checkLicense;

View File

@@ -0,0 +1,129 @@
import { useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
import { classNames } from "@calcom/lib";
import { DEFAULT_LIGHT_BRAND_COLOR, DEFAULT_DARK_BRAND_COLOR } from "@calcom/lib/constants";
import { checkWCAGContrastColor } from "@calcom/lib/getBrandColours";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, ColorPicker, SettingsToggle, Alert } from "@calcom/ui";
type BrandColorsFormValues = {
brandColor: string;
darkBrandColor: string;
};
const BrandColorsForm = ({
onSubmit,
brandColor,
darkBrandColor,
}: {
onSubmit: (values: BrandColorsFormValues) => void;
brandColor: string | undefined;
darkBrandColor: string | undefined;
}) => {
const { t } = useLocale();
const brandColorsFormMethods = useFormContext();
const {
formState: { isSubmitting: isBrandColorsFormSubmitting, isDirty: isBrandColorsFormDirty },
} = brandColorsFormMethods;
const [isCustomBrandColorChecked, setIsCustomBrandColorChecked] = useState(
brandColor !== DEFAULT_LIGHT_BRAND_COLOR || darkBrandColor !== DEFAULT_DARK_BRAND_COLOR
);
const [darkModeError, setDarkModeError] = useState(false);
const [lightModeError, setLightModeError] = useState(false);
return (
<div className="mt-6">
<SettingsToggle
toggleSwitchAtTheEnd={true}
title={t("custom_brand_colors")}
description={t("customize_your_brand_colors")}
checked={isCustomBrandColorChecked}
onCheckedChange={(checked) => {
setIsCustomBrandColorChecked(checked);
if (!checked) {
onSubmit({
brandColor: DEFAULT_LIGHT_BRAND_COLOR,
darkBrandColor: DEFAULT_DARK_BRAND_COLOR,
});
}
}}
childrenClassName="lg:ml-0"
switchContainerClassName={classNames(
"py-6 px-4 sm:px-6 border-subtle rounded-xl border",
isCustomBrandColorChecked && "rounded-b-none"
)}>
<div className="border-subtle flex flex-col gap-6 border-x p-6">
<Controller
name="brandColor"
control={brandColorsFormMethods.control}
defaultValue={brandColor}
render={() => (
<div>
<p className="text-default mb-2 block text-sm font-medium">{t("light_brand_color")}</p>
<ColorPicker
defaultValue={brandColor || DEFAULT_LIGHT_BRAND_COLOR}
resetDefaultValue={DEFAULT_LIGHT_BRAND_COLOR}
onChange={(value) => {
try {
checkWCAGContrastColor("#ffffff", value);
setLightModeError(false);
brandColorsFormMethods.setValue("brandColor", value, { shouldDirty: true });
} catch (err) {
setLightModeError(false);
}
}}
/>
{lightModeError ? (
<div className="mt-4">
<Alert severity="warning" message={t("light_theme_contrast_error")} />
</div>
) : null}
</div>
)}
/>
<Controller
name="darkBrandColor"
control={brandColorsFormMethods.control}
defaultValue={darkBrandColor}
render={() => (
<div className="mt-6 sm:mt-0">
<p className="text-default mb-2 block text-sm font-medium">{t("dark_brand_color")}</p>
<ColorPicker
defaultValue={darkBrandColor || DEFAULT_DARK_BRAND_COLOR}
resetDefaultValue={DEFAULT_DARK_BRAND_COLOR}
onChange={(value) => {
try {
checkWCAGContrastColor("#101010", value);
setDarkModeError(false);
brandColorsFormMethods.setValue("darkBrandColor", value, { shouldDirty: true });
} catch (err) {
setDarkModeError(true);
}
}}
/>
{darkModeError ? (
<div className="mt-4">
<Alert severity="warning" message={t("dark_theme_contrast_error")} />
</div>
) : null}
</div>
)}
/>
</div>
<SectionBottomActions align="end">
<Button
disabled={isBrandColorsFormSubmitting || !isBrandColorsFormDirty}
color="primary"
type="submit">
{t("update")}
</Button>
</SectionBottomActions>
</SettingsToggle>
</div>
);
};
export default BrandColorsForm;

View File

@@ -0,0 +1,31 @@
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
import { Meta, SkeletonButton, SkeletonContainer, SkeletonText } from "@calcom/ui";
export const AppearanceSkeletonLoader = ({ title, description }: { title: string; description: string }) => {
return (
<SkeletonContainer>
<Meta title={title} description={description} borderInShellHeader={false} />
<div className="border-subtle mt-6 flex items-center rounded-t-xl border p-6 text-sm">
<SkeletonText className="h-8 w-1/3" />
</div>
<div className="border-subtle space-y-6 border-x px-4 py-6 sm:px-6">
<div className="flex w-full items-center justify-center gap-6">
<div className="bg-emphasis h-32 flex-1 animate-pulse rounded-md p-5" />
<div className="bg-emphasis h-32 flex-1 animate-pulse rounded-md p-5" />
<div className="bg-emphasis h-32 flex-1 animate-pulse rounded-md p-5" />
</div>
<div className="flex justify-between">
<SkeletonText className="h-8 w-1/3" />
<SkeletonText className="h-8 w-1/3" />
</div>
<SkeletonText className="h-8 w-full" />
</div>
<div className="rounded-b-xl">
<SectionBottomActions align="end">
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
</SectionBottomActions>
</div>
</SkeletonContainer>
);
};

View File

@@ -0,0 +1,34 @@
import { useSession } from "next-auth/react";
import Link from "next/link";
import { useIsEmbed } from "@calcom/embed-core/embed-iframe";
import { APP_NAME, POWERED_BY_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
const PoweredByCal = ({ logoOnly }: { logoOnly?: boolean }) => {
const { t } = useLocale();
const session = useSession();
const isEmbed = useIsEmbed();
const hasValidLicense = session.data ? session.data.hasValidLicense : null;
return (
<div className={`p-2 text-center text-xs sm:text-right${isEmbed ? " max-w-3xl" : ""}`}>
<Link href={POWERED_BY_URL} target="_blank" className="text-subtle">
{!logoOnly && <>{t("powered_by")} </>}
{APP_NAME === "Cal.com" || !hasValidLicense ? (
<>
<img
className="relative -mt-px inline h-[10px] w-auto dark:invert"
src="/api/logo"
alt="Cal.com Logo"
/>
</>
) : (
<span className="text-emphasis font-semibold opacity-50 hover:opacity-100">{APP_NAME}</span>
)}
</Link>
</div>
);
};
export default PoweredByCal;

View File

@@ -0,0 +1,9 @@
import type { PrismaClient } from "@calcom/prisma";
export async function getDeploymentKey(prisma: PrismaClient) {
const deployment = await prisma.deployment.findUnique({
where: { id: 1 },
select: { licenseKey: true },
});
return deployment?.licenseKey || process.env.CALCOM_LICENSE_KEY || "";
}

View File

@@ -0,0 +1,270 @@
import type { SessionContextValue } from "next-auth/react";
import { useSession } from "next-auth/react";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import type { Ensure } from "@calcom/types/utils";
import { showToast } from "@calcom/ui";
import { Alert, Button, Form, Label, TextField, ToggleGroup } from "@calcom/ui";
import { UserPermissionRole } from "../../../../prisma/enums";
export const CreateANewLicenseKeyForm = () => {
const session = useSession();
if (session.data?.user.role !== "ADMIN") {
return null;
}
// @ts-expect-error session can't be null due to the early return
return <CreateANewLicenseKeyFormChild session={session} />;
};
enum BillingType {
PER_BOOKING = "PER_BOOKING",
PER_USER = "PER_USER",
}
enum BillingPeriod {
MONTHLY = "MONTHLY",
ANNUALLY = "ANNUALLY",
}
interface FormValues {
billingType: BillingType;
entityCount: number;
entityPrice: number;
billingPeriod: BillingPeriod;
overages: number;
billingEmail: string;
}
const CreateANewLicenseKeyFormChild = ({ session }: { session: Ensure<SessionContextValue, "data"> }) => {
const { t } = useLocale();
const [serverErrorMessage, setServerErrorMessage] = useState<string | null>(null);
const [stripeCheckoutUrl, setStripeCheckoutUrl] = useState<string | null>(null);
const isAdmin = session.data.user.role === UserPermissionRole.ADMIN;
const newLicenseKeyFormMethods = useForm<FormValues>({
defaultValues: {
billingType: BillingType.PER_BOOKING,
billingPeriod: BillingPeriod.MONTHLY,
entityCount: 500,
overages: 99, // $0.99
entityPrice: 50, // $0.5
billingEmail: undefined,
},
});
const mutation = trpc.viewer.admin.createSelfHostedLicense.useMutation({
onSuccess: async (values) => {
showToast(`Success: We have created a stripe payment URL for this billing email`, "success");
setStripeCheckoutUrl(values.stripeCheckoutUrl);
},
onError: async (err) => {
setServerErrorMessage(err.message);
},
});
const watchedBillingPeriod = newLicenseKeyFormMethods.watch("billingPeriod");
const watchedEntityCount = newLicenseKeyFormMethods.watch("entityCount");
const watchedEntityPrice = newLicenseKeyFormMethods.watch("entityPrice");
function calculateMonthlyPrice() {
const occurrence = watchedBillingPeriod === "MONTHLY" ? 1 : 12;
const sum = watchedEntityCount * watchedEntityPrice * occurrence;
return `$ ${sum / 100} / ${occurrence} months`;
}
return (
<>
{!stripeCheckoutUrl ? (
<Form
form={newLicenseKeyFormMethods}
className="space-y-5"
id="createOrg"
handleSubmit={(values) => {
mutation.mutate(values);
}}>
<div>
{serverErrorMessage && (
<div className="mb-5">
<Alert severity="error" message={serverErrorMessage} />
</div>
)}
<div className="mb-5">
<Controller
name="billingPeriod"
control={newLicenseKeyFormMethods.control}
render={({ field: { value, onChange } }) => (
<>
<Label htmlFor="billingPeriod">Billing Period</Label>
<ToggleGroup
isFullWidth
id="billingPeriod"
defaultValue={value}
onValueChange={(e) => onChange(e)}
options={[
{
value: "MONTHLY",
label: "Monthly",
},
{
value: "ANNUALLY",
label: "Annually",
},
]}
/>
</>
)}
/>
</div>
<Controller
name="billingEmail"
control={newLicenseKeyFormMethods.control}
rules={{
required: t("must_enter_billing_email"),
}}
render={({ field: { value, onChange } }) => (
<div className="flex">
<TextField
containerClassName="w-full"
placeholder="john@acme.com"
name="billingEmail"
disabled={!isAdmin}
label="Billing Email for Customer"
defaultValue={value}
onChange={onChange}
autoComplete="off"
/>
</div>
)}
/>
</div>
<div>
<Controller
name="billingType"
control={newLicenseKeyFormMethods.control}
render={({ field: { value, onChange } }) => (
<>
<Label htmlFor="bookingType">Booking Type</Label>
<ToggleGroup
isFullWidth
id="bookingType"
defaultValue={value}
onValueChange={(e) => onChange(e)}
options={[
{
value: "PER_BOOKING",
label: "Per Booking",
tooltip: "Configure pricing on a per booking basis",
},
{
value: "PER_USER",
label: "Per User",
tooltip: "Configure pricing on a per user basis",
},
]}
/>
</>
)}
/>
</div>
<div className="flex flex-wrap gap-2 [&>*]:flex-1">
<Controller
name="entityCount"
control={newLicenseKeyFormMethods.control}
rules={{
required: "Must enter a total of billable users",
}}
render={({ field: { value, onChange } }) => (
<TextField
className="mt-2"
name="entityCount"
label="Total entities included"
placeholder="100"
defaultValue={value}
onChange={(event) => onChange(+event.target.value)}
/>
)}
/>
<Controller
name="entityPrice"
control={newLicenseKeyFormMethods.control}
rules={{
required: "Must enter fixed price per user",
}}
render={({ field: { value, onChange } }) => (
<TextField
className="mt-2"
name="entityPrice"
label="Fixed price per entity"
addOnSuffix="$"
defaultValue={value / 100}
onChange={(event) => onChange(+event.target.value * 100)}
/>
)}
/>
</div>
<div>
<Controller
name="overages"
control={newLicenseKeyFormMethods.control}
rules={{
required: "Must enter overages",
}}
render={({ field: { value, onChange } }) => (
<>
<TextField
className="mt-2"
placeholder="Acme"
name="overages"
addOnSuffix="$"
label="Overages"
disabled={!isAdmin}
defaultValue={value / 100}
onChange={(event) => onChange(+event.target.value * 100)}
autoComplete="off"
/>
</>
)}
/>
</div>
<div className="flex space-x-2 rtl:space-x-reverse">
<Button
disabled={newLicenseKeyFormMethods.formState.isSubmitting}
color="primary"
type="submit"
form="createOrg"
loading={mutation.isPending}
className="w-full justify-center">
{t("continue")} - {calculateMonthlyPrice()}
</Button>
</div>
</Form>
) : (
<div className="w-full">
<div className="">
<TextField className="flex-1" disabled value={stripeCheckoutUrl} />
</div>
<div className="mt-4 flex gap-2 [&>*]:flex-1 [&>*]:justify-center">
<Button
color="secondary"
onClick={() => {
newLicenseKeyFormMethods.reset();
setStripeCheckoutUrl(null);
}}>
Back
</Button>
</div>
</div>
)}
</>
);
};

View File

@@ -0,0 +1,113 @@
import { useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import {
AppSkeletonLoader as SkeletonLoader,
Button,
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogTrigger,
Label,
showToast,
EmptyScreen,
} from "@calcom/ui";
import CreateDirectory from "./CreateDirectory";
import DirectoryInfo from "./DirectoryInfo";
import GroupTeamMappingTable from "./GroupTeamMappingTable";
const ConfigureDirectorySync = ({ organizationId }: { organizationId: number | null }) => {
const { t } = useLocale();
const utils = trpc.useUtils();
const [deleteDirectoryOpen, setDeleteDirectoryOpen] = useState(false);
const { data, isLoading, isError, error } = trpc.viewer.dsync.get.useQuery({ organizationId });
const deleteMutation = trpc.viewer.dsync.delete.useMutation({
async onSuccess() {
showToast(t("directory_sync_deleted"), "success");
await utils.viewer.dsync.invalidate();
setDeleteDirectoryOpen(false);
},
});
if (isLoading) {
return <SkeletonLoader />;
}
const directory = data ?? null;
const onDeleteConfirmation = (e: Event | React.MouseEvent<HTMLElement, MouseEvent>) => {
e.preventDefault();
if (!directory) {
return;
}
deleteMutation.mutate({ organizationId, directoryId: directory.id });
};
if (error || isError) {
return (
<div>
<EmptyScreen
headline="Error"
description={error.message || "Error getting dsync data"}
Icon="triangle-alert"
/>
</div>
);
}
return (
<div>
{!directory ? (
<CreateDirectory orgId={organizationId} />
) : (
<>
<DirectoryInfo directory={directory} />
<div className="mt-4">
<GroupTeamMappingTable />
</div>
<hr className="border-subtle my-6" />
<Label>{t("danger_zone")}</Label>
{/* Delete directory sync connection */}
<Dialog open={deleteDirectoryOpen} onOpenChange={setDeleteDirectoryOpen}>
<DialogTrigger asChild>
<Button color="destructive" className="mt-1" StartIcon="trash">
{t("directory_sync_delete_connection")}
</Button>
</DialogTrigger>
<DialogContent
title={t("directory_sync_delete_title")}
description={t("directory_sync_delete_description")}
type="creation"
Icon="triangle-alert">
<>
<div className="mb-10">
<p className="text-default mb-4">{t("directory_sync_delete_confirmation")}</p>
</div>
<DialogFooter showDivider>
<DialogClose />
<Button
color="primary"
data-testid="delete-account-confirm"
onClick={onDeleteConfirmation}
loading={deleteMutation.isPending}>
{t("directory_sync_delete_connection")}
</Button>
</DialogFooter>
</>
</DialogContent>
</Dialog>
</>
)}
</div>
);
};
export default ConfigureDirectorySync;

View File

@@ -0,0 +1,126 @@
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import {
Button,
Dialog,
DialogContent,
SelectField,
Form,
TextField,
DialogFooter,
showToast,
} from "@calcom/ui";
import { directoryProviders } from "../lib/directoryProviders";
const defaultValues = {
name: "",
provider: directoryProviders[0].value,
};
const CreateDirectory = ({ orgId }: { orgId: number | null }) => {
const { t } = useLocale();
const utils = trpc.useUtils();
const form = useForm({ defaultValues });
const [openModal, setOpenModal] = useState(false);
const mutation = trpc.viewer.dsync.create.useMutation({
async onSuccess() {
showToast(t("directory_sync_created"), "success");
await utils.viewer.dsync.invalidate();
setOpenModal(false);
},
});
return (
<>
<div className="flex flex-col sm:flex-row">
<div>
<p className="text-default text-sm font-normal leading-6 dark:text-gray-300">
{t("directory_sync_title")}
</p>
</div>
<div className="flex-shrink-0 pt-3 sm:ml-auto sm:pl-3 sm:pt-0">
<Button color="primary" onClick={() => setOpenModal(true)}>
{t("configure")}
</Button>
</div>
</div>
<Dialog open={openModal} onOpenChange={setOpenModal}>
<DialogContent type="creation">
<Form
form={form}
handleSubmit={(values) => {
mutation.mutate({
...values,
organizationId: orgId,
});
}}>
<div className="mb-5 mt-1">
<h2 className="font-semi-bold font-cal text-emphasis text-xl tracking-wide">
{t("directory_sync_configure")}
</h2>
<p className="mt-1 text-sm text-gray-500">{t("directory_sync_configure_description")}</p>
</div>
<fieldset className="space-y-6 py-2">
<Controller
control={form.control}
name="name"
render={({ field: { value } }) => (
<TextField
name="title"
label={t("directory_name")}
value={value}
onChange={(e) => {
form.setValue("name", e?.target.value);
}}
type="text"
required
/>
)}
/>
<Controller
control={form.control}
name="provider"
render={() => (
<SelectField
name="provider"
label={t("directory_provider")}
options={directoryProviders}
placeholder={t("choose_directory_provider")}
defaultValue={directoryProviders[0]}
onChange={(option) => {
if (option) {
form.setValue("provider", option.value);
}
}}
/>
)}
/>
</fieldset>
<DialogFooter>
<Button
type="button"
color="secondary"
onClick={() => {
setOpenModal(false);
}}
tabIndex={-1}>
{t("cancel")}
</Button>
<Button type="submit" loading={form.formState.isSubmitting || mutation.isPending}>
{t("save")}
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
</>
);
};
export default CreateDirectory;

View File

@@ -0,0 +1,34 @@
import { CreateANewTeamForm } from "@calcom/features/ee/teams/components";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Dialog, DialogContent } from "@calcom/ui";
interface CreateTeamDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
const CreateTeamDialog = (props: CreateTeamDialogProps) => {
const { open, onOpenChange } = props;
const { t } = useLocale();
const utils = trpc.useUtils();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent type="creation" title={t("create_new_team")} description={t("team_will_be_under_org")}>
<CreateANewTeamForm
inDialog
submitLabel="Create"
onCancel={() => onOpenChange(false)}
onSuccess={async () => {
await utils.viewer.dsync.teamGroupMapping.get.invalidate();
await utils.viewer.teams.list.invalidate();
onOpenChange(false);
}}
/>
</DialogContent>
</Dialog>
);
};
export default CreateTeamDialog;

View File

@@ -0,0 +1,62 @@
import type { Directory } from "@boxyhq/saml-jackson";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, showToast, Label, Tooltip } from "@calcom/ui";
const DirectoryInfo = ({ directory }: { directory: Directory }) => {
const { t } = useLocale();
return (
<div className="space-y-8">
<p className="text-default text-sm font-normal leading-6 dark:text-gray-300">
{t("directory_sync_info_description")}
</p>
<div className="flex flex-col">
<div className="flex">
<Label>{t("directory_scim_url")}</Label>
</div>
<div className="flex">
<code className="bg-subtle text-default w-full truncate rounded-md rounded-r-none py-[6px] pl-2 pr-2 align-middle font-mono">
{directory.scim.endpoint}
</code>
<Tooltip side="top" content={t("copy_to_clipboard")}>
<Button
onClick={() => {
navigator.clipboard.writeText(`${directory.scim.endpoint}`);
showToast(t("directory_scim_url_copied"), "success");
}}
type="button"
className="rounded-l-none text-base"
StartIcon="clipboard">
{t("copy")}
</Button>
</Tooltip>
</div>
</div>
<div className="flex flex-col">
<div className="flex">
<Label>{t("directory_scim_token")}</Label>
</div>
<div className="flex">
<code className="bg-subtle text-default w-full truncate rounded-md rounded-r-none py-[6px] pl-2 pr-2 align-middle font-mono">
{directory.scim.secret}
</code>
<Tooltip side="top" content={t("copy_to_clipboard")}>
<Button
onClick={() => {
navigator.clipboard.writeText(`${directory.scim.secret}`);
showToast(t("directory_scim_token_copied"), "success");
}}
type="button"
className="rounded-l-none text-base"
StartIcon="clipboard">
{t("copy")}
</Button>
</Tooltip>
</div>
</div>
</div>
);
};
export default DirectoryInfo;

View File

@@ -0,0 +1,116 @@
import { useState } from "react";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Badge, Icon, showToast, TextField } from "@calcom/ui";
interface GroupNameCellProps {
groupNames: string[];
teamId: number;
directoryId: string;
}
const GroupNameCell = ({ groupNames, teamId, directoryId }: GroupNameCellProps) => {
const [showTextInput, setShowTextInput] = useState(false);
const [newGroupName, setNewGroupName] = useState("");
const { t } = useLocale();
const utils = trpc.useUtils();
const createMutation = trpc.viewer.dsync.teamGroupMapping.create.useMutation({
onSuccess: (data) => {
utils.viewer.dsync.teamGroupMapping.get.setData(undefined, (prev) => {
if (prev) {
const teamIndex = prev.teamGroupMapping.findIndex((team) => team.id === teamId);
prev.teamGroupMapping[teamIndex].groupNames.push(data.newGroupName);
}
return prev;
});
setShowTextInput(false);
setNewGroupName("");
showToast(`Group added`, "success");
},
onError: (error) => {
showToast(`Error adding group name${error.message}`, "error");
},
});
const deleteMutation = trpc.viewer.dsync.teamGroupMapping.delete.useMutation({
onSuccess: (data) => {
utils.viewer.dsync.teamGroupMapping.get.setData(undefined, (prev) => {
if (prev) {
const teamIndex = prev.teamGroupMapping.findIndex((team) => team.id === teamId);
const indexToRemove = prev.teamGroupMapping[teamIndex].groupNames.indexOf(data.deletedGroupName);
prev.teamGroupMapping[teamIndex].groupNames.splice(indexToRemove, 1);
}
return prev;
});
showToast(`Group removed`, "success");
},
onError: (error) => {
showToast(`Error removing group name${error.message}`, "error");
},
});
const addGroupName = (groupName: string) => {
if (groupNames.some((name: string) => name === groupName)) {
showToast(`Group name already added`, "error");
return;
}
createMutation.mutate({ teamId: teamId, name: groupName, directoryId: directoryId });
};
const removeGroupName = (groupName: string) => {
deleteMutation.mutate({
teamId: teamId,
groupName: groupName,
});
};
return (
<div className="flex items-center space-x-4">
{groupNames.map((name) => (
<Badge variant="gray" size="lg" key={name} className="h-8 py-4">
<div className="flex items-center space-x-2 ">
<p>{name}</p>
<div className="hover:bg-emphasis rounded p-1">
<Icon name="x" className="h-4 w-4 stroke-[3px]" onClick={() => removeGroupName(name)} />
</div>
</div>
</Badge>
))}
<Badge variant="gray" size="lg" className={classNames(!showTextInput && "hover:bg-emphasis")}>
<div
className="flex items-center space-x-1"
onClick={() => {
if (!showTextInput) setShowTextInput(true);
}}>
{showTextInput ? (
<TextField
autoFocus
className="mb-0 h-6"
onBlur={() => {
if (!newGroupName) setShowTextInput(false);
}}
onChange={(e) => setNewGroupName(e.target.value)}
value={newGroupName}
onKeyDown={(e) => {
if (e.key === "Enter") {
addGroupName(newGroupName);
}
}}
/>
) : (
<p>{t("add_group_name")}</p>
)}
<div className={classNames("rounded p-1", showTextInput && "hover:bg-emphasis ml-2")}>
<Icon name="plus" className="h-4 w-4 stroke-[3px]" onClick={() => addGroupName(newGroupName)} />
</div>
</div>
</Badge>
</div>
);
};
export default GroupNameCell;

View File

@@ -0,0 +1,60 @@
import type { ColumnDef } from "@tanstack/react-table";
import { useRef, useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { DataTable, Button } from "@calcom/ui";
import CreateTeamDialog from "./CreateTeamDialog";
import GroupNameCell from "./GroupNameCell";
interface TeamGroupMapping {
name: string;
id: number;
groupNames: string[];
directoryId: string;
}
const GroupTeamMappingTable = () => {
const { t } = useLocale();
const [createTeamDialogOpen, setCreateTeamDialogOpen] = useState(false);
const { data } = trpc.viewer.dsync.teamGroupMapping.get.useQuery();
const tableContainerRef = useRef<HTMLDivElement>(null);
const columns: ColumnDef<TeamGroupMapping>[] = [
{
id: "name",
header: t("team"),
cell: ({ row }) => {
const { name } = row.original;
return <p>{name}</p>;
},
},
{
id: "group",
header: t("group_name"),
cell: ({ row }) => {
const { id, groupNames, directoryId } = row.original;
return <GroupNameCell groupNames={groupNames} teamId={id} directoryId={directoryId} />;
},
},
];
return (
<>
<DataTable
data={data ? data.teamGroupMapping : []}
tableContainerRef={tableContainerRef}
columns={columns}
tableCTA={<Button onClick={() => setCreateTeamDialogOpen(true)}>Create team</Button>}
/>
<CreateTeamDialog open={createTeamDialogOpen} onOpenChange={setCreateTeamDialogOpen} />
</>
);
};
export default GroupTeamMappingTable;

View File

@@ -0,0 +1,67 @@
import type { TFunction } from "next-i18next";
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import type { getTeamOrThrow } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/utils";
import { sendSignupToOrganizationEmail } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/utils";
const createUserAndInviteToOrg = async ({
userEmail,
org,
translation,
}: {
userEmail: string;
org: Awaited<ReturnType<typeof getTeamOrThrow>>;
translation: TFunction;
}) => {
const orgId = org.id;
const [emailUser, emailDomain] = userEmail.split("@");
const username = slugify(`${emailUser}-${emailDomain.split(".")[0]}`);
await prisma.user.create({
data: {
username,
email: userEmail,
// name: event.data?.givenName,
// Assume verified since coming from directory
verified: true,
invitedTo: orgId,
organizationId: orgId,
teams: {
create: {
teamId: orgId,
role: MembershipRole.MEMBER,
accepted: true,
},
},
profiles: {
createMany: {
data: [
{
uid: ProfileRepository.generateProfileUid(),
username,
organizationId: orgId,
},
],
},
},
},
});
await sendSignupToOrganizationEmail({
usernameOrEmail: userEmail,
team: org,
translation,
inviterName: org.name,
input: {
teamId: orgId,
role: MembershipRole.MEMBER,
usernameOrEmail: userEmail,
language: "en",
isOrg: true,
},
});
};
export default createUserAndInviteToOrg;

View File

@@ -0,0 +1,22 @@
export const directoryProviders = [
{
label: "Azure SCIM v2.0",
value: "azure-scim-v2",
},
{
label: "Okta SCIM v2.0",
value: "okta-scim-v2",
},
{
label: "JumpCloud v2.0",
value: "jumpcloud-scim-v2",
},
{
label: "OneLogin SCIM v2.0",
value: "onelogin-scim-v2",
},
{
label: "SCIM Generic v2.0",
value: "generic-scim-v2",
},
];

View File

@@ -0,0 +1,191 @@
import type { DirectorySyncEvent, Group } from "@boxyhq/saml-jackson";
import jackson from "@calcom/features/ee/sso/lib/jackson";
import { createAProfileForAnExistingUser } from "@calcom/lib/createAProfileForAnExistingUser";
import { getTranslation } from "@calcom/lib/server/i18n";
import prisma from "@calcom/prisma";
import { IdentityProvider, MembershipRole } from "@calcom/prisma/enums";
import {
getTeamOrThrow,
sendSignupToOrganizationEmail,
sendExistingUserTeamInviteEmails,
} from "@calcom/trpc/server/routers/viewer/teams/inviteMember/utils";
import createUsersAndConnectToOrg from "./users/createUsersAndConnectToOrg";
const handleGroupEvents = async (event: DirectorySyncEvent, organizationId: number) => {
const { dsyncController } = await jackson();
// Find the group name associated with the event
const eventData = event.data as Group;
// If the group doesn't have any members assigned then return early
if (!eventData.raw.members.length) {
return;
}
const groupNames = await prisma.dSyncTeamGroupMapping.findMany({
where: {
directoryId: event.directory_id,
groupName: eventData.name,
organizationId,
},
select: {
teamId: true,
team: {
include: {
parent: {
include: {
organizationSettings: true,
},
},
organizationSettings: true,
},
},
groupName: true,
},
});
if (!groupNames.length) {
return;
}
const org = await getTeamOrThrow(organizationId);
// Check if the group member display property is an email
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const isEmail = emailRegex.test(eventData.raw.members[0].display);
let userEmails: string[] = [];
// TODO: Handle the case where display property is not an email
if (isEmail) {
userEmails = eventData.raw.members.map((member: { display: string }) => member.display);
}
// Find existing users
const users = await prisma.user.findMany({
where: {
email: {
in: userEmails,
},
},
select: {
id: true,
email: true,
username: true,
organizationId: true,
completedOnboarding: true,
identityProvider: true,
profiles: true,
locale: true,
teams: true,
password: {
select: {
hash: true,
userId: true,
},
},
},
});
const translation = await getTranslation("en", "common");
const newUserEmails = userEmails.filter((email) => !users.find((user) => user.email === email));
// For each team linked to the dsync group name provision members
for (const group of groupNames) {
if (newUserEmails.length) {
const createUsersAndConnectToOrgProps = {
emailsToCreate: newUserEmails,
organizationId: org.id,
identityProvider: IdentityProvider.CAL,
identityProviderId: null,
};
const newUsers = await createUsersAndConnectToOrg(createUsersAndConnectToOrgProps);
await prisma.membership.createMany({
data: newUsers.map((user) => ({
userId: user.id,
teamId: group.teamId,
role: MembershipRole.MEMBER,
accepted: true,
})),
});
await Promise.all(
newUserEmails.map((email) => {
return sendSignupToOrganizationEmail({
usernameOrEmail: email,
team: group.team,
translation,
inviterName: org.name,
teamId: group.teamId,
isOrg: false,
});
})
);
}
// For existing users create membership for team and org if needed
await prisma.membership.createMany({
data: [
...users
.map((user) => {
return [
{
userId: user.id,
teamId: group.teamId,
role: MembershipRole.MEMBER,
accepted: true,
},
{
userId: user.id,
teamId: organizationId,
role: MembershipRole.MEMBER,
accepted: true,
},
];
})
.flat(),
],
skipDuplicates: true,
});
// Send emails to new members
const newMembers = users.filter((user) => !user.teams.find((team) => team.id === group.teamId));
const newOrgMembers = users.filter(
(user) => !user.profiles.find((profile) => profile.organizationId === organizationId)
);
await Promise.all([
...newMembers.map(async (user) => {
const translation = await getTranslation(user.locale || "en", "common");
return sendExistingUserTeamInviteEmails({
currentUserTeamName: group.team.name,
existingUsersWithMemberships: [
{
...user,
profile: null,
},
],
language: translation,
isOrg: false,
teamId: group.teamId,
isAutoJoin: true,
currentUserParentTeamName: org.name,
orgSlug: null,
});
}),
...newOrgMembers.map((user) => {
return createAProfileForAnExistingUser({
user: {
id: user.id,
email: user.email,
currentUsername: user.username,
},
organizationId,
});
}),
]);
}
};
export default handleGroupEvents;

View File

@@ -0,0 +1,93 @@
import type { DirectorySyncEvent, User } from "@boxyhq/saml-jackson";
import removeUserFromOrg from "@calcom/features/ee/dsync/lib/removeUserFromOrg";
import { getTranslation } from "@calcom/lib/server/i18n";
import prisma from "@calcom/prisma";
import { IdentityProvider } from "@calcom/prisma/enums";
import { getTeamOrThrow } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/utils";
import type { UserWithMembership } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/utils";
import { sendExistingUserTeamInviteEmails } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/utils";
import { sendSignupToOrganizationEmail } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/utils";
import createUsersAndConnectToOrg from "./users/createUsersAndConnectToOrg";
import dSyncUserSelect from "./users/dSyncUserSelect";
import inviteExistingUserToOrg from "./users/inviteExistingUserToOrg";
const handleUserEvents = async (event: DirectorySyncEvent, organizationId: number) => {
const eventData = event.data as User;
const userEmail = eventData.email;
// Check if user exists in DB
const user = await prisma.user.findFirst({
where: {
email: userEmail,
},
select: dSyncUserSelect,
});
// User is already a part of that org
if (user?.organizationId && eventData.active) {
return;
}
const translation = await getTranslation(user?.locale || "en", "common");
const org = await getTeamOrThrow(organizationId);
if (!org) {
throw new Error("Org not found");
}
if (user) {
if (eventData.active) {
// If data.active is true then provision the user into the org
const addedUser = await inviteExistingUserToOrg({
user: user as UserWithMembership,
org,
translation,
});
await sendExistingUserTeamInviteEmails({
currentUserName: user.username,
currentUserTeamName: org.name,
existingUsersWithMemberships: [
{
...addedUser,
profile: null,
},
],
language: translation,
isOrg: true,
teamId: org.id,
isAutoJoin: true,
currentUserParentTeamName: org?.parent?.name,
orgSlug: org.slug,
});
} else {
// If data.active is false then remove the user from the org
await removeUserFromOrg({
userId: user.id,
orgId: organizationId,
});
}
// If user is not in DB, create user and add to the org
} else {
const createUsersAndConnectToOrgProps = {
emailsToCreate: [userEmail],
organizationId: org.id,
identityProvider: IdentityProvider.CAL,
identityProviderId: null,
};
await createUsersAndConnectToOrg(createUsersAndConnectToOrgProps);
await sendSignupToOrganizationEmail({
usernameOrEmail: userEmail,
team: org,
translation,
inviterName: org.name,
teamId: organizationId,
isOrg: true,
});
}
};
export default handleUserEvents;

View File

@@ -0,0 +1,54 @@
import type { TFunction } from "next-i18next";
import { createAProfileForAnExistingUser } from "@calcom/lib/createAProfileForAnExistingUser";
import prisma from "@calcom/prisma";
import { sendExistingUserTeamInviteEmails } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/utils";
import type { UserWithMembership } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/utils";
/**
* This should only be used in a dsync context
*/
const inviteExistingUserToOrg = async ({
user,
org,
translation,
}: {
user: UserWithMembership;
org: { id: number; name: string; parent: { name: string } | null };
translation: TFunction;
}) => {
await createAProfileForAnExistingUser({
user,
organizationId: org.id,
});
await prisma.user.update({
where: {
id: user.id,
},
data: {
organizationId: org.id,
teams: {
create: {
teamId: org.id,
role: "MEMBER",
// Since coming from directory assume it'll be verified
accepted: true,
},
},
},
});
await sendExistingUserTeamInviteEmails({
currentUserName: user.username,
currentUserTeamName: org.name,
existingUsersWithMemberships: [user],
language: translation,
isOrg: true,
teamId: org.id,
isAutoJoin: true,
currentUserParentTeamName: org?.parent?.name,
});
};
export default inviteExistingUserToOrg;

View File

@@ -0,0 +1,11 @@
import removeMember from "@calcom/features/ee/teams/lib/removeMember";
const removeUserFromOrg = async ({ userId, orgId }: { userId: number; orgId: number }) => {
return removeMember({
memberId: userId,
teamId: orgId,
isOrg: true,
});
};
export default removeUserFromOrg;

View File

@@ -0,0 +1,49 @@
import { canAccess } from "@calcom/features/ee/sso/lib/saml";
import prisma from "@calcom/prisma";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { TRPCError } from "@trpc/server";
const userCanCreateTeamGroupMapping = async (
user: NonNullable<TrpcSessionUser>,
organizationId: number | null,
teamId?: number
) => {
if (!organizationId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Could not find organization id",
});
}
const { message, access } = await canAccess(user, organizationId);
if (!access) {
throw new TRPCError({
code: "BAD_REQUEST",
message,
});
}
if (teamId) {
const orgTeam = await prisma.team.findFirst({
where: {
id: teamId,
parentId: organizationId,
},
select: {
id: true,
},
});
if (!orgTeam) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Could not find team",
});
}
}
return { organizationId };
};
export default userCanCreateTeamGroupMapping;

View File

@@ -0,0 +1,76 @@
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import type { IdentityProvider } from "@calcom/prisma/enums";
import { MembershipRole } from "@calcom/prisma/enums";
import dSyncUserSelect from "./dSyncUserSelect";
type createUsersAndConnectToOrgPropsType = {
emailsToCreate: string[];
organizationId: number;
identityProvider: IdentityProvider;
identityProviderId: string | null;
};
const createUsersAndConnectToOrg = async (
createUsersAndConnectToOrgProps: createUsersAndConnectToOrgPropsType
) => {
const { emailsToCreate, organizationId, identityProvider, identityProviderId } =
createUsersAndConnectToOrgProps;
// As of Mar 2024 Prisma createMany does not support nested creates and returning created records
await prisma.user.createMany({
data: emailsToCreate.map((email) => {
const [emailUser, emailDomain] = email.split("@");
const username = slugify(`${emailUser}-${emailDomain.split(".")[0]}`);
const name = username
.split("-")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
return {
username,
email,
name,
// Assume verified since coming from directory
verified: true,
emailVerified: new Date(),
invitedTo: organizationId,
organizationId,
identityProvider,
identityProviderId,
};
}),
});
const users = await prisma.user.findMany({
where: {
email: {
in: emailsToCreate,
},
},
select: dSyncUserSelect,
});
await prisma.membership.createMany({
data: users.map((user) => ({
accepted: true,
userId: user.id,
teamId: organizationId,
role: MembershipRole.MEMBER,
})),
});
await prisma.profile.createMany({
data: users.map((user) => ({
uid: ProfileRepository.generateProfileUid(),
userId: user.id,
// The username is already set when creating the user
username: user.username!,
organizationId,
})),
});
return users;
};
export default createUsersAndConnectToOrg;

View File

@@ -0,0 +1,17 @@
const dSyncUserSelect = {
id: true,
email: true,
username: true,
organizationId: true,
completedOnboarding: true,
identityProvider: true,
profiles: true,
locale: true,
password: {
select: {
hash: true,
},
},
};
export default dSyncUserSelect;

View File

@@ -0,0 +1,48 @@
import type { TFunction } from "next-i18next";
import { createAProfileForAnExistingUser } from "@calcom/lib/createAProfileForAnExistingUser";
import prisma from "@calcom/prisma";
import type { UserWithMembership } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/utils";
/**
* This should only be used in a dsync context
*/
const inviteExistingUserToOrg = async ({
user,
org,
translation,
}: {
user: UserWithMembership;
org: { id: number; name: string; parent: { name: string } | null };
translation: TFunction;
}) => {
await createAProfileForAnExistingUser({
user: {
id: user.id,
email: user.email,
currentUsername: user.username,
},
organizationId: org.id,
});
await prisma.user.update({
where: {
id: user.id,
},
data: {
organizationId: org.id,
teams: {
create: {
teamId: org.id,
role: "MEMBER",
// Since coming from directory assume it'll be verified
accepted: true,
},
},
},
});
return user;
};
export default inviteExistingUserToOrg;

View File

@@ -0,0 +1,61 @@
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Meta, SkeletonLoader, showToast } from "@calcom/ui";
import { getLayout } from "../../../settings/layouts/SettingsLayout";
import ConfigureDirectorySync from "../components/ConfigureDirectorySync";
// For Hosted Cal - Team view
const DirectorySync = () => {
const { t } = useLocale();
const router = useRouter();
const { data: currentOrg, isLoading, error } = trpc.viewer.organizations.listCurrent.useQuery();
useEffect(() => {
if (!HOSTED_CAL_FEATURES) {
router.push("/404");
}
}, [router]);
if (isLoading) {
return <SkeletonLoader />;
}
if (!currentOrg?.id) {
router.push("/404");
}
if (error) {
showToast(error.message, "error");
}
return (
<div className="bg-default w-full sm:mx-0 xl:mt-0">
<Meta title={t("directory_sync")} description={t("directory_sync_description")} />
{HOSTED_CAL_FEATURES && <ConfigureDirectorySync organizationId={currentOrg?.id || null} />}
{/* TODO add additional settings for dsync */}
{/* <SettingsToggle
toggleSwitchAtTheEnd={true}
title="Map groups to teams 1:1"
description="Members will be auto assigned to teams with the same name as their group."
switchContainerClassName="mt-6"
/>
<SettingsToggle
toggleSwitchAtTheEnd={true}
title="Create new teams based on groups"
description="Automatically create new teams if a new group is pushed"
switchContainerClassName="mt-6"
/>
<Button>Default team config</Button> */}
</div>
);
};
DirectorySync.getLayout = getLayout;
export default DirectorySync;

View File

@@ -0,0 +1,44 @@
import type { SessionContextValue } from "next-auth/react";
import { signIn } from "next-auth/react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { TopBanner } from "@calcom/ui";
export type ImpersonatingBannerProps = { data: SessionContextValue["data"] };
function ImpersonatingBanner({ data }: ImpersonatingBannerProps) {
const { t } = useLocale();
if (!data?.user.impersonatedBy) return null;
const returnToId = data.user.impersonatedBy.id;
const canReturnToSelf = data.user.impersonatedBy.role == "ADMIN" || data.user?.org?.id;
return (
<>
<TopBanner
text={t("impersonating_user_warning", { user: data.user.username })}
variant="warning"
actions={
canReturnToSelf ? (
<form
onSubmit={(e) => {
e.preventDefault();
signIn("impersonation-auth", { returnToId });
}}>
<button className="text-emphasis hover:underline" data-testid="stop-impersonating-button">
{t("impersonating_stop_instructions")}
</button>
</form>
) : (
<a className="border-b border-b-black" href="/auth/logout">
{t("impersonating_stop_instructions")}
</a>
)
}
/>
</>
);
}
export default ImpersonatingBanner;

View File

@@ -0,0 +1,81 @@
import type { Session } from "next-auth";
import { describe, expect, it } from "vitest";
import { UserPermissionRole } from "@calcom/prisma/enums";
import {
parseTeamId,
checkSelfImpersonation,
checkUserIdentifier,
checkGlobalPermission,
} from "./ImpersonationProvider";
const session: Session = {
expires: "2021-08-31T15:00:00.000Z",
hasValidLicense: true,
user: {
id: 123,
username: "test",
role: UserPermissionRole.USER,
email: "test@example.com",
},
};
describe("parseTeamId", () => {
it("should return undefined if no teamId is provided", () => {
expect(parseTeamId(undefined)).toBeUndefined();
});
it("should return the parsed teamId if a teamId is provided", () => {
expect(parseTeamId({ username: "test", teamId: "123" })).toBe(123);
});
it("should throw an error if the provided teamId is not a positive number", () => {
expect(() => parseTeamId({ username: "test", teamId: "-123" })).toThrow();
});
it("should throw an error if the provided teamId is not a number", () => {
expect(() => parseTeamId({ username: "test", teamId: "notanumber" })).toThrow();
});
});
describe("checkSelfImpersonation", () => {
it("should throw an error if the provided username is the same as the session user's username", () => {
expect(() => checkSelfImpersonation(session, { username: "test" })).toThrow();
});
it("should throw an error if the provided username is the same as the session user's email", () => {
expect(() => checkSelfImpersonation(session, { username: "test@example.com" })).toThrow();
});
it("should not throw an error if the provided username is different from the session user's username and email", () => {
expect(() => checkSelfImpersonation(session, { username: "other" })).not.toThrow();
});
});
describe("checkUserIdentifier", () => {
it("should throw an error if no username is provided", () => {
expect(() => checkUserIdentifier(undefined)).toThrow();
});
it("should not throw an error if a username is provided", () => {
expect(() => checkUserIdentifier({ username: "test" })).not.toThrow();
});
});
describe("checkPermission", () => {
it("should throw an error if the user is not an admin and team impersonation is disabled", () => {
process.env.NEXT_PUBLIC_TEAM_IMPERSONATION = "false";
expect(() => checkGlobalPermission(session)).toThrow();
});
it("should not throw an error if the user is an admin and team impersonation is disabled", () => {
const modifiedSession = { ...session, user: { ...session.user, role: UserPermissionRole.ADMIN } };
process.env.NEXT_PUBLIC_TEAM_IMPERSONATION = "false";
expect(() => checkGlobalPermission(modifiedSession)).not.toThrow();
});
it("should not throw an error if the user is not an admin but team impersonation is enabled", () => {
process.env.NEXT_PUBLIC_TEAM_IMPERSONATION = "true";
expect(() => checkGlobalPermission(session)).not.toThrow();
});
});

View File

@@ -0,0 +1,361 @@
import type { User } from "@prisma/client";
import type { Session } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { z } from "zod";
import { ensureOrganizationIsReviewed } from "@calcom/ee/organizations/lib/ensureOrganizationIsReviewed";
import { getSession } from "@calcom/features/auth/lib/getSession";
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
import prisma from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import type { Membership } from "@calcom/prisma/client";
import type { OrgProfile, PersonalProfile, UserAsPersonalProfile } from "@calcom/types/UserProfile";
const teamIdschema = z.object({
teamId: z.preprocess((a) => parseInt(z.string().parse(a), 10), z.number().positive()),
});
type ProfileType =
| UserAsPersonalProfile
| PersonalProfile
| (Omit<OrgProfile, "organization"> & {
organization: OrgProfile["organization"] & {
members: Membership[];
};
});
const auditAndReturnNextUser = async (
impersonatedUser: Pick<User, "id" | "username" | "email" | "name" | "role" | "locale"> & {
organizationId: number | null;
profile: ProfileType;
},
impersonatedByUID: number,
hasTeam?: boolean,
isReturningToSelf?: boolean
) => {
// Log impersonations for audit purposes
await prisma.impersonations.create({
data: {
impersonatedBy: {
connect: {
id: impersonatedByUID,
},
},
impersonatedUser: {
connect: {
id: impersonatedUser.id,
},
},
},
});
const obj = {
id: impersonatedUser.id,
username: impersonatedUser.username,
email: impersonatedUser.email,
name: impersonatedUser.name,
role: impersonatedUser.role,
belongsToActiveTeam: hasTeam,
organizationId: impersonatedUser.organizationId,
locale: impersonatedUser.locale,
profile: impersonatedUser.profile,
};
if (!isReturningToSelf) {
const impersonatedByUser = await prisma.user.findUnique({
where: {
id: impersonatedByUID,
},
select: {
id: true,
role: true,
},
});
if (!impersonatedByUser) throw new Error("This user does not exist.");
return {
...obj,
impersonatedBy: {
id: impersonatedByUser?.id,
role: impersonatedByUser?.role,
},
};
}
return obj;
};
type Credentials = Record<"username" | "teamId" | "returnToId", string> | undefined;
export function parseTeamId(creds: Partial<Credentials>) {
return creds?.teamId ? teamIdschema.parse({ teamId: creds.teamId }).teamId : undefined;
}
export function checkSelfImpersonation(session: Session | null, creds: Partial<Credentials>) {
if (session?.user.username === creds?.username || session?.user.email === creds?.username) {
throw new Error("You cannot impersonate yourself.");
}
}
export function checkUserIdentifier(creds: Partial<Credentials>) {
if (!creds?.username) {
if (creds?.returnToId) return;
throw new Error("User identifier must be present");
}
}
export function checkGlobalPermission(session: Session | null) {
if (
(session?.user.role !== "ADMIN" && process.env.NEXT_PUBLIC_TEAM_IMPERSONATION === "false") ||
!session?.user
) {
throw new Error("You do not have permission to do this.");
}
}
async function getImpersonatedUser({
session,
teamId,
creds,
}: {
session: Session | null;
teamId: number | undefined;
creds: Credentials | null;
}) {
let TeamWhereClause: Prisma.MembershipWhereInput = {
disableImpersonation: false, // Ensure they have impersonation enabled
accepted: true, // Ensure they are apart of the team and not just invited.
team: {
id: teamId, // Bring back only the right team
},
};
// If you are an admin we dont need to follow this flow -> We can just follow the usual flow
// If orgId and teamId are the same we can follow the same flow
if (session?.user.org?.id && session.user.org.id !== teamId && session?.user.role !== "ADMIN") {
TeamWhereClause = {
disableImpersonation: false,
accepted: true,
team: {
id: session.user.org.id,
},
};
}
// Get user who is being impersonated
const impersonatedUser = await prisma.user.findFirst({
where: {
OR: [{ username: creds?.username }, { email: creds?.username }],
},
select: {
id: true,
username: true,
role: true,
name: true,
email: true,
disableImpersonation: true,
locale: true,
teams: {
where: TeamWhereClause,
select: {
teamId: true,
disableImpersonation: true,
role: true,
},
},
},
});
if (!impersonatedUser) {
throw new Error("This user does not exist");
}
const profile = await findProfile(impersonatedUser);
return {
...impersonatedUser,
organizationId: profile.organization?.id ?? null,
profile,
};
}
async function isReturningToSelf({ session, creds }: { session: Session | null; creds: Credentials | null }) {
const impersonatedByUID = session?.user.impersonatedBy?.id;
if (!impersonatedByUID || !creds?.returnToId) return;
const returnToId = parseInt(creds?.returnToId, 10);
// Ensure session impersonatedUID + the returnToId is the same so we cant take over a random account
if (impersonatedByUID !== returnToId) return;
const returningUser = await prisma.user.findUnique({
where: {
id: returnToId,
},
select: {
id: true,
username: true,
email: true,
name: true,
role: true,
organizationId: true,
locale: true,
profiles: true,
teams: {
where: {
accepted: true, // Ensure they are apart of the team and not just invited.
},
select: {
teamId: true,
disableImpersonation: true,
role: true,
},
},
},
});
if (returningUser) {
// Skip for none org users
const inOrg =
returningUser.organizationId || // Keep for backwards compatability
returningUser.profiles.some((profile) => profile.organizationId !== undefined); // New way of seeing if the user has a profile in orgs.
if (returningUser.role !== "ADMIN" && !inOrg) return;
const hasTeams = returningUser.teams.length >= 1;
const profile = await findProfile(returningUser);
return {
user: {
id: returningUser.id,
email: returningUser.email,
locale: returningUser.locale,
name: returningUser.name,
organizationId: returningUser.organizationId,
role: returningUser.role,
username: returningUser.username,
profile,
},
impersonatedByUID,
hasTeams,
};
}
}
const ImpersonationProvider = CredentialsProvider({
id: "impersonation-auth",
name: "Impersonation",
type: "credentials",
credentials: {
username: { type: "text" },
teamId: { type: "text" },
returnToId: { type: "text" },
},
async authorize(creds, req) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore need to figure out how to correctly type this
const session = await getSession({ req });
const teamId = parseTeamId(creds);
checkSelfImpersonation(session, creds);
checkUserIdentifier(creds);
// Returning to target and UID is self without having to do perm checks.
const returnToUser = await isReturningToSelf({ session, creds });
if (returnToUser) {
return auditAndReturnNextUser(
returnToUser.user,
returnToUser.impersonatedByUID,
returnToUser.hasTeams,
true
);
}
checkGlobalPermission(session);
const impersonatedUser = await getImpersonatedUser({ session, teamId, creds });
if (session?.user.role === "ADMIN") {
if (impersonatedUser.disableImpersonation) {
throw new Error("This user has disabled Impersonation.");
}
return auditAndReturnNextUser(
impersonatedUser,
session?.user.id as number,
impersonatedUser.teams.length > 0 // If the user has any teams, they belong to an active team and we can set the hasActiveTeam ctx to true
);
}
await ensureOrganizationIsReviewed(session?.user.org?.id);
if (!teamId) throw new Error("Error-teamNotFound: You do not have permission to do this.");
// Check session
const sessionUserFromDb = await prisma.user.findUnique({
where: {
id: session?.user.id,
},
include: {
teams: {
where: {
AND: [
{
role: {
in: ["ADMIN", "OWNER"],
},
},
{
team: {
id: teamId,
},
},
],
},
select: {
role: true,
},
},
},
});
if (sessionUserFromDb?.teams.length === 0 || impersonatedUser.teams.length === 0) {
throw new Error("Error-UserHasNoTeams: You do not have permission to do this.");
}
// We find team by ID so we know there is only one team in the array
if (sessionUserFromDb?.teams[0].role === "ADMIN" && impersonatedUser.teams[0].role === "OWNER") {
throw new Error("You do not have permission to do this.");
}
return auditAndReturnNextUser(
impersonatedUser,
session?.user.id as number,
impersonatedUser.teams.length > 0 // If the user has any teams, they belong to an active team and we can set the hasActiveTeam ctx to true
);
},
});
export default ImpersonationProvider;
async function findProfile(returningUser: { id: number; username: string | null }) {
const allOrgProfiles = await ProfileRepository.findAllProfilesForUserIncludingMovedUser({
id: returningUser.id,
username: returningUser.username,
});
const firstOrgProfile = allOrgProfiles[0];
const orgMembers = firstOrgProfile.organizationId
? await prisma.membership.findMany({
where: {
teamId: firstOrgProfile.organizationId,
},
})
: [];
const profile = !firstOrgProfile.organization
? firstOrgProfile
: {
...firstOrgProfile,
organization: {
...firstOrgProfile.organization,
members: orgMembers,
},
};
return profile;
}

View File

@@ -0,0 +1 @@
export { default } from "./common/server/checkLicense";

View File

@@ -0,0 +1,207 @@
// eslint-disable-next-line no-restricted-imports
import { get } from "lodash";
import type { TFunction } from "next-i18next";
import type { Dispatch, SetStateAction } from "react";
import { useState } from "react";
import type { UseFormReturn } from "react-hook-form";
import type z from "zod";
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
import { classNames } from "@calcom/lib";
import type { Prisma } from "@calcom/prisma/client";
import { SchedulingType } from "@calcom/prisma/enums";
import type { _EventTypeModel } from "@calcom/prisma/zod/eventtype";
import { Badge, Icon, Switch, Tooltip } from "@calcom/ui";
export const LockedSwitch = (
isManagedEventType: boolean,
[isLocked, setIsLocked]: [boolean, Dispatch<SetStateAction<boolean>>],
fieldName: string,
setUnlockedFields: (fieldName: string, val: boolean | undefined) => void,
options = { simple: false }
) => {
return isManagedEventType ? (
<Switch
data-testid={`locked-indicator-${fieldName}`}
onCheckedChange={(enabled) => {
setIsLocked(enabled);
setUnlockedFields(fieldName, !enabled || undefined);
}}
checked={isLocked}
small={!options.simple}
/>
) : null;
};
export const LockedIndicator = (
isChildrenManagedEventType: boolean,
isManagedEventType: boolean,
[isLocked, setIsLocked]: [boolean, Dispatch<SetStateAction<boolean>>],
t: TFunction,
fieldName: string,
setUnlockedFields: (fieldName: string, val: boolean | undefined) => void,
options = { simple: false }
) => {
const stateText = t(isLocked ? "locked" : "unlocked");
const tooltipText = t(
`${isLocked ? "locked" : "unlocked"}_fields_${isManagedEventType ? "admin" : "member"}_description`
);
return (
(isManagedEventType || isChildrenManagedEventType) && (
<Tooltip content={<>{tooltipText}</>}>
<div className="inline">
<Badge
variant={isLocked ? "gray" : "green"}
className={classNames(
"ml-2 transform justify-between gap-1.5 p-1",
isManagedEventType && !options.simple && "w-28"
)}>
{!options.simple && (
<span className="inline-flex">
<Icon name={isLocked ? "lock" : "lock-open"} className="text-subtle h-3 w-3" />
<span className="ml-1 font-medium">{stateText}</span>
</span>
)}
{isManagedEventType && (
<Switch
data-testid={`locked-indicator-${fieldName}`}
onCheckedChange={(enabled) => {
setIsLocked(enabled);
setUnlockedFields(fieldName, !enabled || undefined);
}}
checked={isLocked}
small={!options.simple}
/>
)}
</Badge>
</div>
</Tooltip>
)
);
};
const useLockedFieldsManager = ({
eventType,
translate,
formMethods,
}: {
eventType: Pick<z.infer<typeof _EventTypeModel>, "schedulingType" | "userId" | "metadata" | "id">;
translate: TFunction;
formMethods: UseFormReturn<FormValues>;
}) => {
const { setValue, getValues } = formMethods;
const fieldStates: Record<string, [boolean, Dispatch<SetStateAction<boolean>>]> = {};
const unlockedFields =
(eventType.metadata?.managedEventConfig?.unlockedFields !== undefined &&
eventType.metadata?.managedEventConfig?.unlockedFields) ||
{};
const isManagedEventType = eventType.schedulingType === SchedulingType.MANAGED;
const isChildrenManagedEventType =
eventType.metadata?.managedEventConfig !== undefined &&
eventType.schedulingType !== SchedulingType.MANAGED;
const setUnlockedFields = (fieldName: string, val: boolean | undefined) => {
const path = "metadata.managedEventConfig.unlockedFields";
const metaUnlockedFields = getValues(path);
if (!metaUnlockedFields) return;
if (val === undefined) {
delete metaUnlockedFields[fieldName as keyof typeof metaUnlockedFields];
setValue(path, { ...metaUnlockedFields }, { shouldDirty: true });
} else {
setValue(
path,
{
...metaUnlockedFields,
[fieldName]: val,
},
{ shouldDirty: true }
);
}
};
const getLockedInitState = (fieldName: string): boolean => {
let locked = isManagedEventType || isChildrenManagedEventType;
const unlockedFieldList = getValues("metadata")?.managedEventConfig?.unlockedFields;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
const fieldIsUnlocked = unlockedFieldList && unlockedFieldList[fieldName] === true;
if (fieldName.includes(".")) {
locked = locked && get(unlockedFields, fieldName) === undefined;
} else {
locked = locked && !fieldIsUnlocked;
}
return locked;
};
const useShouldLockIndicator = (fieldName: string, options?: { simple: true }) => {
if (!fieldStates[fieldName]) {
// eslint-disable-next-line react-hooks/rules-of-hooks
fieldStates[fieldName] = useState(getLockedInitState(fieldName));
}
return LockedIndicator(
isChildrenManagedEventType,
isManagedEventType,
fieldStates[fieldName],
translate,
fieldName,
setUnlockedFields,
options
);
};
const useLockedLabel = (fieldName: string, options?: { simple: true }) => {
if (!fieldStates[fieldName]) {
// eslint-disable-next-line react-hooks/rules-of-hooks
fieldStates[fieldName] = useState(getLockedInitState(fieldName));
}
const isLocked = fieldStates[fieldName][0];
return {
disabled:
!isManagedEventType &&
eventType.metadata?.managedEventConfig !== undefined &&
unlockedFields[fieldName as keyof Omit<Prisma.EventTypeSelect, "id">] === undefined,
LockedIcon: useShouldLockIndicator(fieldName, options),
isLocked,
};
};
const useLockedSwitch = (fieldName: string, options = { simple: false }) => {
if (!fieldStates[fieldName]) {
// eslint-disable-next-line react-hooks/rules-of-hooks
fieldStates[fieldName] = useState(getLockedInitState(fieldName));
}
return () =>
LockedSwitch(isManagedEventType, fieldStates[fieldName], fieldName, setUnlockedFields, options);
};
const useShouldLockDisableProps = (fieldName: string, options?: { simple: true }) => {
if (!fieldStates[fieldName]) {
// eslint-disable-next-line react-hooks/rules-of-hooks
fieldStates[fieldName] = useState(getLockedInitState(fieldName));
}
return {
disabled:
!isManagedEventType &&
eventType.metadata?.managedEventConfig !== undefined &&
unlockedFields[fieldName as keyof Omit<Prisma.EventTypeSelect, "id">] === undefined,
LockedIcon: useShouldLockIndicator(fieldName, options),
isLocked: fieldStates[fieldName][0],
};
};
return {
shouldLockIndicator: useShouldLockIndicator,
shouldLockDisableProps: useShouldLockDisableProps,
useLockedLabel,
useLockedSwitch,
isManagedEventType,
isChildrenManagedEventType,
};
};
export default useLockedFieldsManager;

View File

@@ -0,0 +1,317 @@
import type { Prisma } from "@prisma/client";
// eslint-disable-next-line no-restricted-imports
import type { DeepMockProxy } from "vitest-mock-extended";
import { sendSlugReplacementEmail } from "@calcom/emails/email-manager";
import { generateHashedLink } from "@calcom/lib/generateHashedLink";
import { getTranslation } from "@calcom/lib/server/i18n";
import type { PrismaClient } from "@calcom/prisma";
import { SchedulingType } from "@calcom/prisma/enums";
import { _EventTypeModel } from "@calcom/prisma/zod";
import { allManagedEventTypeProps, unlockedManagedEventTypeProps } from "@calcom/prisma/zod-utils";
interface handleChildrenEventTypesProps {
eventTypeId: number;
profileId: number | null;
updatedEventType: {
schedulingType: SchedulingType | null;
slug: string;
};
currentUserId: number;
oldEventType: {
children?: { userId: number | null }[] | null | undefined;
team: { name: string } | null;
workflows?: { workflowId: number }[];
} | null;
hashedLink: string | undefined;
connectedLink: { id: number } | null;
children:
| {
hidden: boolean;
owner: {
id: number;
name: string;
email: string;
eventTypeSlugs: string[];
};
}[]
| undefined;
prisma: PrismaClient | DeepMockProxy<PrismaClient>;
updatedValues: Prisma.EventTypeUpdateInput;
}
const sendAllSlugReplacementEmails = async (
persons: { email: string; name: string }[],
slug: string,
teamName: string | null
) => {
const t = await getTranslation("en", "common");
persons.map(
async (person) =>
await sendSlugReplacementEmail({ email: person.email, name: person.name, teamName, slug, t })
);
};
const checkExistentEventTypes = async ({
updatedEventType,
children,
prisma,
userIds,
teamName,
}: Pick<handleChildrenEventTypesProps, "updatedEventType" | "children" | "prisma"> & {
userIds: number[];
teamName: string | null;
}) => {
const replaceEventType = children?.filter(
(ch) => ch.owner.eventTypeSlugs.includes(updatedEventType.slug) && userIds.includes(ch.owner.id)
);
// If so, delete their event type with the same slug to proceed to create a managed one
if (replaceEventType?.length) {
const deletedReplacedEventTypes = await prisma.eventType.deleteMany({
where: {
slug: updatedEventType.slug,
userId: {
in: replaceEventType.map((evTy) => evTy.owner.id),
},
},
});
// Sending notification after deleting
await sendAllSlugReplacementEmails(
replaceEventType.map((evTy) => evTy.owner),
updatedEventType.slug,
teamName
);
return deletedReplacedEventTypes;
}
};
export default async function handleChildrenEventTypes({
eventTypeId: parentId,
oldEventType,
updatedEventType,
hashedLink,
connectedLink,
children,
prisma,
profileId,
updatedValues,
}: handleChildrenEventTypesProps) {
// Check we are dealing with a managed event type
if (updatedEventType?.schedulingType !== SchedulingType.MANAGED)
return {
message: "No managed event type",
};
// Retrieving the updated event type
const eventType = await prisma.eventType.findFirst({
where: { id: parentId },
select: allManagedEventTypeProps,
});
// Shortcircuit when no data for old and updated event types
if (!oldEventType || !eventType)
return {
message: "Missing event type",
};
// bookingFields is expected to be filled by the _EventTypeModel but is null at create event
const _ManagedEventTypeModel = _EventTypeModel.extend({
bookingFields: _EventTypeModel.shape.bookingFields.nullish(),
});
const allManagedEventTypePropsZod = _ManagedEventTypeModel.pick(allManagedEventTypeProps);
const managedEventTypeValues = allManagedEventTypePropsZod
.omit(unlockedManagedEventTypeProps)
.parse(eventType);
// Check we are certainly dealing with a managed event type through its metadata
if (!managedEventTypeValues.metadata?.managedEventConfig)
return {
message: "No managed event metadata",
};
// Define the values for unlocked properties to use on creation, not updation
const unlockedEventTypeValues = allManagedEventTypePropsZod
.pick(unlockedManagedEventTypeProps)
.parse(eventType);
// Calculate if there are new/existent/deleted children users for which the event type needs to be created/updated/deleted
const previousUserIds = oldEventType.children?.flatMap((ch) => ch.userId ?? []);
const currentUserIds = children?.map((ch) => ch.owner.id);
const deletedUserIds = previousUserIds?.filter((id) => !currentUserIds?.includes(id));
const newUserIds = currentUserIds?.filter((id) => !previousUserIds?.includes(id));
const oldUserIds = currentUserIds?.filter((id) => previousUserIds?.includes(id));
// Calculate if there are new workflows for which assigned members will get too
const currentWorkflowIds = eventType.workflows?.map((wf) => wf.workflowId);
// Define hashedLink query input
const hashedLinkQuery = (userId: number) => {
return hashedLink
? !connectedLink
? { create: { link: generateHashedLink(userId) } }
: undefined
: connectedLink
? { delete: true }
: undefined;
};
// Store result for existent event types deletion process
let deletedExistentEventTypes = undefined;
// New users added
if (newUserIds?.length) {
// Check if there are children with existent homonym event types to send notifications
deletedExistentEventTypes = await checkExistentEventTypes({
updatedEventType,
children,
prisma,
userIds: newUserIds,
teamName: oldEventType.team?.name ?? null,
});
// Create event types for new users added
await prisma.$transaction(
newUserIds.map((userId) => {
return prisma.eventType.create({
data: {
profileId: profileId ?? null,
...managedEventTypeValues,
...unlockedEventTypeValues,
bookingLimits:
(managedEventTypeValues.bookingLimits as unknown as Prisma.InputJsonObject) ?? undefined,
recurringEvent:
(managedEventTypeValues.recurringEvent as unknown as Prisma.InputJsonValue) ?? undefined,
metadata: (managedEventTypeValues.metadata as Prisma.InputJsonValue) ?? undefined,
bookingFields: (managedEventTypeValues.bookingFields as Prisma.InputJsonValue) ?? undefined,
durationLimits: (managedEventTypeValues.durationLimits as Prisma.InputJsonValue) ?? undefined,
onlyShowFirstAvailableSlot: managedEventTypeValues.onlyShowFirstAvailableSlot ?? false,
userId,
users: {
connect: [{ id: userId }],
},
parentId,
hidden: children?.find((ch) => ch.owner.id === userId)?.hidden ?? false,
workflows: currentWorkflowIds && {
create: currentWorkflowIds.map((wfId) => ({ workflowId: wfId })),
},
// Reserved for future releases
/*
webhooks: eventType.webhooks && {
createMany: {
data: eventType.webhooks?.map((wh) => ({ ...wh, eventTypeId: undefined })),
},
},*/
hashedLink: hashedLinkQuery(userId),
},
});
})
);
}
// Old users updated
if (oldUserIds?.length) {
// Check if there are children with existent homonym event types to send notifications
deletedExistentEventTypes = await checkExistentEventTypes({
updatedEventType,
children,
prisma,
userIds: oldUserIds,
teamName: oldEventType.team?.name || null,
});
const { unlockedFields } = managedEventTypeValues.metadata?.managedEventConfig;
const unlockedFieldProps = !unlockedFields
? {}
: Object.keys(unlockedFields).reduce((acc, key) => {
const filteredKey =
key === "afterBufferTime"
? "afterEventBuffer"
: key === "beforeBufferTime"
? "beforeEventBuffer"
: key;
// @ts-expect-error Element implicitly has any type
acc[filteredKey] = true;
return acc;
}, {});
// Add to payload all eventType values that belong to locked fields, changed or unchanged
// Ignore from payload any eventType values that belong to unlocked fields
const updatePayload = allManagedEventTypePropsZod.omit(unlockedFieldProps).parse(eventType);
const updatePayloadFiltered = Object.entries(updatePayload)
.filter(([key, _]) => key !== "children")
.reduce((newObj, [key, value]) => ({ ...newObj, [key]: value }), {});
console.log({ unlockedFieldProps });
// Update event types for old users
const oldEventTypes = await prisma.$transaction(
oldUserIds.map((userId) => {
return prisma.eventType.update({
where: {
userId_parentId: {
userId,
parentId,
},
},
data: {
...updatePayloadFiltered,
hashedLink: "hashedLink" in unlockedFieldProps ? undefined : hashedLinkQuery(userId),
},
});
})
);
if (currentWorkflowIds?.length) {
await prisma.$transaction(
currentWorkflowIds.flatMap((wfId) => {
return oldEventTypes.map((oEvTy) => {
return prisma.workflowsOnEventTypes.upsert({
create: {
eventTypeId: oEvTy.id,
workflowId: wfId,
},
update: {},
where: {
workflowId_eventTypeId: {
eventTypeId: oEvTy.id,
workflowId: wfId,
},
},
});
});
})
);
}
// Reserved for future releases
/**
const updatedOldWebhooks = await prisma.webhook.updateMany({
where: {
userId: {
in: oldUserIds,
},
},
data: {
...eventType.webhooks,
},
});
console.log(
"handleChildrenEventTypes:updatedOldWebhooks",
JSON.stringify({ updatedOldWebhooks }, null, 2)
);*/
}
// Old users deleted
if (deletedUserIds?.length) {
// Delete event types for deleted users
await prisma.eventType.deleteMany({
where: {
userId: {
in: deletedUserIds,
},
parentId,
},
});
}
return { newUserIds, oldUserIds, deletedUserIds, deletedExistentEventTypes };
}

View File

@@ -0,0 +1,73 @@
# Organizations feature
From the [Original RFC](https://github.com/calcom/cal.com/issues/7142):
> We want to create organisations within Cal.com to enable people to easily and effectively manage multiple teams. An organisation will live above the current teams layer.
## App setup
1. Log in as admin and in Settings, turn on Organizations feature flag under Features section. Organizations feature has an operational feature flag in order to turn on the entire feature.
2. Set the following environment variables as described:
1. **`CALCOM_LICENSE_KEY`**: Since Organizations is an EE feature, a license key should be present, either as this environment variable or visiting as an Admin `/auth/setup`. To get a license key you should visit Cal Console ([prod](https://console.cal.com) or [dev](https://console.cal.dev))
2. **`NEXT_PUBLIC_WEBAPP_URL`**: In case of local development, this variable should be set to `http://app.cal.local:3000` to be able to handle subdomains correctly in terms of authentication and cookies
3. **`NEXTAUTH_URL`**: Should be equal to `NEXT_PUBLIC_WEBAPP_URL` which is `http://app.cal.local:3000`
4. **`NEXTAUTH_COOKIE_DOMAIN`**: In case of local development, this variable should be set to `.cal.local` to be able to accept session cookies in subdomains as well otherwise it should be set to the corresponding environment such as `.cal.dev`, `.cal.qa` or `.cal.com`. If you choose another subdomain, the value for this should match the apex domain of `NEXT_PUBLIC_WEBAPP_URL` with a leading dot (`.`)
5. **`ORGANIZATIONS_ENABLED`**: Should be set to `1` or `true`
6. **`STRIPE_ORG_MONTHLY_PRICE_ID`**: For dev and all testing should be set to your own testing key. Or ask for the shared key if you're a core member.
7. **`ORGANIZATIONS_AUTOLINK`**: Optional. Set to `1` or `true` to let new signed-up users using Google external provider join the corresponding organization based on the email domain name.
3. Add `app.cal.local` to your host file, either:
1. `sudo npx hostile set localhost app.cal.local`
2. Add it yourself
6. Add `acme.cal.local` to host file given that the org create for it will be `acme`, otherwise do this for whatever slug will be assigned to the org. This is needed to test org-related public URLs, such as sub-teams, members and event-types.
7. Be sure to be logged in with any type of user and visit `/settings/organizations/new` and follow setup steps with the slug matching the org slug from step 3
8. Log in as admin and go to Settings and under Organizations you will need to accept the newly created organization in order to be operational
9. After finishing the org creation, you will be automatically logged in as the owner of the organization, and the app will be shown in organization mode
### Note
Browsers do not allow camera/mic access on any non-HTTPS hosts except for localhost. To test cal video organization meeting links locally (`app.cal.local/video/[uid]`). You can access the meeting link by replacing app.cal.local with localhost in the URL.
For eg:- Use `http://localhost:3000/video/nAjnkjejuzis99NhN72rGt` instead of `http://app.cal.local:3000/video/nAjnkjejuzis99NhN72rGt`.
To get an HTTPS URL for localhost, you can use a tunneling tool such as `ngrok` or [Tunnelmole](https://github.com/robbie-cahill/tunnelmole-client) . Alternatively, you can generate an SSL certificate for your local domain using `mkcert`. Turn off any SSL certificate validation in your HTTPS client (be sure to do this for local only, otherwise its a security risk).
#### Tunnelmole - Open Source Tunnelling Tool:
To install Tunnelmole, execute the command:
```
curl -O https://install.tunnelmole.com/8dPBw/install && sudo bash install
```
After a successful installation, you can run Tunnelmole using the following command, replacing `8000` with your actual port number if it is different:
```
tmole 8000
```
In the output, you'll see two URLs, one HTTP and an HTTPS URL. For privacy and security reasons, it is recommended to use the HTTPS URL.
View the Tunnelmole [README](https://github.com/robbie-cahill/tunnelmole-client) for additional information and other installation methods such as `npm` or building your own binaries from source.
#### ngrok - Closed Source Tunnelling Tool:
ngrok is a popular closed source tunneling tool. You can run ngrok using the same port, using the format `ngrok http <port>` replacing `<port>` with your actual port number. For example:
```
ngrok http 8000
```
This will generate a public URL that you can use to access your localhost server.
## DNS setup
When a new organization is created, other than not being verified up until the admin accepts it in settings as explained in step 6, a flag gets created that marks the organization as missing DNS setup. That flag get auto-checked by the system upon organization creation when the Cal instance is deployed in Vercel and the subdomain registration was successful. Logging in as admin and going to Settings > Organizations section, you will see that flag as a badge, designed to give admins a glimpe on what is pending in terms of making an organization work. Alongside the mentioned badge, an email gets sent to admins in order to warn them there is a pending action about setting up DNS for the newly created organization to work.

View File

@@ -0,0 +1,38 @@
import type { NextApiRequest, NextApiResponse } from "next";
import z from "zod";
import { HttpError } from "@calcom/lib/http-error";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
const querySchema = z.object({
org: z.string({ required_error: "org slug is required" }),
});
async function handler(req: NextApiRequest, res: NextApiResponse) {
const parsedQuery = querySchema.safeParse(req.query);
if (!parsedQuery.success) throw new HttpError({ statusCode: 400, message: parsedQuery.error.message });
const {
data: { org: slug },
} = parsedQuery;
if (!slug) return res.status(400).json({ message: "Org is needed" });
const org = await prisma.team.findFirst({
where: { slug },
select: { children: true, isOrganization: true },
});
if (!org) return res.status(400).json({ message: "Org doesn't exist" });
const isOrganization = org.isOrganization;
if (!isOrganization) return res.status(400).json({ message: "Team is not an org" });
return res.status(200).json({ slugs: org.children.map((ch) => ch.slug) });
}
export default defaultHandler({
GET: Promise.resolve({ default: defaultResponder(handler) }),
});

View File

@@ -0,0 +1,123 @@
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import z from "zod";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import { trpc } from "@calcom/trpc/react";
import { Alert, Avatar, Button, Form, Icon, ImageUploader, Label, TextAreaField } from "@calcom/ui";
const querySchema = z.object({
id: z.string(),
});
export const AboutOrganizationForm = () => {
const { t } = useLocale();
const router = useRouter();
const routerQuery = useRouterQuery();
const { id: orgId } = querySchema.parse(routerQuery);
const [serverErrorMessage, setServerErrorMessage] = useState<string | null>(null);
const [image, setImage] = useState("");
const aboutOrganizationFormMethods = useForm<{
logo: string;
bio: string;
}>();
const updateOrganizationMutation = trpc.viewer.organizations.update.useMutation({
onSuccess: (data) => {
if (data.update) {
router.push(`/settings/organizations/${orgId}/onboard-members`);
}
},
onError: (err) => {
setServerErrorMessage(err.message);
},
});
return (
<>
<Form
form={aboutOrganizationFormMethods}
className="space-y-5"
handleSubmit={(v) => {
if (!updateOrganizationMutation.isPending) {
setServerErrorMessage(null);
updateOrganizationMutation.mutate({ ...v, orgId });
}
}}>
{serverErrorMessage && (
<div>
<Alert severity="error" message={serverErrorMessage} />
</div>
)}
<div>
<Controller
control={aboutOrganizationFormMethods.control}
name="logo"
render={() => (
<>
<Label>{t("organization_logo")}</Label>
<div className="flex items-center">
<Avatar
alt=""
fallback={<Icon name="plus" className="text-subtle h-6 w-6" />}
className="items-center"
imageSrc={image}
size="lg"
/>
<div className="ms-4">
<ImageUploader
target="avatar"
id="avatar-upload"
buttonMsg={t("upload")}
handleAvatarChange={(newAvatar: string) => {
setImage(newAvatar);
aboutOrganizationFormMethods.setValue("logo", newAvatar);
}}
imageSrc={image}
/>
</div>
</div>
</>
)}
/>
</div>
<div>
<Controller
control={aboutOrganizationFormMethods.control}
name="bio"
render={({ field: { value } }) => (
<>
<TextAreaField
name="about"
defaultValue={value}
onChange={(e) => {
aboutOrganizationFormMethods.setValue("bio", e?.target.value);
}}
/>
<p className="text-subtle text-sm">{t("organization_about_description")}</p>
</>
)}
/>
</div>
<div className="flex">
<Button
disabled={
aboutOrganizationFormMethods.formState.isSubmitting || updateOrganizationMutation.isPending
}
color="primary"
EndIcon="arrow-right"
type="submit"
className="w-full justify-center">
{t("continue")}
</Button>
</div>
</Form>
</>
);
};

View File

@@ -0,0 +1,256 @@
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { z } from "zod";
import { classNames } from "@calcom/lib";
import { IS_TEAM_BILLING_ENABLED_CLIENT } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import { Button, CheckboxField, Form, Icon, showToast, TextField } from "@calcom/ui";
const querySchema = z.object({
id: z.string().transform((val) => parseInt(val)),
});
const schema = z.object({
teams: z.array(
z.object({
name: z.string().trim(),
})
),
moveTeams: z.array(
z.object({
id: z.number(),
shouldMove: z.boolean(),
newSlug: z.string().optional(),
})
),
});
export const AddNewTeamsForm = () => {
const { data: teams } = trpc.viewer.teams.list.useQuery();
const routerQuery = useRouterQuery();
const { id: orgId } = querySchema.parse(routerQuery);
const { data: org } = trpc.viewer.teams.get.useQuery({ teamId: orgId, isOrg: true });
if (!teams || !org) {
return null;
}
const orgWithRequestedSlug = {
...org,
requestedSlug: org.metadata.requestedSlug ?? null,
};
const regularTeams = teams.filter((team) => !team.parentId);
return <AddNewTeamsFormChild org={orgWithRequestedSlug} teams={regularTeams} />;
};
const AddNewTeamsFormChild = ({
teams,
org,
}: {
org: { id: number; slug: string | null; requestedSlug: string | null };
teams: { id: number; name: string; slug: string | null }[];
}) => {
const { t } = useLocale();
const router = useRouter();
const [counter, setCounter] = useState(1);
const form = useForm({
defaultValues: {
teams: [{ name: "" }],
moveTeams: teams.map((team) => ({
id: team.id,
shouldMove: false,
newSlug: getSuggestedSlug({ teamSlug: team.slug, orgSlug: org.slug || org.requestedSlug }),
})),
}, // Set initial values
resolver: async (data) => {
try {
const validatedData = await schema.parseAsync(data);
return { values: validatedData, errors: {} };
} catch (error) {
if (error instanceof z.ZodError) {
return {
values: {
teams: [],
},
errors: error.formErrors.fieldErrors,
};
}
return { values: {}, errors: { teams: { message: "Error validating input" } } };
}
},
});
const session = useSession();
const isAdmin =
session.data?.user?.role === UserPermissionRole.ADMIN ||
session.data?.user?.impersonatedBy?.role === UserPermissionRole.ADMIN;
const allowWizardCompletionWithoutUpgrading = !IS_TEAM_BILLING_ENABLED_CLIENT || isAdmin;
const { register, control, watch, getValues } = form;
const { fields, append, remove } = useFieldArray({
control,
name: "teams",
});
const publishOrgMutation = trpc.viewer.organizations.publish.useMutation({
onSuccess(data) {
router.push(data.url);
},
onError: (error) => {
showToast(error.message, "error");
},
});
const handleCounterIncrease = () => {
if (counter >= 0 && counter < 5) {
setCounter((prevCounter) => prevCounter + 1);
append({ name: "" });
}
};
const handleRemoveInput = (index: number) => {
remove(index);
setCounter((prevCounter) => prevCounter - 1);
};
const createTeamsMutation = trpc.viewer.organizations.createTeams.useMutation({
async onSuccess(data) {
if (data.duplicatedSlugs.length) {
showToast(t("duplicated_slugs_warning", { slugs: data.duplicatedSlugs.join(", ") }), "warning");
// Server will return array of duplicated slugs, so we need to wait for user to read the warning
// before pushing to next page
setTimeout(() => handleSuccessRedirect, 3000);
} else {
handleSuccessRedirect();
}
function handleSuccessRedirect() {
if (allowWizardCompletionWithoutUpgrading) {
router.push(`/event-types`);
return;
}
// mutate onSuccess will take care of routing to the correct place.
publishOrgMutation.mutate();
}
},
onError: (error) => {
showToast(t(error.message), "error");
},
});
const handleFormSubmit = () => {
const fields = getValues("teams");
const moveTeams = getValues("moveTeams");
createTeamsMutation.mutate({ orgId: org.id, moveTeams, teamNames: fields.map((field) => field.name) });
};
const moveTeams = watch("moveTeams");
return (
<>
<Form form={form} handleSubmit={handleFormSubmit}>
{moveTeams.length ? (
<>
<label className="text-emphasis mb-2 block text-sm font-medium leading-none">
Move existing teams
</label>
<ul className="mb-8 space-y-4">
{moveTeams.map((team, index) => {
return (
<li key={team.id}>
<Controller
name={`moveTeams.${index}.shouldMove`}
render={({ field: { value, onChange } }) => (
<CheckboxField
defaultValue={value}
checked={value}
onChange={onChange}
description={teams.find((t) => t.id === team.id)?.name ?? ""}
/>
)}
/>
{moveTeams[index].shouldMove ? (
<TextField
placeholder="New Slug"
defaultValue={teams.find((t) => t.id === team.id)?.slug ?? ""}
{...register(`moveTeams.${index}.newSlug`)}
className="mt-2"
label=""
/>
) : null}
</li>
);
})}
</ul>
</>
) : null}
<label className="text-emphasis mb-2 block text-sm font-medium leading-none">Add New Teams</label>
{fields.map((field, index) => (
<div className={classNames("relative", index > 0 ? "mb-2" : "")} key={field.id}>
<TextField
key={field.id}
{...register(`teams.${index}.name`)}
label=""
addOnClassname="bg-transparent p-0 border-l-0"
className={index > 0 ? "mb-2" : ""}
placeholder={t(`org_team_names_example_${index + 1}`)}
addOnSuffix={
index > 0 && (
<Button
color="minimal"
className="group/remove mx-2 px-0 hover:bg-transparent"
onClick={() => handleRemoveInput(index)}
aria-label="Remove Team">
<Icon
name="x"
className="bg-subtle text group-hover/remove:text-inverted group-hover/remove:bg-inverted h-5 w-5 rounded-full p-1"
/>
</Button>
)
}
minLength={2}
maxLength={63}
/>
</div>
))}
{counter === 5 && <p className="text-subtle my-2 text-sm">{t("org_max_team_warnings")}</p>}
{counter < 5 && (
<Button
StartIcon="plus"
color="secondary"
disabled={createTeamsMutation.isPending}
onClick={handleCounterIncrease}
aria-label={t("add_a_team")}
className="my-1">
{t("add_a_team")}
</Button>
)}
<Button
EndIcon="arrow-right"
color="primary"
className="mt-6 w-full justify-center"
data-testId="continue_or_checkout"
disabled={createTeamsMutation.isPending || createTeamsMutation.isSuccess}
onClick={handleFormSubmit}>
{allowWizardCompletionWithoutUpgrading ? t("continue") : t("checkout")}
</Button>
</Form>
</>
);
};
const getSuggestedSlug = ({ teamSlug, orgSlug }: { teamSlug: string | null; orgSlug: string | null }) => {
// If there is no orgSlug, we can't suggest a slug
if (!teamSlug || !orgSlug) {
return teamSlug;
}
return teamSlug.replace(`${orgSlug}-`, "").replace(`-${orgSlug}`, "");
};

View File

@@ -0,0 +1,340 @@
import type { SessionContextValue } from "next-auth/react";
import { signIn, useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
import classNames from "@calcom/lib/classNames";
import { MINIMUM_NUMBER_OF_ORG_SEATS } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import slugify from "@calcom/lib/slugify";
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import type { Ensure } from "@calcom/types/utils";
import { Alert, Button, Form, Label, RadioGroup as RadioArea, TextField, ToggleGroup } from "@calcom/ui";
function extractDomainFromEmail(email: string) {
let out = "";
try {
const match = email.match(/^(?:.*?:\/\/)?.*?(?<root>[\w\-]*(?:\.\w{2,}|\.\w{2,}\.\w{2}))(?:[\/?#:]|$)/);
out = (match && match.groups?.root) ?? "";
} catch (ignore) {}
return out.split(".")[0];
}
export const CreateANewOrganizationForm = () => {
const session = useSession();
if (!session.data) {
return null;
}
return <CreateANewOrganizationFormChild session={session} />;
};
enum BillingPeriod {
MONTHLY = "MONTHLY",
ANNUALLY = "ANNUALLY",
}
const CreateANewOrganizationFormChild = ({
session,
}: {
session: Ensure<SessionContextValue, "data">;
isPlatformOrg?: boolean;
}) => {
const { t } = useLocale();
const router = useRouter();
const telemetry = useTelemetry();
const [serverErrorMessage, setServerErrorMessage] = useState<string | null>(null);
const isAdmin = session.data.user.role === UserPermissionRole.ADMIN;
const defaultOrgOwnerEmail = session.data.user.email ?? "";
const newOrganizationFormMethods = useForm<{
name: string;
seats: number;
billingPeriod: BillingPeriod;
pricePerSeat: number;
slug: string;
orgOwnerEmail: string;
}>({
defaultValues: {
billingPeriod: BillingPeriod.MONTHLY,
slug: !isAdmin ? deriveSlugFromEmail(defaultOrgOwnerEmail) : undefined,
orgOwnerEmail: !isAdmin ? defaultOrgOwnerEmail : undefined,
name: !isAdmin ? deriveOrgNameFromEmail(defaultOrgOwnerEmail) : undefined,
},
});
const createOrganizationMutation = trpc.viewer.organizations.create.useMutation({
onSuccess: async (data) => {
telemetry.event(telemetryEventTypes.org_created);
// This is necessary so that server token has the updated upId
await session.update({
upId: data.upId,
});
if (isAdmin && data.userId !== session.data?.user.id) {
// Impersonate the user chosen as the organization owner(if the admin user isn't the owner himself), so that admin can now configure the organisation on his behalf.
// He won't need to have access to the org directly in this way.
signIn("impersonation-auth", {
username: data.email,
callbackUrl: `/settings/organizations/${data.organizationId}/about`,
});
}
router.push(`/settings/organizations/${data.organizationId}/about`);
},
onError: (err) => {
if (err.message === "organization_url_taken") {
newOrganizationFormMethods.setError("slug", { type: "custom", message: t("url_taken") });
} else if (err.message === "domain_taken_team" || err.message === "domain_taken_project") {
newOrganizationFormMethods.setError("slug", {
type: "custom",
message: t("problem_registering_domain"),
});
} else {
setServerErrorMessage(err.message);
}
},
});
return (
<>
<Form
form={newOrganizationFormMethods}
className="space-y-5"
id="createOrg"
handleSubmit={(v) => {
if (!createOrganizationMutation.isPending) {
setServerErrorMessage(null);
createOrganizationMutation.mutate(v);
}
}}>
<div>
{serverErrorMessage && (
<div className="mb-4">
<Alert severity="error" message={serverErrorMessage} />
</div>
)}
{isAdmin && (
<div className="mb-5">
<Controller
name="billingPeriod"
control={newOrganizationFormMethods.control}
render={({ field: { value, onChange } }) => (
<>
<Label htmlFor="billingPeriod">Billing Period</Label>
<ToggleGroup
isFullWidth
id="billingPeriod"
value={value}
onValueChange={(e: BillingPeriod) => {
if ([BillingPeriod.ANNUALLY, BillingPeriod.MONTHLY].includes(e)) {
onChange(e);
}
}}
options={[
{
value: "MONTHLY",
label: "Monthly",
},
{
value: "ANNUALLY",
label: "Annually",
},
]}
/>
</>
)}
/>
</div>
)}
<Controller
name="orgOwnerEmail"
control={newOrganizationFormMethods.control}
rules={{
required: t("must_enter_organization_admin_email"),
}}
render={({ field: { value } }) => (
<div className="flex">
<TextField
containerClassName="w-full"
placeholder="john@acme.com"
name="orgOwnerEmail"
disabled={!isAdmin}
label={t("admin_email")}
defaultValue={value}
onChange={(e) => {
const email = e?.target.value;
const slug = deriveSlugFromEmail(email);
newOrganizationFormMethods.setValue("orgOwnerEmail", email.trim());
if (newOrganizationFormMethods.getValues("slug") === "") {
newOrganizationFormMethods.setValue("slug", slug);
}
newOrganizationFormMethods.setValue("name", deriveOrgNameFromEmail(email));
}}
autoComplete="off"
/>
</div>
)}
/>
</div>
<div>
<Controller
name="name"
control={newOrganizationFormMethods.control}
defaultValue=""
rules={{
required: t("must_enter_organization_name"),
}}
render={({ field: { value } }) => (
<>
<TextField
className="mt-2"
placeholder="Acme"
name="name"
label={t("organization_name")}
defaultValue={value}
onChange={(e) => {
newOrganizationFormMethods.setValue("name", e?.target.value.trim());
if (newOrganizationFormMethods.formState.touchedFields["slug"] === undefined) {
newOrganizationFormMethods.setValue("slug", slugify(e?.target.value));
}
}}
autoComplete="off"
/>
</>
)}
/>
</div>
<div>
<Controller
name="slug"
control={newOrganizationFormMethods.control}
rules={{
required: "Must enter organization slug",
}}
render={({ field: { value } }) => (
<TextField
className="mt-2"
name="slug"
label={t("organization_url")}
placeholder="acme"
addOnSuffix={`.${subdomainSuffix()}`}
defaultValue={value}
onChange={(e) => {
newOrganizationFormMethods.setValue("slug", slugify(e?.target.value), {
shouldTouch: true,
});
newOrganizationFormMethods.clearErrors("slug");
}}
/>
)}
/>
</div>
{isAdmin && (
<>
<section className="grid grid-cols-2 gap-2">
<div className="w-full">
<Controller
name="seats"
control={newOrganizationFormMethods.control}
render={({ field: { value, onChange } }) => (
<div className="flex">
<TextField
containerClassName="w-full"
placeholder="30"
name="seats"
type="number"
label="Seats (optional)"
min={isAdmin ? 1 : MINIMUM_NUMBER_OF_ORG_SEATS}
defaultValue={value || MINIMUM_NUMBER_OF_ORG_SEATS}
onChange={(e) => {
onChange(+e.target.value);
}}
autoComplete="off"
/>
</div>
)}
/>
</div>
<div className="w-full">
<Controller
name="pricePerSeat"
control={newOrganizationFormMethods.control}
render={({ field: { value, onChange } }) => (
<div className="flex">
<TextField
containerClassName="w-full"
placeholder="30"
name="pricePerSeat"
type="number"
addOnSuffix="$"
label="Price per seat (optional)"
defaultValue={value}
onChange={(e) => {
onChange(+e.target.value);
}}
autoComplete="off"
/>
</div>
)}
/>
</div>
</section>
</>
)}
{/* This radio group does nothing - its just for visuall purposes */}
{!isAdmin && (
<>
<div className="bg-subtle space-y-5 rounded-lg p-5">
<h3 className="font-cal text-default text-lg font-semibold leading-4">
Upgrade to Organizations
</h3>
<RadioArea.Group className={classNames("mt-1 flex flex-col gap-4")} value="ORGANIZATION">
<RadioArea.Item
className={classNames("bg-default w-full text-sm opacity-70")}
value="TEAMS"
disabled>
<strong className="mb-1 block">{t("teams")}</strong>
<p>{t("your_current_plan")}</p>
</RadioArea.Item>
<RadioArea.Item className={classNames("bg-default w-full text-sm")} value="ORGANIZATION">
<strong className="mb-1 block">{t("organization")}</strong>
<p>{t("organization_price_per_user_month")}</p>
</RadioArea.Item>
</RadioArea.Group>
</div>
</>
)}
<div className="flex space-x-2 rtl:space-x-reverse">
<Button
disabled={
newOrganizationFormMethods.formState.isSubmitting || createOrganizationMutation.isPending
}
color="primary"
EndIcon="arrow-right"
type="submit"
form="createOrg"
className="w-full justify-center">
{t("continue")}
</Button>
</div>
</Form>
</>
);
};
export function deriveSlugFromEmail(email: string) {
const domain = extractDomainFromEmail(email);
return domain;
}
export function deriveOrgNameFromEmail(email: string) {
const domain = extractDomainFromEmail(email);
return domain.charAt(0).toUpperCase() + domain.slice(1);
}

View File

@@ -0,0 +1,50 @@
import { useRouter } from "next/navigation";
import { useIsPlatform } from "@calcom/atoms/monorepo";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import { showToast, TopBanner } from "@calcom/ui";
export type OrgUpgradeBannerProps = {
data: RouterOutputs["viewer"]["getUserTopBanners"]["orgUpgradeBanner"];
};
export function OrgUpgradeBanner({ data }: OrgUpgradeBannerProps) {
const { t } = useLocale();
const router = useRouter();
const isPlatform = useIsPlatform();
const publishOrgMutation = trpc.viewer.organizations.publish.useMutation({
onSuccess(data) {
router.push(data.url);
},
onError: (error) => {
showToast(error.message, "error");
},
});
if (!data) return null;
const [membership] = data;
if (!membership) return null;
// TODO: later figure out how to not show this banner on platform since platform is different to orgs (it just uses the same code)
if (isPlatform) return null;
return (
<TopBanner
text={t("org_upgrade_banner_description", { teamName: membership.team.name })}
variant="warning"
actions={
<button
data-testid="upgrade_org_banner_button"
className="border-b border-b-black"
onClick={() => {
publishOrgMutation.mutate();
}}>
{t("upgrade_banner_action")}
</button>
}
/>
);
}

View File

@@ -0,0 +1,96 @@
import type { PropsWithChildren } from "react";
import { useState } from "react";
import classNames from "@calcom/lib/classNames";
import type { RouterOutputs } from "@calcom/trpc";
import { Avatar, TextField } from "@calcom/ui";
type TeamInviteFromOrgProps = PropsWithChildren<{
selectedEmails?: string | string[];
handleOnChecked: (usersEmail: string) => void;
orgMembers?: RouterOutputs["viewer"]["organizations"]["getMembers"];
}>;
const keysToCheck = ["name", "email", "username"] as const; // array of keys to check
export default function TeamInviteFromOrg({
handleOnChecked,
selectedEmails,
orgMembers,
}: TeamInviteFromOrgProps) {
const [searchQuery, setSearchQuery] = useState("");
const filteredMembers = orgMembers?.filter((member) => {
if (!searchQuery) {
return true; // return all members if searchQuery is empty
}
const { user } = member ?? {}; // destructuring with default value in case member is undefined
return keysToCheck.some((key) => user?.[key]?.toLowerCase().includes(searchQuery.toLowerCase()));
});
return (
<div className="bg-muted border-subtle flex flex-col rounded-md border p-4">
<div className="-my-1">
<TextField placeholder="Search..." onChange={(e) => setSearchQuery(e.target.value)} />
</div>
<hr className="border-subtle -mx-4 mt-2" />
<div className="scrollbar min-h-48 flex max-h-48 flex-col space-y-0.5 overflow-y-scroll pt-2">
<>
{filteredMembers &&
filteredMembers.map((member) => {
const isSelected = Array.isArray(selectedEmails)
? selectedEmails.includes(member.user.email)
: selectedEmails === member.user.email;
return (
<UserToInviteItem
key={member.user.id}
member={member}
isSelected={isSelected}
onChange={() => handleOnChecked(member.user.email)}
/>
);
})}
</>
</div>
</div>
);
}
function UserToInviteItem({
member,
isSelected,
onChange,
}: {
member: RouterOutputs["viewer"]["organizations"]["getMembers"][number];
isSelected: boolean;
onChange: () => void;
}) {
return (
<div
key={member.userId}
onClick={() => onChange()} // We handle this on click on the div also - for a11y we handle it with label and checkbox below
className={classNames(
"flex cursor-pointer items-center rounded-md px-2 py-1",
isSelected ? "bg-emphasis" : "hover:bg-subtle "
)}>
<div className="flex items-center space-x-2 rtl:space-x-reverse">
<Avatar size="sm" alt="Users avatar" asChild imageSrc={member.user.avatarUrl} />
<label
htmlFor={`${member.user.id}`}
className="text-emphasis cursor-pointer text-sm font-medium leading-none">
{member.user.name || member.user.email || "Nameless User"}
</label>
</div>
<div className="ml-auto">
<input
id={`${member.user.id}`}
checked={isSelected}
type="checkbox"
className="text-emphasis focus:ring-emphasis dark:text-muted border-default hover:bg-subtle inline-flex h-4 w-4 place-self-center justify-self-end rounded checked:bg-gray-800"
onChange={() => {
onChange();
}}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,3 @@
export { CreateANewOrganizationForm } from "./CreateANewOrganizationForm";
export { AboutOrganizationForm } from "./AboutOrganizationForm";
export { AddNewTeamsForm } from "./AddNewTeamsForm";

View File

@@ -0,0 +1,77 @@
import { createContext, useContext, createElement } from "react";
import type z from "zod";
import type { MembershipRole } from "@calcom/prisma/enums";
import type { teamMetadataSchema } from "@calcom/prisma/zod-utils";
/**
* Organization branding
*
* Entries consist of the different properties that constitues a brand for an organization.
*/
export type OrganizationBranding =
| ({
/** 1 */
id: number;
/** Acme */
name?: string;
/** acme */
slug: string;
/** logo url */
logoUrl?: string | null;
/** https://acme.cal.com */
fullDomain: string;
/** cal.com */
domainSuffix: string;
role: MembershipRole;
} & z.infer<typeof teamMetadataSchema>)
| null
| undefined;
/**
* Allows you to access the flags from context
*/
const OrganizationBrandingContext = createContext<{ orgBrand: OrganizationBranding } | null>(null);
/**
* Accesses the branding for an organization from context.
* You need to render a <OrgBrandingProvider /> further up to be able to use
* this component.
*
* @returns `undefined` when data isn't available yet, `null` when there's an error, and the data(which can be `null`) when it's available
*/
export function useOrgBranding() {
const orgBrandingContext = useContext(OrganizationBrandingContext);
if (!orgBrandingContext) throw new Error("Error: useOrgBranding was used outside of OrgBrandingProvider.");
return orgBrandingContext.orgBrand;
}
/**
* If you want to be able to access the flags from context using `useOrganizationBranding()`,
* you can render the OrgBrandingProvider at the top of your Next.js pages, like so:
*
* ```ts
* import { useOrgBrandingValues } from "@calcom/features/flags/hooks/useFlag"
* import { OrgBrandingProvider, useOrgBranding } from @calcom/features/flags/context/provider"
*
* export default function YourPage () {
* const orgBrand = useOrgBrandingValues()
*
* return (
* <OrgBrandingProvider value={orgBrand}>
* <YourOwnComponent />
* </OrgBrandingProvider>
* )
* }
* ```
*
* You can then call `useOrgBrandingValues()` to access your `OrgBranding` from within
* `YourOwnComponent` or further down.
*
*/
export function OrgBrandingProvider<F extends { orgBrand: OrganizationBranding }>(props: {
value: F;
children: React.ReactNode;
}) {
return createElement(OrganizationBrandingContext.Provider, { value: props.value }, props.children);
}

View File

@@ -0,0 +1,22 @@
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
/**
* It assumes that a user can only impersonate the members of the organization he is logged in to.
* Note: Ensuring that one organization's member can't impersonate other organization's member isn't the job of this function
*/
export async function ensureOrganizationIsReviewed(loggedInUserOrgId: number | undefined) {
if (loggedInUserOrgId) {
const org = await OrganizationRepository.findByIdIncludeOrganizationSettings({
id: loggedInUserOrgId,
});
if (!org) {
throw new Error("Error-OrgNotFound: You do not have permission to do this.");
}
if (!org.organizationSettings?.isAdminReviewed) {
// If the org is not reviewed, we can't allow impersonation
throw new Error("Error-OrgNotReviewed: You do not have permission to do this.");
}
}
}

View File

@@ -0,0 +1,108 @@
import { describe, expect, it } from "vitest";
import {
getOrgSlug,
getOrgDomainConfigFromHostname,
getOrgFullOrigin,
} from "@calcom/features/ee/organizations/lib/orgDomains";
import * as constants from "@calcom/lib/constants";
function setupEnvs({ WEBAPP_URL = "https://app.cal.com", WEBSITE_URL } = {}) {
Object.defineProperty(constants, "WEBAPP_URL", { value: WEBAPP_URL });
Object.defineProperty(constants, "WEBSITE_URL", { value: WEBSITE_URL });
Object.defineProperty(constants, "ALLOWED_HOSTNAMES", {
value: ["cal.com", "cal.dev", "cal-staging.com", "cal.community", "cal.local:3000", "localhost:3000"],
});
Object.defineProperty(constants, "RESERVED_SUBDOMAINS", {
value: [
"app",
"auth",
"docs",
"design",
"console",
"go",
"status",
"api",
"saml",
"www",
"matrix",
"developer",
"cal",
"my",
"team",
"support",
"security",
"blog",
"learn",
"admin",
],
});
}
describe("Org Domains Utils", () => {
describe("getOrgDomainConfigFromHostname", () => {
it("should return a valid org domain", () => {
setupEnvs();
expect(getOrgDomainConfigFromHostname({ hostname: "acme.cal.com" })).toEqual({
currentOrgDomain: "acme",
isValidOrgDomain: true,
});
});
it("should return a non valid org domain", () => {
setupEnvs();
expect(getOrgDomainConfigFromHostname({ hostname: "app.cal.com" })).toEqual({
currentOrgDomain: null,
isValidOrgDomain: false,
});
});
it("should return a non valid org domain for localhost", () => {
setupEnvs();
expect(getOrgDomainConfigFromHostname({ hostname: "localhost:3000" })).toEqual({
currentOrgDomain: null,
isValidOrgDomain: false,
});
});
});
describe("getOrgSlug", () => {
it("should handle a prod web app url with a prod subdomain hostname", () => {
setupEnvs();
expect(getOrgSlug("acme.cal.com")).toEqual("acme");
});
it("should handle a prod web app url with a staging subdomain hostname", () => {
setupEnvs();
expect(getOrgSlug("acme.cal.dev")).toEqual(null);
});
it("should handle a local web app with port url with a local subdomain hostname", () => {
setupEnvs({ WEBAPP_URL: "http://app.cal.local:3000" });
expect(getOrgSlug("acme.cal.local:3000")).toEqual("acme");
});
it("should handle a local web app with port url with a non-local subdomain hostname", () => {
setupEnvs({ WEBAPP_URL: "http://app.cal.local:3000" });
expect(getOrgSlug("acme.cal.com:3000")).toEqual(null);
});
});
describe("getOrgFullOrigin", () => {
it("should return the regular(non-org) origin if slug is null", () => {
setupEnvs({
WEBAPP_URL: "https://app.cal.com",
WEBSITE_URL: "https://abc.com",
});
expect(getOrgFullOrigin(null)).toEqual("https://abc.com");
});
it("should return the org origin if slug is set", () => {
setupEnvs({
WEBAPP_URL: "https://app.cal-app.com",
WEBSITE_URL: "https://cal.com",
});
// We are supposed to use WEBAPP_URL to derive the origin from and not WEBSITE_URL
expect(getOrgFullOrigin("org")).toEqual("https://org.cal-app.com");
});
});
});

View File

@@ -0,0 +1,161 @@
import type { Prisma } from "@prisma/client";
import type { IncomingMessage } from "http";
import { IS_PRODUCTION, WEBSITE_URL } from "@calcom/lib/constants";
import { ALLOWED_HOSTNAMES, RESERVED_SUBDOMAINS, WEBAPP_URL } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import slugify from "@calcom/lib/slugify";
const log = logger.getSubLogger({
prefix: ["orgDomains.ts"],
});
/**
* return the org slug
* @param hostname
*/
export function getOrgSlug(hostname: string, forcedSlug?: string) {
if (forcedSlug) {
if (process.env.NEXT_PUBLIC_IS_E2E) {
log.debug("Using provided forcedSlug in E2E", {
forcedSlug,
});
return forcedSlug;
}
log.debug("Ignoring forcedSlug in non-test mode", {
forcedSlug,
});
}
if (!hostname.includes(".")) {
log.warn('Org support not enabled for hostname without "."', { hostname });
// A no-dot domain can never be org domain. It automatically considers localhost to be non-org domain
return null;
}
// Find which hostname is being currently used
const currentHostname = ALLOWED_HOSTNAMES.find((ahn) => {
const url = new URL(WEBAPP_URL);
const testHostname = `${url.hostname}${url.port ? `:${url.port}` : ""}`;
return testHostname.endsWith(`.${ahn}`);
});
if (!currentHostname) {
log.warn("Match of WEBAPP_URL with ALLOWED_HOSTNAME failed", { WEBAPP_URL, ALLOWED_HOSTNAMES });
return null;
}
// Define which is the current domain/subdomain
const slug = hostname.replace(`.${currentHostname}` ?? "", "");
const hasNoDotInSlug = slug.indexOf(".") === -1;
if (hasNoDotInSlug) {
return slug;
}
log.warn("Derived slug ended up having dots, so not considering it an org domain", { slug });
return null;
}
export function orgDomainConfig(req: IncomingMessage | undefined, fallback?: string | string[]) {
const forPlatform = isPlatformRequest(req);
const forcedSlugHeader = req?.headers?.["x-cal-force-slug"];
const forcedSlug = forcedSlugHeader instanceof Array ? forcedSlugHeader[0] : forcedSlugHeader;
if (forPlatform && forcedSlug) {
return {
isValidOrgDomain: true,
currentOrgDomain: forcedSlug,
};
}
const hostname = req?.headers?.host || "";
return getOrgDomainConfigFromHostname({
hostname,
fallback,
forcedSlug,
});
}
function isPlatformRequest(req: IncomingMessage | undefined) {
return !!req?.headers?.["x-cal-client-id"];
}
export function getOrgDomainConfigFromHostname({
hostname,
fallback,
forcedSlug,
}: {
hostname: string;
fallback?: string | string[];
forcedSlug?: string;
}) {
const currentOrgDomain = getOrgSlug(hostname, forcedSlug);
const isValidOrgDomain = currentOrgDomain !== null && !RESERVED_SUBDOMAINS.includes(currentOrgDomain);
if (isValidOrgDomain || !fallback) {
return {
currentOrgDomain: isValidOrgDomain ? currentOrgDomain : null,
isValidOrgDomain,
};
}
const fallbackOrgSlug = fallback as string;
const isValidFallbackDomain = !RESERVED_SUBDOMAINS.includes(fallbackOrgSlug);
return {
currentOrgDomain: isValidFallbackDomain ? fallbackOrgSlug : null,
isValidOrgDomain: isValidFallbackDomain,
};
}
export function subdomainSuffix() {
if (!IS_PRODUCTION && process.env.LOCAL_TESTING_DOMAIN_VERCEL) {
// Allow testing with a valid domain so that we can test with deployment services like Vercel and Cloudflare locally.
return process.env.LOCAL_TESTING_DOMAIN_VERCEL;
}
const urlSplit = WEBAPP_URL.replace("https://", "")?.replace("http://", "").split(".");
return urlSplit.length === 3 ? urlSplit.slice(1).join(".") : urlSplit.join(".");
}
export function getOrgFullOrigin(slug: string | null, options: { protocol: boolean } = { protocol: true }) {
if (!slug)
return options.protocol ? WEBSITE_URL : WEBSITE_URL.replace("https://", "").replace("http://", "");
const orgFullOrigin = `${
options.protocol ? `${new URL(WEBSITE_URL).protocol}//` : ""
}${slug}.${subdomainSuffix()}`;
return orgFullOrigin;
}
/**
* @deprecated You most probably intend to query for an organization only, use `whereClauseForOrgWithSlugOrRequestedSlug` instead which will only return the organization and not a team accidentally.
*/
export function getSlugOrRequestedSlug(slug: string) {
const slugifiedValue = slugify(slug);
return {
OR: [
{ slug: slugifiedValue },
{
metadata: {
path: ["requestedSlug"],
equals: slugifiedValue,
},
},
],
} satisfies Prisma.TeamWhereInput;
}
export function whereClauseForOrgWithSlugOrRequestedSlug(slug: string) {
const slugifiedValue = slugify(slug);
return {
OR: [
{ slug: slugifiedValue },
{
metadata: {
path: ["requestedSlug"],
equals: slug,
},
},
],
isOrganization: true,
} satisfies Prisma.TeamWhereInput;
}
export function userOrgQuery(req: IncomingMessage | undefined, fallback?: string | string[]) {
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req, fallback);
return isValidOrgDomain && currentOrgDomain ? getSlugOrRequestedSlug(currentOrgDomain) : null;
}

View File

@@ -0,0 +1,6 @@
export interface NewOrganizationFormValues {
name: string;
slug: string;
logo: string;
adminEmail: string;
}

View File

@@ -0,0 +1,8 @@
export function extractDomainFromEmail(email: string) {
let out = "";
try {
const match = email.match(/^(?:.*?:\/\/)?.*?(?<root>[\w\-]*(?:\.\w{2,}|\.\w{2,}\.\w{2}))(?:[\/?#:]|$)/);
out = (match && match.groups?.root) ?? "";
} catch (ignore) {}
return out.split(".")[0];
}

View File

@@ -0,0 +1,144 @@
import { useState } from "react";
import { useForm } from "react-hook-form";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc";
import { trpc } from "@calcom/trpc";
import {
showToast,
Form,
SettingsToggle,
Dialog,
DialogContent,
DialogHeader,
DialogFooter,
DialogClose,
Button,
RadioGroup as RadioArea,
} from "@calcom/ui";
enum CurrentEventTypeOptions {
DELETE = "DELETE",
HIDE = "HIDE",
}
interface GeneralViewProps {
currentOrg: RouterOutputs["viewer"]["organizations"]["listCurrent"];
isAdminOrOwner: boolean;
}
interface FormValues {
currentEventTypeOptions: CurrentEventTypeOptions;
}
export const LockEventTypeSwitch = ({ currentOrg, isAdminOrOwner }: GeneralViewProps) => {
const [lockEventTypeCreationForUsers, setLockEventTypeCreationForUsers] = useState(
!!currentOrg.organizationSettings.lockEventTypeCreationForUsers
);
const [showModal, setShowModal] = useState(false);
const { t } = useLocale();
const mutation = trpc.viewer.organizations.update.useMutation({
onSuccess: async () => {
reset(getValues());
showToast(t("settings_updated_successfully"), "success");
},
onError: () => {
showToast(t("error_updating_settings"), "error");
},
});
const formMethods = useForm<FormValues>({
defaultValues: {
currentEventTypeOptions: CurrentEventTypeOptions.HIDE,
},
});
if (!isAdminOrOwner) return null;
const currentLockedOption = formMethods.watch("currentEventTypeOptions");
const { reset, getValues } = formMethods;
const onSubmit = (values: FormValues) => {
mutation.mutate({
lockEventTypeCreation: lockEventTypeCreationForUsers,
lockEventTypeCreationOptions: values.currentEventTypeOptions,
});
setShowModal(false);
};
return (
<>
<SettingsToggle
toggleSwitchAtTheEnd={true}
title={t("lock_org_users_eventtypes")}
disabled={mutation?.isPending || !isAdminOrOwner}
description={t("lock_org_users_eventtypes_description")}
checked={lockEventTypeCreationForUsers}
onCheckedChange={(checked) => {
if (!checked) {
mutation.mutate({
lockEventTypeCreation: checked,
});
} else {
setShowModal(true);
}
setLockEventTypeCreationForUsers(checked);
}}
switchContainerClassName="mt-6"
/>
{showModal && (
<Dialog
open={showModal}
onOpenChange={(e) => {
if (!e) {
setLockEventTypeCreationForUsers(
!!currentOrg.organizationSettings.lockEventTypeCreationForUsers
);
setShowModal(false);
}
}}>
<DialogContent enableOverflow>
<Form form={formMethods} handleSubmit={onSubmit}>
<div className="flex flex-row space-x-3">
<div className="w-full pt-1">
<DialogHeader title={t("lock_event_types_modal_header")} />
<RadioArea.Group
id="currentEventTypeOptions"
onValueChange={(val: CurrentEventTypeOptions) => {
formMethods.setValue("currentEventTypeOptions", val);
}}
className={classNames("min-h-24 mt-1 flex flex-col gap-4")}>
<RadioArea.Item
checked={currentLockedOption === CurrentEventTypeOptions.HIDE}
value={CurrentEventTypeOptions.HIDE}
className={classNames("h-full text-sm")}>
<strong className="mb-1 block">{t("hide_org_eventtypes")}</strong>
<p>{t("org_hide_event_types_org_admin")}</p>
</RadioArea.Item>
<RadioArea.Item
checked={currentLockedOption === CurrentEventTypeOptions.DELETE}
value={CurrentEventTypeOptions.DELETE}
className={classNames("[&:has(input:checked)]:border-error h-full text-sm")}>
<strong className="mb-1 block">{t("delete_org_eventtypes")}</strong>
<p>{t("org_delete_event_types_org_admin")}</p>
</RadioArea.Item>
</RadioArea.Group>
<DialogFooter>
<DialogClose />
<Button disabled={!isAdminOrOwner} type="submit">
{t("submit")}
</Button>
</DialogFooter>
</div>
</div>
</Form>
</DialogContent>
</Dialog>
)}
</>
);
};

View File

@@ -0,0 +1,105 @@
import classNames from "classnames";
import TeamPill, { TeamRole } from "@calcom/ee/teams/components/TeamPill";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
import {
Button,
ButtonGroup,
Dropdown,
DropdownItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Tooltip,
UserAvatar,
} from "@calcom/ui";
interface Props {
member: RouterOutputs["viewer"]["organizations"]["listOtherTeamMembers"]["rows"][number];
}
export default function MemberListItem(props: Props) {
const { t } = useLocale();
const { member } = props;
const { user } = member;
const name = user.name || user.username || user.email;
const bookerUrl = props.member.bookerUrl;
const bookerUrlWithoutProtocol = bookerUrl.replace(/^https?:\/\//, "");
const bookingLink = user.username && `${bookerUrlWithoutProtocol}/${user.username}`;
return (
<li className="divide-subtle divide-y px-5">
<div className="my-4 flex justify-between">
<div className="flex w-full flex-col justify-between overflow-hidden sm:flex-row">
<div className="flex">
<UserAvatar noOrganizationIndicator size="sm" user={user} className="h-10 w-10 rounded-full" />
<div className="ms-3 inline-block overflow-hidden">
<div className="mb-1 flex">
<span className="text-default mr-1 text-sm font-bold leading-4">{name}</span>
{!props.member.accepted && <TeamPill color="orange" text={t("pending")} />}
{props.member.role && <TeamRole role={props.member.role} />}
</div>
<div className="text-default flex items-center">
<span className=" block text-sm" data-testid="member-email" data-email={user.email}>
{user.email}
</span>
{user.username != null && (
<>
<span className="text-default mx-2 block"></span>
<a
target="_blank"
href={`${bookerUrl}/${user.username}`}
className="text-default block truncate text-sm">
{bookingLink}
</a>
</>
)}
</div>
</div>
</div>
</div>
{member.accepted && user.username && (
<div className="flex items-center justify-center">
<ButtonGroup combined containerProps={{ className: "border-default hidden md:flex" }}>
<Tooltip content={t("view_public_page")}>
<Button
target="_blank"
href={`${bookerUrl}/${user.username}`}
color="secondary"
className={classNames("rounded-r-md")}
variant="icon"
StartIcon="external-link"
disabled={!member.accepted}
/>
</Tooltip>
</ButtonGroup>
<div className="flex md:hidden">
<Dropdown>
<DropdownMenuTrigger asChild>
<Button type="button" variant="icon" color="minimal" StartIcon="ellipsis" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem className="outline-none">
<DropdownItem
disabled={!member.accepted}
href={`/${user.username}`}
target="_blank"
type="button"
StartIcon="external-link">
{t("view_public_page")}
</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</div>
</div>
)}
</div>
</li>
);
}

View File

@@ -0,0 +1,52 @@
import { useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc";
import { trpc } from "@calcom/trpc";
import { SettingsToggle, showToast } from "@calcom/ui";
interface GeneralViewProps {
currentOrg: RouterOutputs["viewer"]["organizations"]["listCurrent"];
isAdminOrOwner: boolean;
}
export const NoSlotsNotificationSwitch = ({ currentOrg, isAdminOrOwner }: GeneralViewProps) => {
const { t } = useLocale();
const utils = trpc.useUtils();
const [notificationActive, setNotificationActive] = useState(
!!currentOrg.organizationSettings.adminGetsNoSlotsNotification
);
const mutation = trpc.viewer.organizations.update.useMutation({
onSuccess: async () => {
showToast(t("settings_updated_successfully"), "success");
},
onError: () => {
showToast(t("error_updating_settings"), "error");
},
onSettled: () => {
utils.viewer.organizations.listCurrent.invalidate();
},
});
if (!isAdminOrOwner) return null;
return (
<>
<SettingsToggle
toggleSwitchAtTheEnd={true}
title={t("organization_no_slots_notification_switch_title")}
disabled={mutation?.isPending}
description={t("organization_no_slots_notification_switch_description")}
checked={notificationActive}
onCheckedChange={(checked) => {
mutation.mutate({
adminGetsNoSlotsNotification: checked,
});
setNotificationActive(checked);
}}
switchContainerClassName="mt-6"
/>
</>
);
};

View File

@@ -0,0 +1,56 @@
import { useState } from "react";
import { trackFormbricksAction } from "@calcom/lib/formbricks-client";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import { showToast } from "@calcom/ui";
import OtherTeamListItem from "./OtherTeamListItem";
interface Props {
teams: RouterOutputs["viewer"]["organizations"]["listOtherTeams"];
pending?: boolean;
}
export default function OtherTeamList(props: Props) {
const utils = trpc.useUtils();
const [hideDropdown, setHideDropdown] = useState(false);
function selectAction(action: string, teamId: number) {
switch (action) {
case "disband":
deleteTeam(teamId);
break;
}
}
const deleteTeamMutation = trpc.viewer.organizations.deleteTeam.useMutation({
async onSuccess() {
await utils.viewer.organizations.listOtherTeams.invalidate();
trackFormbricksAction("team_disbanded");
},
async onError(err) {
showToast(err.message, "error");
},
});
function deleteTeam(teamId: number) {
deleteTeamMutation.mutate({ teamId });
}
return (
<ul className="bg-default divide-subtle border-subtle mb-2 divide-y overflow-hidden rounded-md border">
{props.teams.map((team) => (
<OtherTeamListItem
key={team?.id as number}
team={team}
onActionSelect={(action: string) => selectAction(action, team?.id as number)}
isPending={deleteTeamMutation.isPending}
hideDropdown={hideDropdown}
setHideDropdown={setHideDropdown}
/>
))}
</ul>
);
}

View File

@@ -0,0 +1,158 @@
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
import {
Avatar,
Button,
ButtonGroup,
ConfirmationDialogContent,
Dialog,
DialogTrigger,
Dropdown,
DropdownItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
showToast,
Tooltip,
} from "@calcom/ui";
import { useOrgBranding } from "../../../organizations/context/provider";
interface Props {
team: RouterOutputs["viewer"]["organizations"]["listOtherTeams"][number];
key: number;
onActionSelect: (text: string) => void;
isPending?: boolean;
hideDropdown: boolean;
setHideDropdown: (value: boolean) => void;
}
export default function OtherTeamListItem(props: Props) {
const { t } = useLocale();
const team = props.team;
const orgBranding = useOrgBranding();
const { hideDropdown, setHideDropdown } = props;
if (!team) return <></>;
const teamInfo = (
<div className="item-center flex px-5 py-5">
<Avatar
size="md"
imageSrc={getPlaceholderAvatar(team.logoUrl, team.name)}
alt="Team Logo"
className="inline-flex justify-center"
/>
<div className="ms-3 inline-block truncate">
<span className="text-default text-sm font-bold">{team.name}</span>
<span className="text-muted block text-xs">
{team.slug
? orgBranding
? `${orgBranding.fullDomain}/${team.slug}`
: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/team/${team.slug}`
: "Unpublished team"}
</span>
</div>
</div>
);
return (
<li>
<div className="hover:bg-muted group flex items-center justify-between">
{teamInfo}
<div className="px-5 py-5">
<div className="flex space-x-2 rtl:space-x-reverse">
<ButtonGroup combined>
{team.slug && (
<Tooltip content={t("copy_link_team")}>
<Button
color="secondary"
onClick={() => {
navigator.clipboard.writeText(
`${
orgBranding
? `${orgBranding.fullDomain}`
: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/team`
}/${team.slug}`
);
showToast(t("link_copied"), "success");
}}
variant="icon"
StartIcon="link"
/>
</Tooltip>
)}
<Dropdown>
<DropdownMenuTrigger asChild>
<Button
className="radix-state-open:rounded-r-md"
type="button"
color="secondary"
variant="icon"
StartIcon="ellipsis"
/>
</DropdownMenuTrigger>
<DropdownMenuContent hidden={hideDropdown}>
<DropdownMenuItem>
<DropdownItem
type="button"
href={`/settings/teams/other/${team.id}/profile`}
StartIcon="pencil">
{t("edit_team") as string}
</DropdownItem>
</DropdownMenuItem>
{team.slug && (
<DropdownMenuItem>
<DropdownItem
type="button"
target="_blank"
href={`${
orgBranding
? `${orgBranding.fullDomain}`
: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/team/other`
}/${team.slug}`}
StartIcon="external-link">
{t("preview_team") as string}
</DropdownItem>
</DropdownMenuItem>
)}
<DropdownMenuItem>
<Dialog open={hideDropdown} onOpenChange={setHideDropdown}>
<DialogTrigger asChild>
<DropdownItem
color="destructive"
type="button"
StartIcon="trash"
onClick={(e) => {
e.stopPropagation();
}}>
{t("disband_team")}
</DropdownItem>
</DialogTrigger>
<ConfirmationDialogContent
variety="danger"
title={t("disband_team")}
confirmBtnText={t("confirm_disband_team")}
isPending={props.isPending}
onConfirm={() => {
props.onActionSelect("disband");
}}>
{t("disband_team_confirmation_message")}
</ConfirmationDialogContent>
</Dialog>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</ButtonGroup>
</div>
</div>
</div>
</li>
);
}

View File

@@ -0,0 +1,32 @@
import SkeletonLoaderTeamList from "@calcom/ee/teams/components/SkeletonloaderTeamList";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Alert, EmptyScreen } from "@calcom/ui";
import OtherTeamList from "./OtherTeamList";
export function OtherTeamsListing() {
const { t } = useLocale();
const { data: teams, isPending, error } = trpc.viewer.organizations.listOtherTeams.useQuery();
if (isPending) {
return <SkeletonLoaderTeamList />;
}
return (
<>
{!!error && <Alert severity="error" title={error.message} />}
{teams && teams.length > 0 ? (
<OtherTeamList teams={teams} />
) : (
<EmptyScreen
headline={t("no_other_teams_found")}
title={t("no_other_teams_found")}
description={t("no_other_teams_found_description")}
/>
)}
</>
);
}

View File

@@ -0,0 +1,46 @@
import type { GetServerSidePropsContext } from "next";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
import { MembershipRole } from "@calcom/prisma/client";
export const getServerSideProps = async ({ req, res }: GetServerSidePropsContext) => {
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
const organizationsEnabled = await getFeatureFlag(prisma, "organizations");
// Check if organizations are enabled
if (!organizationsEnabled) {
return {
notFound: true,
};
}
// Check if logged in user has an organization assigned
const session = await getServerSession({ req, res });
if (!session?.user.profile?.organizationId) {
return {
notFound: true,
};
}
// Check if logged in user has OWNER/ADMIN role in organization
const membership = await prisma.membership.findFirst({
where: {
userId: session?.user.id,
teamId: session?.user.profile.organizationId,
},
select: {
role: true,
},
});
if (!membership?.role || membership?.role === MembershipRole.MEMBER) {
return {
notFound: true,
};
}
// Otherwise, all good
return {
props: {},
};
};

View File

@@ -0,0 +1,64 @@
"use client";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { UpgradeTip } from "@calcom/features/tips";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, Icon, Meta, ButtonGroup } from "@calcom/ui";
const AdminAPIViewWrapper = () => {
const { t } = useLocale();
const features = [
{
icon: <Icon name="terminal" className="h-5 w-5 text-pink-500" />,
title: t("admin_api"),
description: t("leverage_our_api"),
},
{
icon: <Icon name="folder" className="h-5 w-5 text-red-500" />,
title: `SCIM & ${t("directory_sync")}`,
description: t("directory_sync_description"),
},
{
icon: <Icon name="sparkles" className="h-5 w-5 text-blue-500" />,
title: "Cal.ai",
description: t("use_cal_ai_to_make_call_description"),
},
];
return (
<LicenseRequired>
<Meta
title={`${t("admin")} ${t("api_reference")}`}
description={t("leverage_our_api")}
borderInShellHeader={false}
/>
<div className="mt-8">
<UpgradeTip
plan="enterprise"
title={t("enterprise_license")}
description={t("create_your_enterprise_description")}
features={features}
background="/tips/enterprise"
buttons={
<div className="space-y-2 rtl:space-x-reverse sm:space-x-2">
<ButtonGroup>
<Button color="primary" href="https://i.cal.com/sales/enterprise" target="_blank">
{t("contact_sales")}
</Button>
<Button color="minimal" href="https://cal.com/enterprise" target="_blank">
{t("learn_more")}
</Button>
</ButtonGroup>
</div>
}>
<>Create Org</>
</UpgradeTip>
</div>
</LicenseRequired>
);
};
AdminAPIViewWrapper.getLayout = getLayout;
export default AdminAPIViewWrapper;

View File

@@ -0,0 +1,113 @@
import type { Team } from "@prisma/client";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { z } from "zod";
import NoSSR from "@calcom/core/components/NoSSR";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
import type { orgSettingsSchema } from "@calcom/prisma/zod-utils";
import { trpc } from "@calcom/trpc/react";
import { Button, Form, Meta, TextField, showToast } from "@calcom/ui";
import { getLayout } from "../../../../../settings/layouts/SettingsLayout";
import LicenseRequired from "../../../../common/components/LicenseRequired";
const paramsSchema = z.object({ id: z.coerce.number() });
const OrgEditPage = () => {
const params = useParamsWithFallback();
const parsedParams = paramsSchema.safeParse(params);
if (!parsedParams.success) return <div>Invalid id</div>;
return <OrgEditView orgId={parsedParams.data.id} />;
};
const OrgEditView = ({ orgId }: { orgId: number }) => {
const [org] = trpc.viewer.organizations.adminGet.useSuspenseQuery({ id: orgId });
return (
<LicenseRequired>
<Meta
title={`Editing organization: ${org.name}`}
description="Here you can edit a current organization."
/>
<NoSSR>
<OrgForm org={org} />
</NoSSR>
</LicenseRequired>
);
};
type FormValues = {
name: Team["name"];
slug: Team["slug"];
organizationSettings: z.infer<typeof orgSettingsSchema>;
};
const OrgForm = ({
org,
}: {
org: FormValues & {
id: Team["id"];
};
}) => {
const { t } = useLocale();
const router = useRouter();
const utils = trpc.useUtils();
const mutation = trpc.viewer.organizations.adminUpdate.useMutation({
onSuccess: async () => {
await Promise.all([
utils.viewer.organizations.adminGetAll.invalidate(),
utils.viewer.organizations.adminGet.invalidate({
id: org.id,
}),
]);
showToast(t("org_has_been_processed"), "success");
router.replace(`/settings/admin/organizations`);
},
onError: (err) => {
showToast(err.message, "error");
},
});
const form = useForm<FormValues>({
defaultValues: org,
});
const onSubmit = (values: FormValues) => {
mutation.mutate({
id: org.id,
...values,
organizationSettings: {
...org.organizationSettings,
orgAutoAcceptEmail: values.organizationSettings?.orgAutoAcceptEmail,
},
});
};
return (
<Form form={form} className="space-y-4" handleSubmit={onSubmit}>
<TextField label="Name" placeholder="example" required {...form.register("name")} />
<TextField label="Slug" placeholder="example" required {...form.register("slug")} />
<p className="text-default mt-2 text-sm">
Changing the slug would delete the previous organization domain and DNS and setup new domain and DNS
for the organization.
</p>
<TextField
label="Domain for which invitations are auto-accepted"
placeholder="abc.com"
required
{...form.register("organizationSettings.orgAutoAcceptEmail")}
/>
<Button type="submit" color="primary" loading={mutation.isPending}>
{t("save")}
</Button>
</Form>
);
};
OrgEditPage.getLayout = getLayout;
export default OrgEditPage;

View File

@@ -0,0 +1,289 @@
"use client";
import { Trans } from "next-i18next";
import { useState } from "react";
import NoSSR from "@calcom/core/components/NoSSR";
import LicenseRequired from "@calcom/ee/common/components/LicenseRequired";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import {
Badge,
ConfirmationDialogContent,
Dialog,
DropdownActions,
Meta,
showToast,
Table,
} from "@calcom/ui";
import { getLayout } from "../../../../../settings/layouts/SettingsLayout";
import { subdomainSuffix } from "../../../../organizations/lib/orgDomains";
const { Body, Cell, ColumnTitle, Header, Row } = Table;
function AdminOrgTable() {
const { t } = useLocale();
const utils = trpc.useUtils();
const [data] = trpc.viewer.organizations.adminGetAll.useSuspenseQuery();
const verifyMutation = trpc.viewer.organizations.adminVerify.useMutation({
onSuccess: async (_data, variables) => {
showToast(t("org_has_been_processed"), "success");
await invalidateQueries(utils, variables);
},
onError: (err) => {
console.error(err.message);
showToast(t("org_error_processing"), "error");
},
});
const updateMutation = trpc.viewer.organizations.adminUpdate.useMutation({
onSuccess: async (_data, variables) => {
showToast(t("org_has_been_processed"), "success");
await invalidateQueries(utils, {
orgId: variables.id,
});
},
onError: (err) => {
showToast(err.message, "error");
},
});
const deleteMutation = trpc.viewer.organizations.adminDelete.useMutation({
onSuccess: async (res, variables) => {
showToast(res.message, "success");
await invalidateQueries(utils, variables);
},
onError: (err) => {
console.error(err.message);
showToast(t("org_error_processing"), "error");
},
});
const publishOrg = async (org: (typeof data)[number]) => {
if (!org.metadata?.requestedSlug) {
showToast(t("org_publish_error"), "error");
console.error("metadata.requestedSlug isn't set", org.metadata?.requestedSlug);
return;
}
updateMutation.mutate({
id: org.id,
slug: org.metadata.requestedSlug,
});
};
const [orgToDelete, setOrgToDelete] = useState<(typeof data)[number] | null>(null);
return (
<div>
<Table>
<Header>
<ColumnTitle widthClassNames="w-auto">{t("organization")}</ColumnTitle>
<ColumnTitle widthClassNames="w-auto">{t("owner")}</ColumnTitle>
<ColumnTitle widthClassNames="w-auto">{t("reviewed")}</ColumnTitle>
<ColumnTitle widthClassNames="w-auto">{t("dns_configured")}</ColumnTitle>
<ColumnTitle widthClassNames="w-auto">{t("published")}</ColumnTitle>
<ColumnTitle widthClassNames="w-auto">
<span className="sr-only">{t("edit")}</span>
</ColumnTitle>
</Header>
<Body>
{data.map((org) => (
<Row key={org.id}>
<Cell widthClassNames="w-auto">
<div className="text-subtle font-medium">
<span className="text-default">{org.name}</span>
<br />
<span className="text-muted">
{org.slug}.{subdomainSuffix()}
</span>
</div>
</Cell>
<Cell widthClassNames="w-auto">
<span className="break-all">
{org.members.length ? org.members[0].user.email : "No members"}
</span>
</Cell>
<Cell>
<div className="space-x-2">
{!org.organizationSettings?.isAdminReviewed ? (
<Badge variant="red">{t("unreviewed")}</Badge>
) : (
<Badge variant="green">{t("reviewed")}</Badge>
)}
</div>
</Cell>
<Cell>
<div className="space-x-2">
{org.organizationSettings?.isOrganizationConfigured ? (
<Badge variant="blue">{t("dns_configured")}</Badge>
) : (
<Badge variant="red">{t("dns_missing")}</Badge>
)}
</div>
</Cell>
<Cell>
<div className="space-x-2">
{!org.slug ? (
<Badge variant="red">{t("unpublished")}</Badge>
) : (
<Badge variant="green">{t("published")}</Badge>
)}
</div>
</Cell>
<Cell widthClassNames="w-auto">
<div className="flex w-full justify-end">
<DropdownActions
actions={[
...(!org.organizationSettings?.isAdminReviewed
? [
{
id: "review",
label: t("review"),
onClick: () => {
updateMutation.mutate({
id: org.id,
organizationSettings: {
isAdminReviewed: true,
},
});
},
icon: "check" as const,
},
]
: []),
...(!org.organizationSettings?.isOrganizationConfigured
? [
{
id: "dns",
label: t("mark_dns_configured"),
onClick: () => {
updateMutation.mutate({
id: org.id,
organizationSettings: {
isOrganizationConfigured: true,
},
});
},
icon: "check-check" as const,
},
]
: []),
{
id: "edit",
label: t("edit"),
href: `/settings/admin/organizations/${org.id}/edit`,
icon: "pencil" as const,
},
...(!org.slug
? [
{
id: "publish",
label: t("publish"),
onClick: () => {
publishOrg(org);
},
icon: "book-open-check" as const,
},
]
: []),
{
id: "delete",
label: t("delete"),
onClick: () => {
setOrgToDelete(org);
},
icon: "trash" as const,
},
]}
/>
</div>
</Cell>
</Row>
))}
</Body>
</Table>
<DeleteOrgDialog
org={orgToDelete}
onClose={() => setOrgToDelete(null)}
onConfirm={() => {
if (!orgToDelete) return;
deleteMutation.mutate({
orgId: orgToDelete.id,
});
}}
/>
</div>
);
}
const AdminOrgList = () => {
const { t } = useLocale();
return (
<LicenseRequired>
<Meta title={t("organizations")} description={t("orgs_page_description")} />
<NoSSR>
<AdminOrgTable />
</NoSSR>
</LicenseRequired>
);
};
AdminOrgList.getLayout = getLayout;
export default AdminOrgList;
const DeleteOrgDialog = ({
org,
onConfirm,
onClose,
}: {
org: {
id: number;
name: string;
} | null;
onConfirm: () => void;
onClose: () => void;
}) => {
const { t } = useLocale();
if (!org) {
return null;
}
return (
// eslint-disable-next-line @typescript-eslint/no-empty-function -- noop
<Dialog name="delete-user" open={!!org.id} onOpenChange={(open) => (open ? () => {} : onClose())}>
<ConfirmationDialogContent
title={t("admin_delete_organization_title", {
organizationName: org.name,
})}
confirmBtnText={t("delete")}
cancelBtnText={t("cancel")}
variety="danger"
onConfirm={onConfirm}>
<Trans
i18nKey="admin_delete_organization_description"
components={{ li: <li />, ul: <ul className="ml-4 mt-5 list-disc space-y-2" /> }}>
<ul>
<li>
Teams that are member of this organization will also be deleted along with their event-types
</li>
<li>
Users that were part of the organization will not be deleted and their event-types will also
remain intact.
</li>
<li>Usernames would be changed to allow them to exist outside the organization</li>
</ul>
</Trans>
</ConfirmationDialogContent>
</Dialog>
);
};
async function invalidateQueries(utils: ReturnType<typeof trpc.useUtils>, data: { orgId: number }) {
await utils.viewer.organizations.adminGetAll.invalidate();
await utils.viewer.organizations.adminGet.invalidate({
id: data.orgId,
});
// Due to some super weird reason, just invalidate doesn't work, so do refetch as well.
await utils.viewer.organizations.adminGet.refetch({
id: data.orgId,
});
}

View File

@@ -0,0 +1,181 @@
"use client";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useState, useEffect } from "react";
import { useForm } from "react-hook-form";
import BrandColorsForm from "@calcom/features/ee/components/BrandColorsForm";
import { AppearanceSkeletonLoader } from "@calcom/features/ee/components/CommonSkeletonLoaders";
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
import ThemeLabel from "@calcom/features/settings/ThemeLabel";
import { DEFAULT_LIGHT_BRAND_COLOR, DEFAULT_DARK_BRAND_COLOR } from "@calcom/lib/constants";
import { APP_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { MembershipRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Button, Form, showToast, SettingsToggle } from "@calcom/ui";
type BrandColorsFormValues = {
brandColor: string;
darkBrandColor: string;
};
const OrgAppearanceView = ({
currentOrg,
}: {
currentOrg: RouterOutputs["viewer"]["organizations"]["listCurrent"];
isAdminOrOwner: boolean;
}) => {
const { t } = useLocale();
const utils = trpc.useUtils();
const themeForm = useForm<{ theme: string | null | undefined }>({
defaultValues: {
theme: currentOrg?.theme,
},
});
const {
formState: { isSubmitting: isOrgThemeSubmitting, isDirty: isOrgThemeDirty },
reset: resetOrgThemeReset,
} = themeForm;
const [hideBrandingValue, setHideBrandingValue] = useState(currentOrg?.hideBranding ?? false);
const brandColorsFormMethods = useForm<BrandColorsFormValues>({
defaultValues: {
brandColor: currentOrg?.brandColor || DEFAULT_LIGHT_BRAND_COLOR,
darkBrandColor: currentOrg?.darkBrandColor || DEFAULT_DARK_BRAND_COLOR,
},
});
const mutation = trpc.viewer.organizations.update.useMutation({
onError: (err) => {
showToast(err.message, "error");
},
async onSuccess(res) {
await utils.viewer.teams.get.invalidate();
await utils.viewer.organizations.listCurrent.invalidate();
showToast(t("your_team_updated_successfully"), "success");
if (res) {
brandColorsFormMethods.reset({
brandColor: res.data.brandColor as string,
darkBrandColor: res.data.darkBrandColor as string,
});
resetOrgThemeReset({ theme: res.data.theme as string | undefined });
}
},
});
const onBrandColorsFormSubmit = (values: BrandColorsFormValues) => {
mutation.mutate(values);
};
return (
<div>
<Form
form={themeForm}
handleSubmit={(value) => {
mutation.mutate({
theme: value.theme === "" ? null : value.theme,
});
}}>
<div className="border-subtle mt-6 flex items-center rounded-t-xl border p-6 text-sm">
<div>
<p className="text-default text-base font-semibold">{t("theme")}</p>
<p className="text-default">{t("theme_applies_note")}</p>
</div>
</div>
<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={currentOrg.theme === null}
register={themeForm.register}
/>
<ThemeLabel
variant="light"
value="light"
label={t("light")}
defaultChecked={currentOrg.theme === "light"}
register={themeForm.register}
/>
<ThemeLabel
variant="dark"
value="dark"
label={t("dark")}
defaultChecked={currentOrg.theme === "dark"}
register={themeForm.register}
/>
</div>
<SectionBottomActions className="mb-6" align="end">
<Button
disabled={isOrgThemeSubmitting || !isOrgThemeDirty}
type="submit"
data-testid="update-org-theme-btn"
color="primary">
{t("update")}
</Button>
</SectionBottomActions>
</Form>
<Form
form={brandColorsFormMethods}
handleSubmit={(values) => {
onBrandColorsFormSubmit(values);
}}>
<BrandColorsForm
onSubmit={onBrandColorsFormSubmit}
brandColor={currentOrg?.brandColor ?? DEFAULT_LIGHT_BRAND_COLOR}
darkBrandColor={currentOrg?.darkBrandColor ?? DEFAULT_DARK_BRAND_COLOR}
/>
</Form>
<SettingsToggle
toggleSwitchAtTheEnd={true}
title={t("disable_cal_branding", { appName: APP_NAME })}
disabled={mutation?.isPending}
description={t("removes_cal_branding", { appName: APP_NAME })}
checked={hideBrandingValue}
onCheckedChange={(checked) => {
setHideBrandingValue(checked);
mutation.mutate({ hideBranding: checked });
}}
switchContainerClassName="mt-6"
/>
</div>
);
};
const OrgAppearanceViewWrapper = () => {
const router = useRouter();
const { t } = useLocale();
const session = useSession();
const orgRole = session?.data?.user?.org?.role;
const { data: currentOrg, isPending, error } = trpc.viewer.organizations.listCurrent.useQuery();
useEffect(
function refactorMeWithoutEffect() {
if (error) {
router.replace("/enterprise");
}
},
[error]
);
if (isPending) {
return <AppearanceSkeletonLoader title={t("appearance")} description={t("appearance_org_description")} />;
}
if (!currentOrg) return null;
const isAdminOrOwner = orgRole === MembershipRole.OWNER || orgRole === MembershipRole.ADMIN;
return <OrgAppearanceView currentOrg={currentOrg} isAdminOrOwner={isAdminOrOwner} />;
};
export default OrgAppearanceViewWrapper;

View File

@@ -0,0 +1,230 @@
"use client";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { nameOfDay } from "@calcom/lib/weekday";
import { MembershipRole } from "@calcom/prisma/enums";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import {
Button,
Form,
Label,
Meta,
Select,
showToast,
SkeletonButton,
SkeletonContainer,
SkeletonText,
TimezoneSelect,
} from "@calcom/ui";
import { LockEventTypeSwitch } from "../components/LockEventTypeSwitch";
import { NoSlotsNotificationSwitch } from "../components/NoSlotsNotificationSwitch";
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
return (
<SkeletonContainer>
<Meta title={title} description={description} borderInShellHeader={true} />
<div className="mb-8 mt-6 space-y-6">
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
</div>
</SkeletonContainer>
);
};
interface GeneralViewProps {
currentOrg: RouterOutputs["viewer"]["organizations"]["listCurrent"];
isAdminOrOwner: boolean;
localeProp: string;
}
const OrgGeneralView = () => {
const { t } = useLocale();
const router = useRouter();
const session = useSession();
const orgRole = session?.data?.user?.org?.role;
const {
data: currentOrg,
isPending,
error,
} = trpc.viewer.organizations.listCurrent.useQuery(undefined, {});
const { data: user } = trpc.viewer.me.useQuery();
useEffect(
function refactorMeWithoutEffect() {
if (error) {
router.replace("/enterprise");
}
},
[error]
);
if (isPending) return <SkeletonLoader title={t("general")} description={t("general_description")} />;
if (!currentOrg) {
return null;
}
const isAdminOrOwner = orgRole === MembershipRole.OWNER || orgRole === MembershipRole.ADMIN;
return (
<LicenseRequired>
<GeneralView
currentOrg={currentOrg}
isAdminOrOwner={isAdminOrOwner}
localeProp={user?.locale ?? "en"}
/>
<LockEventTypeSwitch currentOrg={currentOrg} isAdminOrOwner={!!isAdminOrOwner} />
<NoSlotsNotificationSwitch currentOrg={currentOrg} isAdminOrOwner={!!isAdminOrOwner} />
</LicenseRequired>
);
};
const GeneralView = ({ currentOrg, isAdminOrOwner, localeProp }: GeneralViewProps) => {
const { t } = useLocale();
const mutation = trpc.viewer.organizations.update.useMutation({
onSuccess: async () => {
reset(getValues());
showToast(t("settings_updated_successfully"), "success");
},
onError: () => {
showToast(t("error_updating_settings"), "error");
},
});
const timeFormatOptions = [
{ value: 12, label: t("12_hour") },
{ value: 24, label: t("24_hour") },
];
const weekStartOptions = [
{ value: "Sunday", label: nameOfDay(localeProp, 0) },
{ value: "Monday", label: nameOfDay(localeProp, 1) },
{ value: "Tuesday", label: nameOfDay(localeProp, 2) },
{ value: "Wednesday", label: nameOfDay(localeProp, 3) },
{ value: "Thursday", label: nameOfDay(localeProp, 4) },
{ value: "Friday", label: nameOfDay(localeProp, 5) },
{ value: "Saturday", label: nameOfDay(localeProp, 6) },
];
const formMethods = useForm({
defaultValues: {
timeZone: currentOrg.timeZone || "",
timeFormat: {
value: currentOrg.timeFormat || 12,
label: timeFormatOptions.find((option) => option.value === currentOrg.timeFormat)?.label || 12,
},
weekStart: {
value: currentOrg.weekStart,
label:
weekStartOptions.find((option) => option.value === currentOrg.weekStart)?.label ||
nameOfDay(localeProp, 0),
},
},
});
const {
formState: { isDirty, isSubmitting },
reset,
getValues,
} = formMethods;
const isDisabled = isSubmitting || !isDirty || !isAdminOrOwner;
return (
<Form
form={formMethods}
handleSubmit={(values) => {
mutation.mutate({
...values,
timeFormat: values.timeFormat.value,
weekStart: values.weekStart.value,
});
}}>
<Meta
title={t("general")}
description={t("organization_general_description")}
borderInShellHeader={true}
/>
<div className="border-subtle border-x border-y-0 px-4 py-8 sm:px-6">
<Controller
name="timeZone"
control={formMethods.control}
render={({ field: { value } }) => (
<>
<Label className="text-emphasis">
<>{t("timezone")}</>
</Label>
<TimezoneSelect
id="timezone"
value={value}
onChange={(event) => {
if (event) formMethods.setValue("timeZone", event.value, { shouldDirty: true });
}}
/>
</>
)}
/>
<Controller
name="timeFormat"
control={formMethods.control}
render={({ field: { value } }) => (
<>
<Label className="text-emphasis mt-6">
<>{t("time_format")}</>
</Label>
<Select
value={value}
options={timeFormatOptions}
onChange={(event) => {
if (event) formMethods.setValue("timeFormat", { ...event }, { shouldDirty: true });
}}
/>
</>
)}
/>
<div className="text-gray text-default mt-2 flex items-center text-sm">
{t("timeformat_profile_hint")}
</div>
<Controller
name="weekStart"
control={formMethods.control}
render={({ field: { value } }) => (
<>
<Label className="text-emphasis mt-6">
<>{t("start_of_week")}</>
</Label>
<Select
value={value}
options={weekStartOptions}
onChange={(event) => {
if (event) formMethods.setValue("weekStart", { ...event }, { shouldDirty: true });
}}
/>
</>
)}
/>
</div>
<SectionBottomActions align="end">
<Button disabled={isDisabled} color="primary" type="submit">
{t("update")}
</Button>
</SectionBottomActions>
</Form>
);
};
OrgGeneralView.getLayout = getLayout;
export default OrgGeneralView;

View File

@@ -0,0 +1,36 @@
"use client";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { UserListTable } from "@calcom/features/users/components/UserTable/UserListTable";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { MembershipRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import { Meta } from "@calcom/ui";
const MembersView = () => {
const { t } = useLocale();
const { data: currentOrg, isPending } = trpc.viewer.organizations.listCurrent.useQuery();
const isOrgAdminOrOwner =
currentOrg &&
(currentOrg.user.role === MembershipRole.OWNER || currentOrg.user.role === MembershipRole.ADMIN);
const canLoggedInUserSeeMembers =
(currentOrg?.isPrivate && isOrgAdminOrOwner) || isOrgAdminOrOwner || !currentOrg?.isPrivate;
return (
<LicenseRequired>
<Meta title={t("organization_members")} description={t("organization_description")} />
<div>{!isPending && canLoggedInUserSeeMembers && <UserListTable />}</div>
{!canLoggedInUserSeeMembers && (
<div className="border-subtle rounded-xl border p-6" data-testId="members-privacy-warning">
<h2 className="text-default">{t("only_admin_can_see_members_of_org")}</h2>
</div>
)}
</LicenseRequired>
);
};
MembersView.getLayout = getLayout;
export default MembersView;

View File

@@ -0,0 +1,21 @@
"use client";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Meta } from "@calcom/ui";
import { getLayout } from "../../../../settings/layouts/SettingsLayout";
import { OtherTeamsListing } from "./../components/OtherTeamsListing";
const OtherTeamListingView = (): React.ReactElement => {
const { t } = useLocale();
return (
<>
<Meta title={t("org_admin_other_teams")} description={t("org_admin_other_teams_description")} />
<OtherTeamsListing />
</>
);
};
OtherTeamListingView.getLayout = getLayout;
export default OtherTeamListingView;

View File

@@ -0,0 +1,251 @@
"use client";
// import { debounce } from "lodash";
import { keepPreviousData } from "@tanstack/react-query";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useState, useEffect } from "react";
import MemberInvitationModal from "@calcom/ee/teams/components/MemberInvitationModal";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
import { MembershipRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Meta, showToast, Button } from "@calcom/ui";
import { getLayout } from "../../../../settings/layouts/SettingsLayout";
import MakeTeamPrivateSwitch from "../../../teams/components/MakeTeamPrivateSwitch";
import MemberListItem from "../components/MemberListItem";
type Members = RouterOutputs["viewer"]["organizations"]["listOtherTeamMembers"]["rows"];
type Team = RouterOutputs["viewer"]["organizations"]["getOtherTeam"];
interface MembersListProps {
members: Members | undefined;
team: Team | undefined;
fetchNextPage: () => void;
hasNextPage: boolean | undefined;
isFetchingNextPage: boolean | undefined;
}
function MembersList(props: MembersListProps) {
const { t } = useLocale();
const { hasNextPage, members = [], team, fetchNextPage, isFetchingNextPage } = props;
return (
<div className="flex flex-col gap-y-3">
{members?.length && team ? (
<ul className="divide-subtle border-subtle divide-y rounded-md border ">
{members.map((member) => {
return <MemberListItem key={member.id} member={member} />;
})}
</ul>
) : null}
{members?.length === 0 && (
<div className="flex flex-col items-center justify-center">
<p className="text-default text-sm font-bold">{t("no_members_found")}</p>
</div>
)}
<div className="text-default p-4 text-center">
<Button
color="minimal"
loading={isFetchingNextPage}
disabled={!hasNextPage}
onClick={() => fetchNextPage()}>
{hasNextPage ? t("load_more_results") : t("no_more_results")}
</Button>
</div>
</div>
);
}
const MembersView = () => {
const { t, i18n } = useLocale();
const router = useRouter();
const params = useParamsWithFallback();
const teamId = Number(params.id);
const session = useSession();
const utils = trpc.useUtils();
// const [query, setQuery] = useState<string | undefined>("");
// const [queryToFetch, setQueryToFetch] = useState<string | undefined>("");
const limit = 20;
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState<boolean>(false);
const { data: currentOrg } = trpc.viewer.organizations.listCurrent.useQuery(undefined, {
enabled: !!session.data?.user?.org,
});
const {
data: team,
isPending: isTeamLoading,
error: otherTeamError,
} = trpc.viewer.organizations.getOtherTeam.useQuery(
{ teamId },
{
enabled: !Number.isNaN(teamId),
}
);
const { data: orgMembersNotInThisTeam, isPending: isOrgListLoading } =
trpc.viewer.organizations.getMembers.useQuery(
{
teamIdToExclude: teamId,
distinctUser: true,
},
{
enabled: !Number.isNaN(teamId),
}
);
const {
fetchNextPage,
isFetchingNextPage,
hasNextPage,
error: otherMembersError,
data,
} = trpc.viewer.organizations.listOtherTeamMembers.useInfiniteQuery(
{ teamId, limit },
{
enabled: !Number.isNaN(teamId),
getNextPageParam: (lastPage) => lastPage.nextCursor,
placeholderData: keepPreviousData,
}
);
useEffect(
function refactorMeWithoutEffect() {
if (otherMembersError || otherTeamError) {
router.replace("/enterprise");
}
},
[router, otherMembersError, otherTeamError]
);
const isPending = isTeamLoading || isOrgListLoading;
const inviteMemberMutation = trpc.viewer.teams.inviteMember.useMutation({
onSuccess: () => {
utils.viewer.organizations.getMembers.invalidate();
utils.viewer.organizations.listOtherTeams.invalidate();
utils.viewer.teams.list.invalidate();
utils.viewer.organizations.listOtherTeamMembers.invalidate();
},
});
const isOrgAdminOrOwner =
currentOrg &&
(currentOrg.user.role === MembershipRole.OWNER || currentOrg.user.role === MembershipRole.ADMIN);
return (
<>
<Meta
title={t("team_members")}
description={t("members_team_description")}
CTA={
isOrgAdminOrOwner ? (
<Button
type="button"
color="primary"
StartIcon="plus"
className="ml-auto"
onClick={() => setShowMemberInvitationModal(true)}
data-testid="new-member-button">
{t("add")}
</Button>
) : (
<></>
)
}
/>
{!isPending && (
<>
<div>
<>
{/* Currently failing due to re render and loose focus */}
{/* <TextField
type="search"
autoComplete="false"
onChange={(e) => {
setQuery(e.target.value);
debouncedFunction(e.target.value);
}}
value={query}
placeholder={`${t("search")}...`}
/> */}
<MembersList
members={data?.pages?.flatMap((page) => page.rows) ?? []}
team={team}
fetchNextPage={fetchNextPage}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
/>
</>
{team && (
<>
<hr className="border-subtle my-8" />
<MakeTeamPrivateSwitch
teamId={team.id}
isPrivate={team.isPrivate}
disabled={false}
isOrg={false}
/>
</>
)}
</div>
{showMemberInvitationModal && team && (
<MemberInvitationModal
isPending={inviteMemberMutation.isPending}
isOpen={showMemberInvitationModal}
orgMembers={orgMembersNotInThisTeam}
teamId={team.id}
disableCopyLink={true}
onExit={() => setShowMemberInvitationModal(false)}
onSubmit={(values, resetFields) => {
inviteMemberMutation.mutate(
{
teamId,
language: i18n.language,
role: values.role,
usernameOrEmail: values.emailOrUsername,
},
{
onSuccess: async (data) => {
await utils.viewer.teams.get.invalidate();
setShowMemberInvitationModal(false);
if (Array.isArray(data.usernameOrEmail)) {
showToast(
t("email_invite_team_bulk", {
userCount: data.numUsersInvited,
}),
"success"
);
resetFields();
} else {
showToast(
t("email_invite_team", {
email: data.usernameOrEmail,
}),
"success"
);
}
},
onError: (error) => {
showToast(error.message, "error");
},
}
);
}}
onSettingsOpen={() => {
setShowMemberInvitationModal(false);
}}
/>
)}
</>
)}
</>
);
};
MembersView.getLayout = getLayout;
export default MembersView;

View File

@@ -0,0 +1,373 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import type { Prisma } from "@prisma/client";
import { useSession } from "next-auth/react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useLayoutEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { trackFormbricksAction } from "@calcom/lib/formbricks-client";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
import { md } from "@calcom/lib/markdownIt";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import objectKeys from "@calcom/lib/objectKeys";
import slugify from "@calcom/lib/slugify";
import turndown from "@calcom/lib/turndownService";
import { trpc } from "@calcom/trpc/react";
import {
Avatar,
Button,
ConfirmationDialogContent,
Dialog,
DialogTrigger,
Editor,
Form,
ImageUploader,
Label,
LinkIconButton,
Meta,
showToast,
SkeletonContainer,
SkeletonText,
TextField,
} from "@calcom/ui";
import { getLayout } from "../../../../settings/layouts/SettingsLayout";
import { subdomainSuffix } from "../../../organizations/lib/orgDomains";
const regex = new RegExp("^[a-zA-Z0-9-]*$");
const teamProfileFormSchema = z.object({
name: z.string(),
slug: z
.string()
.regex(regex, {
message: "Url can only have alphanumeric characters(a-z, 0-9) and hyphen(-) symbol.",
})
.min(1, { message: "Url cannot be left empty" }),
logoUrl: z.string().nullable(),
bio: z.string(),
});
const OtherTeamProfileView = () => {
const { t } = useLocale();
const router = useRouter();
const utils = trpc.useUtils();
const session = useSession();
const [firstRender, setFirstRender] = useState(true);
useLayoutEffect(() => {
document.body.focus();
}, []);
const mutation = trpc.viewer.teams.update.useMutation({
onError: (err) => {
showToast(err.message, "error");
},
async onSuccess() {
await utils.viewer.teams.get.invalidate();
showToast(t("your_team_updated_successfully"), "success");
},
});
const form = useForm({
resolver: zodResolver(teamProfileFormSchema),
});
const params = useParamsWithFallback();
const teamId = Number(params.id);
const {
data: team,
isPending,
error: teamError,
} = trpc.viewer.organizations.getOtherTeam.useQuery(
{ teamId: teamId },
{
enabled: !Number.isNaN(teamId),
}
);
useEffect(
function refactorMeWithoutEffect() {
if (teamError) {
router.replace("/enterprise");
}
},
[teamError]
);
useEffect(
function refactorMeWithoutEffect() {
if (team) {
form.setValue("name", team.name || "");
form.setValue("slug", team.slug || "");
form.setValue("logoUrl", team.logoUrl);
form.setValue("bio", team.bio || "");
if (team.slug === null && (team?.metadata as Prisma.JsonObject)?.requestedSlug) {
form.setValue("slug", ((team?.metadata as Prisma.JsonObject)?.requestedSlug as string) || "");
}
}
},
[team]
);
// This page can only be accessed by team admins (owner/admin)
const isAdmin = true;
const permalink = `${WEBAPP_URL}/team/${team?.slug}`;
const isBioEmpty = !team || !team.bio || !team.bio.replace("<p><br></p>", "").length;
const deleteTeamMutation = trpc.viewer.organizations.deleteTeam.useMutation({
async onSuccess() {
await utils.viewer.organizations.listOtherTeams.invalidate();
showToast(t("your_team_disbanded_successfully"), "success");
router.push(`${WEBAPP_URL}/teams`);
trackFormbricksAction("team_disbanded");
},
});
const removeMemberMutation = trpc.viewer.teams.removeMember.useMutation({
async onSuccess() {
await utils.viewer.teams.get.invalidate();
await utils.viewer.teams.list.invalidate();
await utils.viewer.eventTypes.invalidate();
showToast(t("success"), "success");
},
async onError(err) {
showToast(err.message, "error");
},
});
const publishMutation = trpc.viewer.teams.publish.useMutation({
async onSuccess(data: { url?: string }) {
if (data.url) {
router.push(data.url);
}
},
async onError(err) {
showToast(err.message, "error");
},
});
function deleteTeam() {
if (team?.id) deleteTeamMutation.mutate({ teamId: team.id });
}
function leaveTeam() {
if (team?.id && session.data)
removeMemberMutation.mutate({
teamIds: [team.id],
memberIds: [session.data.user.id],
});
}
if (!team) return null;
return (
<>
<Meta title={t("profile")} description={t("profile_team_description")} />
{!isPending ? (
<>
{isAdmin ? (
<Form
form={form}
handleSubmit={(values) => {
if (team) {
const variables = {
logoUrl: values.logoUrl,
name: values.name,
slug: values.slug,
bio: values.bio,
};
objectKeys(variables).forEach((key) => {
if (variables[key as keyof typeof variables] === team?.[key]) delete variables[key];
});
mutation.mutate({ id: team.id, ...variables });
}
}}>
<div className="flex items-center">
<Controller
control={form.control}
name="logoUrl"
render={({ field: { value, onChange } }) => (
<>
<Avatar alt="" imageSrc={getPlaceholderAvatar(value, team?.name)} size="lg" />
<div className="ms-4">
<ImageUploader
target="logo"
id="avatar-upload"
buttonMsg={t("update")}
handleAvatarChange={onChange}
imageSrc={value}
/>
</div>
</>
)}
/>
</div>
<hr className="border-subtle my-8" />
<Controller
control={form.control}
name="name"
render={({ field: { value, onChange } }) => (
<div className="mt-8">
<TextField
name="name"
label={t("team_name")}
value={value}
onChange={(e) => onChange(e?.target.value)}
/>
</div>
)}
/>
<Controller
control={form.control}
name="slug"
render={({ field: { value, onChange } }) => (
<div className="mt-8">
<TextField
name="slug"
label={t("team_url")}
value={value}
addOnLeading={
team?.parent ? `${team.parent.slug}.${subdomainSuffix()}/` : `${WEBAPP_URL}/team/`
}
onChange={(e) => {
form.clearErrors("slug");
onChange(slugify(e?.target.value, true));
}}
/>
</div>
)}
/>
<div className="mt-8">
<Label>{t("about")}</Label>
<Editor
getText={() => md.render(form.getValues("bio") || "")}
setText={(value: string) => form.setValue("bio", turndown(value))}
excludedToolbarItems={["blockType"]}
disableLists
firstRender={firstRender}
setFirstRender={setFirstRender}
/>
</div>
<p className="text-default mt-2 text-sm">{t("team_description")}</p>
<Button color="primary" className="mt-8" type="submit" loading={mutation.isPending}>
{t("update")}
</Button>
{IS_TEAM_BILLING_ENABLED &&
team.slug === null &&
(team.metadata as Prisma.JsonObject)?.requestedSlug && (
<Button
color="secondary"
className="ml-2 mt-8"
type="button"
onClick={() => {
publishMutation.mutate({ teamId: team.id });
}}>
Publish
</Button>
)}
</Form>
) : (
<div className="flex">
<div className="flex-grow">
<div>
<Label className="text-emphasis">{t("team_name")}</Label>
<p className="text-default text-sm">{team?.name}</p>
</div>
{team && !isBioEmpty && (
<>
<Label className="text-emphasis mt-5">{t("about")}</Label>
<div
className=" text-subtle break-words text-sm [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
dangerouslySetInnerHTML={{ __html: md.render(markdownToSafeHTML(team.bio)) }}
/>
</>
)}
</div>
<div className="">
<Link href={permalink} passHref={true} target="_blank">
<LinkIconButton Icon="external-link">{t("preview")}</LinkIconButton>
</Link>
<LinkIconButton
Icon="link"
onClick={() => {
navigator.clipboard.writeText(permalink);
showToast("Copied to clipboard", "success");
}}>
{t("copy_link_team")}
</LinkIconButton>
</div>
</div>
)}
<hr className="border-subtle my-8 border" />
<div className="text-default mb-3 text-base font-semibold">{t("danger_zone")}</div>
<Dialog>
<DialogTrigger asChild>
<Button color="destructive" className="border" StartIcon="trash-2">
{t("disband_team")}
</Button>
</DialogTrigger>
<ConfirmationDialogContent
variety="danger"
title={t("disband_team")}
confirmBtnText={t("confirm_disband_team")}
onConfirm={() => {
deleteTeam();
}}>
{t("disband_team_confirmation_message")}
</ConfirmationDialogContent>
</Dialog>
</>
) : (
<>
<SkeletonContainer as="form">
<div className="flex items-center">
<div className="ms-4">
<SkeletonContainer>
<div className="bg-emphasis h-16 w-16 rounded-full" />
</SkeletonContainer>
</div>
</div>
<hr className="border-subtle my-8" />
<SkeletonContainer>
<div className="mt-8">
<SkeletonText className="h-6 w-48" />
</div>
</SkeletonContainer>
<SkeletonContainer>
<div className="mt-8">
<SkeletonText className="h-6 w-48" />
</div>
</SkeletonContainer>
<div className="mt-8">
<SkeletonContainer>
<div className="bg-emphasis h-24 rounded-md" />
</SkeletonContainer>
<SkeletonText className="mt-4 h-12 w-32" />
</div>
<SkeletonContainer>
<div className="mt-8">
<SkeletonText className="h-9 w-24" />
</div>
</SkeletonContainer>
</SkeletonContainer>
</>
)}
</>
);
};
OtherTeamProfileView.getLayout = getLayout;
export default OtherTeamProfileView;

View File

@@ -0,0 +1,43 @@
"use client";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import MakeTeamPrivateSwitch from "@calcom/features/ee/teams/components/MakeTeamPrivateSwitch";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { MembershipRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import { Meta } from "@calcom/ui";
const PrivacyView = () => {
const { t } = useLocale();
const { data: currentOrg } = trpc.viewer.organizations.listCurrent.useQuery();
const isOrgAdminOrOwner =
currentOrg &&
(currentOrg.user.role === MembershipRole.OWNER || currentOrg.user.role === MembershipRole.ADMIN);
const isInviteOpen = !currentOrg?.user.accepted;
const isDisabled = isInviteOpen || !isOrgAdminOrOwner;
if (!currentOrg) return null;
return (
<LicenseRequired>
<Meta
borderInShellHeader={false}
title={t("privacy")}
description={t("privacy_organization_description")}
/>
<div>
<MakeTeamPrivateSwitch
isOrg={true}
teamId={currentOrg.id}
isPrivate={currentOrg.isPrivate}
disabled={isDisabled}
/>
</div>
</LicenseRequired>
);
};
PrivacyView.getLayout = getLayout;
export default PrivacyView;

View File

@@ -0,0 +1,403 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import type { Prisma } from "@prisma/client";
import { useRouter } from "next/navigation";
import { useEffect, useLayoutEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
import OrgAppearanceViewWrapper from "@calcom/features/ee/organizations/pages/settings/appearance";
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { md } from "@calcom/lib/markdownIt";
import turndown from "@calcom/lib/turndownService";
import { MembershipRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import { Icon } from "@calcom/ui";
import {
Avatar,
BannerUploader,
Button,
Editor,
Form,
ImageUploader,
Label,
LinkIconButton,
Meta,
showToast,
SkeletonAvatar,
SkeletonButton,
SkeletonContainer,
SkeletonText,
TextField,
} from "@calcom/ui";
// if I include this in the above barrel import, I get a runtime error that the component is not exported.
import { OrgBanner } from "@calcom/ui";
import { getLayout } from "../../../../settings/layouts/SettingsLayout";
import { useOrgBranding } from "../../../organizations/context/provider";
const orgProfileFormSchema = z.object({
name: z.string(),
logoUrl: z.string().nullable(),
banner: z.string().nullable(),
calVideoLogo: z.string().nullable(),
bio: z.string(),
});
type FormValues = {
name: string;
logoUrl: string | null;
banner: string | null;
bio: string;
slug: string;
calVideoLogo: string | null;
};
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
return (
<SkeletonContainer>
<Meta title={title} description={description} borderInShellHeader={true} />
<div className="border-subtle space-y-6 rounded-b-xl border border-t-0 px-4 py-8">
<div className="flex items-center">
<SkeletonAvatar className="me-4 mt-0 h-16 w-16 px-4" />
<SkeletonButton className="h-6 w-32 rounded-md p-5" />
</div>
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
</div>
</SkeletonContainer>
);
};
const OrgProfileView = () => {
const { t } = useLocale();
const router = useRouter();
const orgBranding = useOrgBranding();
useLayoutEffect(() => {
document.body.focus();
}, []);
const {
data: currentOrganisation,
isPending,
error,
} = trpc.viewer.organizations.listCurrent.useQuery(undefined, {});
useEffect(
function refactorMeWithoutEffect() {
if (error) {
router.replace("/enterprise");
}
},
[error, router]
);
if (isPending || !orgBranding || !currentOrganisation) {
return <SkeletonLoader title={t("profile")} description={t("profile_org_description")} />;
}
const isOrgAdminOrOwner =
currentOrganisation.user.role === MembershipRole.OWNER ||
currentOrganisation.user.role === MembershipRole.ADMIN;
const isBioEmpty =
!currentOrganisation ||
!currentOrganisation.bio ||
!currentOrganisation.bio.replace("<p><br></p>", "").length;
const defaultValues: FormValues = {
name: currentOrganisation?.name || "",
logoUrl: currentOrganisation?.logoUrl,
banner: currentOrganisation?.bannerUrl || "",
bio: currentOrganisation?.bio || "",
calVideoLogo: currentOrganisation?.calVideoLogo || "",
slug:
currentOrganisation?.slug ||
((currentOrganisation?.metadata as Prisma.JsonObject)?.requestedSlug as string) ||
"",
};
return (
<LicenseRequired>
<Meta title={t("profile")} description={t("profile_org_description")} borderInShellHeader={true} />
<>
{isOrgAdminOrOwner ? (
<>
<OrgProfileForm defaultValues={defaultValues} />
<OrgAppearanceViewWrapper />
</>
) : (
<div className="border-subtle flex rounded-b-md border border-t-0 px-4 py-8 sm:px-6">
<div className="flex-grow">
<div>
<Label className="text-emphasis">{t("organization_name")}</Label>
<p className="text-default text-sm">{currentOrganisation?.name}</p>
</div>
{!isBioEmpty && (
<>
<Label className="text-emphasis mt-5">{t("about")}</Label>
<div
className=" text-subtle break-words text-sm [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
dangerouslySetInnerHTML={{ __html: md.render(currentOrganisation.bio || "") }}
/>
</>
)}
</div>
<div className="">
<LinkIconButton
Icon="link"
onClick={() => {
navigator.clipboard.writeText(orgBranding.fullDomain);
showToast("Copied to clipboard", "success");
}}>
{t("copy_link_org")}
</LinkIconButton>
</div>
</div>
)}
{/* LEAVE ORG should go above here ^ */}
</>
</LicenseRequired>
);
};
const OrgProfileForm = ({ defaultValues }: { defaultValues: FormValues }) => {
const utils = trpc.useUtils();
const { t } = useLocale();
const [firstRender, setFirstRender] = useState(true);
const form = useForm({
defaultValues,
resolver: zodResolver(orgProfileFormSchema),
});
const mutation = trpc.viewer.organizations.update.useMutation({
onError: (err) => {
showToast(err.message, "error");
},
onSuccess: async (res) => {
reset({
logoUrl: res.data?.logoUrl,
name: (res.data?.name || "") as string,
bio: (res.data?.bio || "") as string,
slug: defaultValues["slug"],
banner: (res.data?.bannerUrl || "") as string,
calVideoLogo: (res.data?.calVideoLogo || "") as string,
});
await utils.viewer.teams.get.invalidate();
await utils.viewer.organizations.listCurrent.invalidate();
showToast(t("your_organization_updated_sucessfully"), "success");
},
});
const {
formState: { isSubmitting, isDirty },
reset,
} = form;
const isDisabled = isSubmitting || !isDirty;
return (
<Form
form={form}
handleSubmit={(values) => {
const variables = {
logoUrl: values.logoUrl,
name: values.name,
slug: values.slug,
bio: values.bio,
banner: values.banner,
calVideoLogo: values.calVideoLogo,
};
mutation.mutate(variables);
}}>
<div className="border-subtle border-x px-4 py-8 sm:px-6">
<div className="flex items-center">
<Controller
control={form.control}
name="logoUrl"
render={({ field: { value, onChange } }) => {
const showRemoveLogoButton = value !== null;
return (
<>
<Avatar
data-testid="profile-upload-logo"
alt={form.getValues("name")}
imageSrc={getPlaceholderAvatar(value, form.getValues("name"))}
size="lg"
/>
<div className="ms-4">
<div className="flex gap-2">
<ImageUploader
target="logo"
id="avatar-upload"
buttonMsg={t("upload_logo")}
handleAvatarChange={onChange}
imageSrc={getPlaceholderAvatar(value, form.getValues("name"))}
triggerButtonColor={showRemoveLogoButton ? "secondary" : "primary"}
/>
{showRemoveLogoButton && (
<Button color="secondary" onClick={() => onChange(null)}>
{t("remove")}
</Button>
)}
</div>
</div>
</>
);
}}
/>
</div>
<div className="my-4 flex flex-col gap-4">
<Controller
control={form.control}
name="banner"
render={({ field: { value, onChange } }) => {
const showRemoveBannerButton = !!value;
return (
<>
<OrgBanner
data-testid="profile-upload-banner"
alt={`${defaultValues.name} Banner` || ""}
className="grid min-h-[150px] w-full place-items-center rounded-md sm:min-h-[200px]"
fallback={t("no_target", { target: "banner" })}
imageSrc={value}
/>
<div className="ms-4">
<div className="flex gap-2">
<BannerUploader
height={500}
width={1500}
target="banner"
uploadInstruction={t("org_banner_instructions", { height: 500, width: 1500 })}
id="banner-upload"
buttonMsg={t("upload_banner")}
handleAvatarChange={onChange}
imageSrc={value || undefined}
triggerButtonColor={showRemoveBannerButton ? "secondary" : "primary"}
/>
{showRemoveBannerButton && (
<Button color="destructive" onClick={() => onChange(null)}>
{t("remove")}
</Button>
)}
</div>
</div>
</>
);
}}
/>
</div>
<div className="mt-2 flex items-center">
<Controller
control={form.control}
name="calVideoLogo"
render={({ field: { value, onChange } }) => {
const showRemoveLogoButton = !!value;
return (
<>
<Avatar
alt="calVideoLogo"
imageSrc={value}
fallback={<Icon name="plus" className="text-subtle h-6 w-6" />}
size="lg"
/>
<div className="ms-4">
<div className="flex gap-2">
<ImageUploader
target="avatar"
id="cal-video-logo-upload"
buttonMsg={t("upload_cal_video_logo")}
handleAvatarChange={onChange}
imageSrc={value || undefined}
uploadInstruction={t("cal_video_logo_upload_instruction")}
triggerButtonColor={showRemoveLogoButton ? "secondary" : "primary"}
testId="cal-video-logo"
/>
{showRemoveLogoButton && (
<Button color="secondary" onClick={() => onChange(null)}>
{t("remove")}
</Button>
)}
</div>
</div>
</>
);
}}
/>
</div>
<Controller
control={form.control}
name="name"
render={({ field: { value } }) => (
<div className="mt-8">
<TextField
name="name"
label={t("organization_name")}
value={value}
onChange={(e) => {
form.setValue("name", e?.target.value, { shouldDirty: true });
}}
/>
</div>
)}
/>
<Controller
control={form.control}
name="slug"
render={({ field: { value } }) => (
<div className="mt-8">
<TextField
name="slug"
label={t("organization_url")}
value={value}
disabled
addOnSuffix={`.${subdomainSuffix()}`}
/>
</div>
)}
/>
<div className="mt-8">
<Label>{t("about")}</Label>
<Editor
getText={() => md.render(form.getValues("bio") || "")}
setText={(value: string) => form.setValue("bio", turndown(value), { shouldDirty: true })}
excludedToolbarItems={["blockType"]}
disableLists
firstRender={firstRender}
setFirstRender={setFirstRender}
/>
</div>
<p className="text-default mt-2 text-sm">{t("org_description")}</p>
</div>
<SectionBottomActions align="end">
<Button
data-testid="update-org-profile-button"
color="primary"
type="submit"
loading={mutation.isPending}
disabled={isDisabled}>
{t("update")}
</Button>
</SectionBottomActions>
</Form>
);
};
OrgProfileView.getLayout = getLayout;
export default OrgProfileView;

View File

@@ -0,0 +1,22 @@
{
"name": "@calcom/ee",
"description": "Cal.com Commercial License features",
"version": "0.0.0",
"private": true,
"license": "See license in LICENSE",
"main": "./index.ts",
"dependencies": {
"@calcom/lib": "*",
"@calcom/ui": "*",
"@hookform/error-message": "^2.0.0",
"@hookform/resolvers": "^2.9.7",
"@sendgrid/client": "^7.7.0",
"@sendgrid/mail": "^7.6.2",
"libphonenumber-js": "^1.10.51",
"twilio": "^3.80.1",
"zod": "^3.22.4"
},
"devDependencies": {
"@calcom/tsconfig": "*"
}
}

View File

@@ -0,0 +1,173 @@
import type { Prisma } from "@prisma/client";
import { buffer } from "micro";
import type { NextApiRequest, NextApiResponse } from "next";
import type Stripe from "stripe";
import stripe from "@calcom/app-store/stripepayment/lib/server";
import EventManager from "@calcom/core/EventManager";
import { sendAttendeeRequestEmail, sendOrganizerRequestEmail } from "@calcom/emails";
import { doesBookingRequireConfirmation } from "@calcom/features/bookings/lib/doesBookingRequireConfirmation";
import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import { HttpError as HttpCode } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { getBooking } from "@calcom/lib/payment/getBooking";
import { handlePaymentSuccess } from "@calcom/lib/payment/handlePaymentSuccess";
import { safeStringify } from "@calcom/lib/safeStringify";
import { prisma } from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
const log = logger.getSubLogger({ prefix: ["[paymentWebhook]"] });
export const config = {
api: {
bodyParser: false,
},
};
export async function handleStripePaymentSuccess(event: Stripe.Event) {
const paymentIntent = event.data.object as Stripe.PaymentIntent;
const payment = await prisma.payment.findFirst({
where: {
externalId: paymentIntent.id,
},
select: {
id: true,
bookingId: true,
},
});
if (!payment?.bookingId) {
log.error("Stripe: Payment Not Found", safeStringify(paymentIntent), safeStringify(payment));
throw new HttpCode({ statusCode: 204, message: "Payment not found" });
}
if (!payment?.bookingId) throw new HttpCode({ statusCode: 204, message: "Payment not found" });
await handlePaymentSuccess(payment.id, payment.bookingId);
}
const handleSetupSuccess = async (event: Stripe.Event) => {
const setupIntent = event.data.object as Stripe.SetupIntent;
const payment = await prisma.payment.findFirst({
where: {
externalId: setupIntent.id,
},
});
if (!payment?.data || !payment?.id) throw new HttpCode({ statusCode: 204, message: "Payment not found" });
const { booking, user, evt, eventType } = await getBooking(payment.bookingId);
const bookingData: Prisma.BookingUpdateInput = {
paid: true,
};
if (!user) throw new HttpCode({ statusCode: 204, message: "No user found" });
const requiresConfirmation = doesBookingRequireConfirmation({
booking: {
...booking,
eventType,
},
});
if (!requiresConfirmation) {
const eventManager = new EventManager(user, eventType?.metadata?.apps);
const scheduleResult = await eventManager.create(evt);
bookingData.references = { create: scheduleResult.referencesToCreate };
bookingData.status = BookingStatus.ACCEPTED;
}
await prisma.payment.update({
where: {
id: payment.id,
},
data: {
data: {
...(payment.data as Prisma.JsonObject),
setupIntent: setupIntent as unknown as Prisma.JsonObject,
},
booking: {
update: {
...bookingData,
},
},
},
});
// If the card information was already captured in the same customer. Delete the previous payment method
if (!requiresConfirmation) {
await handleConfirmation({
user,
evt,
prisma,
bookingId: booking.id,
booking,
paid: true,
});
} else {
await sendOrganizerRequestEmail({ ...evt });
await sendAttendeeRequestEmail({ ...evt }, evt.attendees[0]);
}
};
type WebhookHandler = (event: Stripe.Event) => Promise<void>;
const webhookHandlers: Record<string, WebhookHandler | undefined> = {
"payment_intent.succeeded": handleStripePaymentSuccess,
"setup_intent.succeeded": handleSetupSuccess,
};
/**
* @deprecated
* We need to create a PaymentManager in `@calcom/core`
* to prevent circular dependencies on App Store migration
*/
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
if (req.method !== "POST") {
throw new HttpCode({ statusCode: 405, message: "Method Not Allowed" });
}
const sig = req.headers["stripe-signature"];
if (!sig) {
throw new HttpCode({ statusCode: 400, message: "Missing stripe-signature" });
}
if (!process.env.STRIPE_WEBHOOK_SECRET) {
throw new HttpCode({ statusCode: 500, message: "Missing process.env.STRIPE_WEBHOOK_SECRET" });
}
const requestBuffer = await buffer(req);
const payload = requestBuffer.toString();
const event = stripe.webhooks.constructEvent(payload, sig, process.env.STRIPE_WEBHOOK_SECRET);
// bypassing this validation for e2e tests
// in order to successfully confirm the payment
if (!event.account && !process.env.NEXT_PUBLIC_IS_E2E) {
throw new HttpCode({ statusCode: 202, message: "Incoming connected account" });
}
const handler = webhookHandlers[event.type];
if (handler) {
await handler(event);
} else {
/** Not really an error, just letting Stripe know that the webhook was received but unhandled */
throw new HttpCode({
statusCode: 202,
message: `Unhandled Stripe Webhook event type ${event.type}`,
});
}
} catch (_err) {
const err = getErrorFromUnknown(_err);
console.error(`Webhook Error: ${err.message}`);
res.status(err.statusCode ?? 500).send({
message: err.message,
stack: IS_PRODUCTION ? undefined : err.stack,
});
return;
}
// Return a response to acknowledge receipt of the event
res.json({ received: true });
}

View File

@@ -0,0 +1,216 @@
import type { Payment } from "@prisma/client";
import type { EventType } from "@prisma/client";
import { Elements, PaymentElement, useElements, useStripe } from "@stripe/react-stripe-js";
import type { StripeElementLocale } from "@stripe/stripe-js";
import { useRouter } from "next/navigation";
import type { SyntheticEvent } from "react";
import { useEffect, useState } from "react";
import getStripe from "@calcom/app-store/stripepayment/lib/client";
import { useBookingSuccessRedirect } from "@calcom/lib/bookingSuccessRedirect";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, CheckboxField } from "@calcom/ui";
import type { PaymentPageProps } from "../pages/payment";
type Props = {
payment: Omit<Payment, "id" | "fee" | "success" | "refunded" | "externalId" | "data"> & {
data: Record<string, unknown>;
};
eventType: {
id: number;
successRedirectUrl: EventType["successRedirectUrl"];
forwardParamsSuccessRedirect: EventType["forwardParamsSuccessRedirect"];
};
user: {
username: string | null;
};
location?: string | null;
clientSecret: string;
booking: PaymentPageProps["booking"];
};
type States =
| {
status: "idle";
}
| {
status: "processing";
}
| {
status: "error";
error: Error;
}
| {
status: "ok";
};
const PaymentForm = (props: Props) => {
const {
user: { username },
} = props;
const { t, i18n } = useLocale();
const router = useRouter();
const searchParams = useCompatSearchParams();
const [state, setState] = useState<States>({ status: "idle" });
const [isCanceling, setIsCanceling] = useState<boolean>(false);
const stripe = useStripe();
const elements = useElements();
const paymentOption = props.payment.paymentOption;
const [holdAcknowledged, setHoldAcknowledged] = useState<boolean>(paymentOption === "HOLD" ? false : true);
const bookingSuccessRedirect = useBookingSuccessRedirect();
useEffect(() => {
elements?.update({ locale: i18n.language as StripeElementLocale });
}, [elements, i18n.language]);
const handleSubmit = async (ev: SyntheticEvent) => {
ev.preventDefault();
if (!stripe || !elements || searchParams === null) {
return;
}
setState({ status: "processing" });
let payload;
const params: {
uid: string;
email: string | null;
location?: string;
payment_intent?: string;
payment_intent_client_secret?: string;
redirect_status?: string;
} = {
uid: props.booking.uid,
email: searchParams?.get("email"),
};
if (paymentOption === "HOLD" && "setupIntent" in props.payment.data) {
payload = await stripe.confirmSetup({
elements,
redirect: "if_required",
});
if (payload.setupIntent) {
params.payment_intent = payload.setupIntent.id;
params.payment_intent_client_secret = payload.setupIntent.client_secret || undefined;
params.redirect_status = payload.setupIntent.status;
}
} else if (paymentOption === "ON_BOOKING") {
payload = await stripe.confirmPayment({
elements,
redirect: "if_required",
});
if (payload.paymentIntent) {
params.payment_intent = payload.paymentIntent.id;
params.payment_intent_client_secret = payload.paymentIntent.client_secret || undefined;
params.redirect_status = payload.paymentIntent.status;
}
}
if (payload?.error) {
setState({
status: "error",
error: new Error(`Payment failed: ${payload.error.message}`),
});
} else {
if (props.location) {
if (props.location.includes("integration")) {
params.location = t("web_conferencing_details_to_follow");
} else {
params.location = props.location;
}
}
return bookingSuccessRedirect({
successRedirectUrl: props.eventType.successRedirectUrl,
query: params,
booking: props.booking,
forwardParamsSuccessRedirect: props.eventType.forwardParamsSuccessRedirect,
});
}
};
const disableButtons = isCanceling || !holdAcknowledged || ["processing", "error"].includes(state.status);
return (
<form id="payment-form" className="bg-subtle mt-4 rounded-md p-6" onSubmit={handleSubmit}>
<div>
<PaymentElement onChange={() => setState({ status: "idle" })} />
</div>
{paymentOption === "HOLD" && (
<div className="bg-info mb-5 mt-2 rounded-md p-3">
<CheckboxField
description={t("acknowledge_booking_no_show_fee", {
amount: props.payment.amount / 100,
formatParams: { amount: { currency: props.payment.currency } },
})}
onChange={(e) => setHoldAcknowledged(e.target.checked)}
descriptionClassName="text-info font-semibold"
/>
</div>
)}
<div className="mt-2 flex justify-end space-x-2">
<Button
color="minimal"
disabled={disableButtons}
id="cancel"
type="button"
loading={isCanceling}
onClick={() => {
setIsCanceling(true);
if (username) {
return router.push(`/${username}`);
}
return router.back();
}}>
<span id="button-text">{t("cancel")}</span>
</Button>
<Button
type="submit"
disabled={disableButtons}
loading={state.status === "processing"}
id="submit"
color="secondary">
<span id="button-text">
{state.status === "processing" ? (
<div className="spinner" id="spinner" />
) : paymentOption === "HOLD" ? (
t("submit_card")
) : (
t("pay_now")
)}
</span>
</Button>
</div>
{state.status === "error" && (
<div className="mt-4 text-center text-red-900 dark:text-gray-300" role="alert">
{state.error.message}
</div>
)}
</form>
);
};
export default function PaymentComponent(props: Props) {
const stripePromise = getStripe(props.payment.data.stripe_publishable_key as any);
const [theme, setTheme] = useState<"stripe" | "night">("stripe");
useEffect(() => {
if (document.documentElement.classList.contains("dark")) {
setTheme("night");
}
}, []);
return (
<Elements
stripe={stripePromise}
options={{
clientSecret: props.clientSecret,
appearance: {
theme,
},
}}>
<PaymentForm {...props} />
</Elements>
);
}

View File

@@ -0,0 +1,183 @@
"use client";
import classNames from "classnames";
import dynamic from "next/dynamic";
import Head from "next/head";
import type { FC } from "react";
import { useEffect, useState } from "react";
import { getSuccessPageLocationMessage } from "@calcom/app-store/locations";
import dayjs from "@calcom/dayjs";
import { sdkActionManager, useIsEmbed } from "@calcom/embed-core/embed-iframe";
import { PayIcon } from "@calcom/features/bookings/components/event-meta/PayIcon";
import { Price } from "@calcom/features/bookings/components/event-meta/Price";
import { APP_NAME, WEBSITE_URL } from "@calcom/lib/constants";
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useTheme from "@calcom/lib/hooks/useTheme";
import { getIs24hClockFromLocalStorage, isBrowserLocale24h } from "@calcom/lib/timeFormat";
import { localStorage } from "@calcom/lib/webstorage";
import type { PaymentPageProps } from "../pages/payment";
const StripePaymentComponent = dynamic(() => import("./Payment"), {
ssr: false,
});
const PaypalPaymentComponent = dynamic(
() =>
import("@calcom/app-store/paypal/components/PaypalPaymentComponent").then(
(m) => m.PaypalPaymentComponent
),
{
ssr: false,
}
);
const AlbyPaymentComponent = dynamic(
() => import("@calcom/app-store/alby/components/AlbyPaymentComponent").then((m) => m.AlbyPaymentComponent),
{
ssr: false,
}
);
const PaymentPage: FC<PaymentPageProps> = (props) => {
const { t, i18n } = useLocale();
const [is24h, setIs24h] = useState(isBrowserLocale24h());
const [date, setDate] = useState(dayjs.utc(props.booking.startTime));
const [timezone, setTimezone] = useState<string | null>(null);
useTheme(props.profile.theme);
const isEmbed = useIsEmbed();
const paymentAppData = getPaymentAppData(props.eventType);
useEffect(() => {
let embedIframeWidth = 0;
const _timezone =
localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess() || "Europe/London";
setTimezone(_timezone);
setDate(date.tz(_timezone));
setIs24h(!!getIs24hClockFromLocalStorage());
if (isEmbed) {
requestAnimationFrame(function fixStripeIframe() {
// HACK: Look for stripe iframe and center position it just above the embed content
const stripeIframeWrapper = document.querySelector(
'iframe[src*="https://js.stripe.com/v3/authorize-with-url-inner"]'
)?.parentElement;
if (stripeIframeWrapper) {
stripeIframeWrapper.style.margin = "0 auto";
stripeIframeWrapper.style.width = `${embedIframeWidth}px`;
}
requestAnimationFrame(fixStripeIframe);
});
sdkActionManager?.on("__dimensionChanged", (e) => {
embedIframeWidth = e.detail.data.iframeWidth as number;
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEmbed]);
const eventName = props.booking.title;
return (
<div className="h-screen">
<Head>
<title>
{t("payment")} | {eventName} | {APP_NAME}
</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<main className="mx-auto max-w-3xl py-24">
<div className="fixed inset-0 z-50 overflow-y-auto scroll-auto">
<div className="flex min-h-screen items-end justify-center px-4 pb-20 pt-4 text-center sm:block sm:p-0">
<div className="inset-0 my-4 transition-opacity sm:my-0" aria-hidden="true">
<span className="hidden sm:inline-block sm:h-screen sm:align-middle" aria-hidden="true">
&#8203;
</span>
<div
className={classNames(
"main bg-default border-subtle inline-block transform overflow-hidden rounded-lg border px-8 pb-4 pt-5 text-left align-bottom transition-all sm:w-full sm:max-w-lg sm:py-6 sm:align-middle",
isEmbed ? "" : "sm:my-8"
)}
role="dialog"
aria-modal="true"
aria-labelledby="modal-headline">
<div>
<div className="bg-success mx-auto flex h-12 w-12 items-center justify-center rounded-full">
<PayIcon currency={paymentAppData.currency} className="h-8 w-8 text-green-600" />
</div>
<div className="mt-3 text-center sm:mt-5">
<h3 className="text-emphasis text-2xl font-semibold leading-6" id="modal-headline">
{paymentAppData.paymentOption === "HOLD" ? t("complete_your_booking") : t("payment")}
</h3>
<div className="text-default mt-4 grid grid-cols-3 border-b border-t py-4 text-left dark:border-gray-900 dark:text-gray-300">
<div className="font-medium">{t("what")}</div>
<div className="col-span-2 mb-6">{eventName}</div>
<div className="font-medium">{t("when")}</div>
<div className="col-span-2 mb-6">
{date.format("dddd, DD MMMM YYYY")}
<br />
{date.format(is24h ? "H:mm" : "h:mma")} - {props.eventType.length} mins{" "}
<span className="text-subtle">({timezone})</span>
</div>
{props.booking.location && (
<>
<div className="font-medium">{t("where")}</div>
<div className="col-span-2 mb-6">
{getSuccessPageLocationMessage(props.booking.location, t)}
</div>
</>
)}
<div className="font-medium">
{props.payment.paymentOption === "HOLD" ? t("no_show_fee") : t("price")}
</div>
<div className="col-span-2 mb-6 font-semibold">
<Price
currency={paymentAppData.currency}
price={paymentAppData.price}
displayAlternateSymbol={false}
/>
</div>
</div>
</div>
</div>
<div>
{props.payment.success && !props.payment.refunded && (
<div className="text-default mt-4 text-center dark:text-gray-300">{t("paid")}</div>
)}
{props.payment.appId === "stripe" && !props.payment.success && (
<StripePaymentComponent
clientSecret={props.clientSecret}
payment={props.payment}
eventType={props.eventType}
user={props.user}
location={props.booking.location}
booking={props.booking}
/>
)}
{props.payment.appId === "paypal" && !props.payment.success && (
<PaypalPaymentComponent payment={props.payment} />
)}
{props.payment.appId === "alby" && !props.payment.success && (
<AlbyPaymentComponent payment={props.payment} paymentPageProps={props} />
)}
{props.payment.refunded && (
<div className="text-default mt-4 text-center dark:text-gray-300">{t("refunded")}</div>
)}
</div>
{!props.profile.hideBranding && (
<div className="text-muted dark:text-inverted mt-4 border-t pt-4 text-center text-xs dark:border-gray-900">
<a href={`${WEBSITE_URL}/signup`}>
{t("create_booking_link_with_calcom", { appName: APP_NAME })}
</a>
</div>
)}
</div>
</div>
</div>
</div>
</main>
</div>
);
};
export default PaymentPage;

View File

@@ -0,0 +1,21 @@
import type { Payment } from "@calcom/prisma/client";
function hasStringProp<T extends string>(x: unknown, key: T): x is { [key in T]: string } {
return !!x && typeof x === "object" && key in x;
}
export function getClientSecretFromPayment(
payment: Omit<Partial<Payment>, "data"> & { data: Record<string, unknown> }
) {
if (
payment.paymentOption === "HOLD" &&
hasStringProp(payment.data, "setupIntent") &&
hasStringProp(payment.data.setupIntent, "client_secret")
) {
return payment.data.setupIntent.client_secret;
}
if (hasStringProp(payment.data, "client_secret")) {
return payment.data.client_secret;
}
return "";
}

View File

@@ -0,0 +1,148 @@
import type { GetServerSidePropsContext } from "next";
import { z } from "zod";
import { getClientSecretFromPayment } from "@calcom/features/ee/payments/pages/getClientSecretFromPayment";
import prisma from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import { ssrInit } from "../../../../../apps/web/server/lib/ssr";
export type PaymentPageProps = inferSSRProps<typeof getServerSideProps>;
const querySchema = z.object({
uid: z.string(),
});
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const ssr = await ssrInit(context);
const { uid } = querySchema.parse(context.query);
const rawPayment = await prisma.payment.findFirst({
where: {
uid,
},
select: {
data: true,
success: true,
uid: true,
refunded: true,
bookingId: true,
appId: true,
amount: true,
currency: true,
paymentOption: true,
booking: {
select: {
id: true,
uid: true,
description: true,
title: true,
startTime: true,
endTime: true,
attendees: {
select: {
email: true,
name: true,
},
},
eventTypeId: true,
location: true,
status: true,
rejectionReason: true,
cancellationReason: true,
eventType: {
select: {
id: true,
title: true,
description: true,
length: true,
eventName: true,
requiresConfirmation: true,
userId: true,
metadata: true,
users: {
select: {
name: true,
username: true,
hideBranding: true,
theme: true,
},
},
team: {
select: {
name: true,
hideBranding: true,
},
},
price: true,
currency: true,
successRedirectUrl: true,
forwardParamsSuccessRedirect: true,
},
},
},
},
},
});
if (!rawPayment) return { notFound: true } as const;
const { data, booking: _booking, ...restPayment } = rawPayment;
const payment = {
...restPayment,
data: data as Record<string, unknown>,
};
if (!_booking) return { notFound: true } as const;
const { startTime, endTime, eventType, ...restBooking } = _booking;
const booking = {
...restBooking,
startTime: startTime.toString(),
endTime: endTime.toString(),
};
if (!eventType) return { notFound: true } as const;
if (eventType.users.length === 0 && !!!eventType.team) return { notFound: true } as const;
const [user] = eventType?.users.length
? eventType.users
: [{ name: null, theme: null, hideBranding: null, username: null }];
const profile = {
name: eventType.team?.name || user?.name || null,
theme: (!eventType.team?.name && user?.theme) || null,
hideBranding: eventType.team?.hideBranding || user?.hideBranding || null,
};
if (
([BookingStatus.CANCELLED, BookingStatus.REJECTED] as BookingStatus[]).includes(
booking.status as BookingStatus
)
) {
return {
redirect: {
destination: `/booking/${booking.uid}`,
permanent: false,
},
};
}
return {
props: {
user,
eventType: {
...eventType,
metadata: EventTypeMetaDataSchema.parse(eventType.metadata),
},
booking,
trpcState: ssr.dehydrate(),
payment,
clientSecret: getClientSecretFromPayment(payment),
profile,
},
};
};

View File

@@ -0,0 +1,18 @@
import Stripe from "stripe";
declare global {
// eslint-disable-next-line no-var
var stripe: Stripe | undefined;
}
const stripe =
globalThis.stripe ||
new Stripe(process.env.STRIPE_PRIVATE_KEY!, {
apiVersion: "2020-08-27",
});
if (process.env.NODE_ENV !== "production") {
globalThis.stripe = stripe;
}
export default stripe;

View File

@@ -0,0 +1,175 @@
import type { SessionContextValue } from "next-auth/react";
import { useSession, signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { deriveOrgNameFromEmail } from "@calcom/ee/organizations/components/CreateANewOrganizationForm";
import { deriveSlugFromEmail } from "@calcom/ee/organizations/components/CreateANewOrganizationForm";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import slugify from "@calcom/lib/slugify";
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import type { Ensure } from "@calcom/types/utils";
import { Alert, Form, TextField, Button } from "@calcom/ui";
export const CreateANewPlatformForm = () => {
const session = useSession();
if (!session.data) {
return null;
}
return <CreateANewPlatformFormChild session={session} />;
};
const CreateANewPlatformFormChild = ({ session }: { session: Ensure<SessionContextValue, "data"> }) => {
const { t } = useLocale();
const router = useRouter();
const telemetry = useTelemetry();
const [serverErrorMessage, setServerErrorMessage] = useState<string | null>(null);
const isAdmin = session.data.user.role === UserPermissionRole.ADMIN;
const defaultOrgOwnerEmail = session.data.user.email ?? "";
const newOrganizationFormMethods = useForm<{
name: string;
slug: string;
orgOwnerEmail: string;
isPlatform: boolean;
}>({
defaultValues: {
slug: !isAdmin ? deriveSlugFromEmail(defaultOrgOwnerEmail) : undefined,
orgOwnerEmail: !isAdmin ? defaultOrgOwnerEmail : undefined,
name: !isAdmin ? deriveOrgNameFromEmail(defaultOrgOwnerEmail) : undefined,
isPlatform: true,
},
});
const createOrganizationMutation = trpc.viewer.organizations.create.useMutation({
onSuccess: async (data) => {
telemetry.event(telemetryEventTypes.org_created);
// This is necessary so that server token has the updated upId
await session.update({
upId: data.upId,
});
if (isAdmin && data.userId !== session.data?.user.id) {
// Impersonate the user chosen as the organization owner(if the admin user isn't the owner himself), so that admin can now configure the organisation on his behalf.
// He won't need to have access to the org directly in this way.
signIn("impersonation-auth", {
username: data.email,
callbackUrl: `/settings/platform`,
});
}
router.push("/settings/platform");
},
onError: (err) => {
if (err.message === "organization_url_taken") {
newOrganizationFormMethods.setError("slug", { type: "custom", message: t("url_taken") });
} else if (err.message === "domain_taken_team" || err.message === "domain_taken_project") {
newOrganizationFormMethods.setError("slug", {
type: "custom",
message: t("problem_registering_domain"),
});
} else {
setServerErrorMessage(err.message);
}
},
});
return (
<>
<Form
form={newOrganizationFormMethods}
className="space-y-5"
id="createOrg"
handleSubmit={(v) => {
if (!createOrganizationMutation.isPending) {
setServerErrorMessage(null);
createOrganizationMutation.mutate({
...v,
slug: `${v.name.toLocaleLowerCase()}_platform`,
});
}
}}>
<div>
{serverErrorMessage && (
<div className="mb-4">
<Alert severity="error" message={serverErrorMessage} />
</div>
)}
<Controller
name="orgOwnerEmail"
control={newOrganizationFormMethods.control}
rules={{
required: t("must_enter_organization_admin_email"),
}}
render={({ field: { value } }) => (
<div className="flex">
<TextField
containerClassName="w-full"
placeholder="john@acme.com"
name="orgOwnerEmail"
disabled={!isAdmin}
label={t("platform_admin_email")}
defaultValue={value}
onChange={(e) => {
const email = e?.target.value;
const slug = deriveSlugFromEmail(email);
newOrganizationFormMethods.setValue("orgOwnerEmail", email.trim());
if (newOrganizationFormMethods.getValues("slug") === "") {
newOrganizationFormMethods.setValue("slug", slug);
}
newOrganizationFormMethods.setValue("name", deriveOrgNameFromEmail(email));
}}
autoComplete="off"
/>
</div>
)}
/>
</div>
<div>
<Controller
name="name"
control={newOrganizationFormMethods.control}
defaultValue=""
rules={{
required: t("must_enter_organization_name"),
}}
render={({ field: { value } }) => (
<>
<TextField
className="mt-2"
placeholder="Acme"
name="name"
label={t("platform_name")}
defaultValue={value}
onChange={(e) => {
newOrganizationFormMethods.setValue("name", e?.target.value.trim());
if (newOrganizationFormMethods.formState.touchedFields["slug"] === undefined) {
newOrganizationFormMethods.setValue("slug", slugify(e?.target.value));
}
}}
autoComplete="off"
/>
</>
)}
/>
</div>
<div className="flex space-x-2 rtl:space-x-reverse">
<Button
disabled={
newOrganizationFormMethods.formState.isSubmitting || createOrganizationMutation.isPending
}
color="primary"
EndIcon="arrow-right"
type="submit"
form="createOrg"
className="w-full justify-center">
{t("continue")}
</Button>
</div>
<div />
</Form>
</>
);
};

View File

@@ -0,0 +1 @@
export { CreateANewPlatformForm } from "./CreateANewPlatformForm";

View File

@@ -0,0 +1,172 @@
import type { SSOConnection } from "@calcom/ee/sso/lib/saml";
import { APP_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import {
Button,
ConfirmationDialogContent,
Dialog,
DialogTrigger,
Icon,
Label,
showToast,
Tooltip,
} from "@calcom/ui";
export default function ConnectionInfo({
teamId,
connection,
}: {
teamId: number | null;
connection: SSOConnection;
}) {
const { t } = useLocale();
const utils = trpc.useUtils();
const connectionType = connection.type.toUpperCase();
// Delete SSO connection
const mutation = trpc.viewer.saml.delete.useMutation({
async onSuccess() {
showToast(
t("sso_connection_deleted_successfully", {
connectionType,
}),
"success"
);
await utils.viewer.saml.get.invalidate();
},
});
const deleteConnection = async () => {
mutation.mutate({
teamId,
});
};
return (
<div>
{connection.type === "saml" ? (
<SAMLInfo acsUrl={connection.acsUrl} entityId={connection.entityId} />
) : (
<OIDCInfo callbackUrl={connection.callbackUrl} />
)}
<hr className="border-subtle my-6" />
<div className="flex flex-col space-y-3">
<Label>{t("danger_zone")}</Label>
<Dialog>
<div>
<DialogTrigger asChild>
<Button
color="destructive"
data-testid={`delete-${connectionType === "OIDC" ? "oidc" : "saml"}-sso-connection`}>
{t("delete_sso_configuration", { connectionType })}
</Button>
</DialogTrigger>
</div>
<ConfirmationDialogContent
variety="danger"
title={t("delete_sso_configuration", { connectionType })}
confirmBtnText={t("delete_sso_configuration_confirmation", { connectionType })}
onConfirm={deleteConnection}>
{t("delete_sso_configuration_confirmation_description", { appName: APP_NAME, connectionType })}
</ConfirmationDialogContent>
</Dialog>
</div>
</div>
);
}
// Connection info for SAML
const SAMLInfo = ({ acsUrl, entityId }: { acsUrl: string | null; entityId: string | null }) => {
const { t } = useLocale();
if (!acsUrl || !entityId) {
return null;
}
return (
<div className="space-y-6">
<div className="flex flex-col">
<div className="flex">
<Label>ACS URL</Label>
</div>
<div className="flex">
<code className="bg-subtle text-default flex w-full items-center truncate rounded rounded-r-none pl-2 font-mono">
{acsUrl}
</code>
<Tooltip side="top" content={t("copy_to_clipboard")}>
<Button
onClick={() => {
navigator.clipboard.writeText(acsUrl);
showToast(t("sso_saml_acsurl_copied"), "success");
}}
type="button"
className="rounded-l-none py-[19px] text-base ">
<Icon name="clipboard" className="text-muted h-5 w-5 ltr:mr-2 rtl:ml-2" />
{t("copy")}
</Button>
</Tooltip>
</div>
</div>
<div className="flex flex-col">
<div className="flex">
<Label>Entity ID</Label>
</div>
<div className="flex">
<code className="bg-subtle text-default flex w-full items-center truncate rounded rounded-r-none pl-2 font-mono">
{entityId}
</code>
<Tooltip side="top" content={t("copy_to_clipboard")}>
<Button
onClick={() => {
navigator.clipboard.writeText(entityId);
showToast(t("sso_saml_entityid_copied"), "success");
}}
type="button"
className="rounded-l-none py-[19px] text-base ">
<Icon name="clipboard" className="text-muted h-5 w-5 ltr:mr-2 rtl:ml-2" />
{t("copy")}
</Button>
</Tooltip>
</div>
</div>
</div>
);
};
// Connection info for OIDC
const OIDCInfo = ({ callbackUrl }: { callbackUrl: string | null }) => {
const { t } = useLocale();
if (!callbackUrl) {
return null;
}
return (
<div>
<div className="flex flex-col">
<div className="flex">
<Label>Callback URL</Label>
</div>
<div className="flex">
<code className="bg-subtle text-default flex w-full items-center truncate rounded rounded-r-none pl-2 font-mono">
{callbackUrl}
</code>
<Tooltip side="top" content={t("copy_to_clipboard")}>
<Button
onClick={() => {
navigator.clipboard.writeText(callbackUrl);
showToast(t("sso_oidc_callback_copied"), "success");
}}
type="button"
className="rounded-l-none py-[19px] text-base ">
<Icon name="clipboard" className="text-muted h-5 w-5 ltr:mr-2 rtl:ml-2" />
{t("copy")}
</Button>
</Tooltip>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,166 @@
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import type { SSOConnection } from "@calcom/ee/sso/lib/saml";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Button, DialogFooter, Form, showToast, TextField, Dialog, DialogContent } from "@calcom/ui";
type FormValues = {
clientId: string;
clientSecret: string;
wellKnownUrl: string;
};
export default function OIDCConnection({
teamId,
connection,
}: {
teamId: number | null;
connection: SSOConnection | null;
}) {
const { t } = useLocale();
const [openModal, setOpenModal] = useState(false);
return (
<div>
<div className="flex flex-col sm:flex-row">
<div>
<h2 className="font-medium">{t("sso_oidc_heading")}</h2>
<p className="text-default text-sm font-normal leading-6 dark:text-gray-300">
{t("sso_oidc_description")}
</p>
</div>
{!connection && (
<div className="flex-shrink-0 pt-3 sm:ml-auto sm:pl-3 sm:pt-0">
<Button data-testid="sso-oidc-configure" color="secondary" onClick={() => setOpenModal(true)}>
{t("configure")}
</Button>
</div>
)}
</div>
<CreateConnectionDialog teamId={teamId} openModal={openModal} setOpenModal={setOpenModal} />
</div>
);
}
const CreateConnectionDialog = ({
teamId,
openModal,
setOpenModal,
}: {
teamId: number | null;
openModal: boolean;
setOpenModal: (open: boolean) => void;
}) => {
const { t } = useLocale();
const utils = trpc.useUtils();
const form = useForm<FormValues>();
const mutation = trpc.viewer.saml.updateOIDC.useMutation({
async onSuccess() {
showToast(
t("sso_connection_created_successfully", {
connectionType: "OIDC",
}),
"success"
);
setOpenModal(false);
await utils.viewer.saml.get.invalidate();
},
onError: (err) => {
showToast(err.message, "error");
},
});
return (
<Dialog open={openModal} onOpenChange={setOpenModal}>
<DialogContent type="creation">
<Form
form={form}
handleSubmit={(values) => {
const { clientId, clientSecret, wellKnownUrl } = values;
mutation.mutate({
teamId,
clientId,
clientSecret,
wellKnownUrl,
});
}}>
<h2 className="font-semi-bold font-cal text-emphasis text-xl tracking-wide">
{t("sso_oidc_configuration_title")}
</h2>
<p className="text-subtle mb-4 mt-1 text-sm">{t("sso_oidc_configuration_description")}</p>
<div className="space-y-5">
<Controller
control={form.control}
name="clientId"
render={({ field: { value } }) => (
<TextField
name="clientId"
label="Client id"
data-testid="sso-oidc-client-id"
value={value}
onChange={(e) => {
form.setValue("clientId", e?.target.value);
}}
type="text"
required
/>
)}
/>
<Controller
control={form.control}
name="clientSecret"
render={({ field: { value } }) => (
<TextField
name="clientSecret"
label="Client secret"
data-testid="sso-oidc-client-secret"
value={value}
onChange={(e) => {
form.setValue("clientSecret", e?.target.value);
}}
type="text"
required
/>
)}
/>
<Controller
control={form.control}
name="wellKnownUrl"
render={({ field: { value } }) => (
<TextField
name="wellKnownUrl"
label="Well-Known URL"
data-testid="sso-oidc-well-known-url"
value={value}
onChange={(e) => {
form.setValue("wellKnownUrl", e?.target.value);
}}
type="text"
required
/>
)}
/>
</div>
<DialogFooter showDivider className="relative mt-10">
<Button
type="button"
color="secondary"
onClick={() => {
setOpenModal(false);
}}
tabIndex={-1}>
{t("cancel")}
</Button>
<Button type="submit" data-testid="sso-oidc-save" loading={form.formState.isSubmitting}>
{t("save")}
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,128 @@
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import type { SSOConnection } from "@calcom/ee/sso/lib/saml";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Button, DialogFooter, Form, showToast, TextArea, Dialog, DialogContent } from "@calcom/ui";
interface FormValues {
metadata: string;
}
export default function SAMLConnection({
teamId,
connection,
}: {
teamId: number | null;
connection: SSOConnection | null;
}) {
const { t } = useLocale();
const [openModal, setOpenModal] = useState(false);
return (
<div>
<div className="flex flex-col sm:flex-row">
<div>
<h2 className="font-medium">{t("sso_saml_heading")}</h2>
<p className="text-default text-sm font-normal leading-6 dark:text-gray-300">
{t("sso_saml_description")}
</p>
</div>
{!connection && (
<div className="flex-shrink-0 pt-3 sm:ml-auto sm:pl-3 sm:pt-0">
<Button color="secondary" onClick={() => setOpenModal(true)}>
Configure
</Button>
</div>
)}
</div>
<CreateConnectionDialog teamId={teamId} openModal={openModal} setOpenModal={setOpenModal} />
</div>
);
}
const CreateConnectionDialog = ({
teamId,
openModal,
setOpenModal,
}: {
teamId: number | null;
openModal: boolean;
setOpenModal: (open: boolean) => void;
}) => {
const { t } = useLocale();
const utils = trpc.useUtils();
const form = useForm<FormValues>();
const mutation = trpc.viewer.saml.update.useMutation({
async onSuccess() {
showToast(
t("sso_connection_created_successfully", {
connectionType: "SAML",
}),
"success"
);
setOpenModal(false);
await utils.viewer.saml.get.invalidate();
},
onError: (err) => {
showToast(err.message, "error");
},
});
return (
<Dialog open={openModal} onOpenChange={setOpenModal}>
<DialogContent type="creation">
<Form
form={form}
handleSubmit={(values) => {
mutation.mutate({
teamId,
encodedRawMetadata: Buffer.from(values.metadata).toString("base64"),
});
}}>
<div className="mb-1">
<h2 className="font-semi-bold font-cal text-emphasis text-xl tracking-wide">
{t("sso_saml_configuration_title")}
</h2>
<p className="text-subtle mb-5 mt-1 text-sm">{t("sso_saml_configuration_description")}</p>
</div>
<Controller
control={form.control}
name="metadata"
render={({ field: { value } }) => (
<div>
<TextArea
data-testid="saml_config"
name="metadata"
value={value}
className="h-40"
required={true}
placeholder={t("saml_configuration_placeholder")}
onChange={(e) => {
form.setValue("metadata", e?.target.value);
}}
/>
</div>
)}
/>
<DialogFooter showDivider className="mt-10">
<Button
type="button"
color="secondary"
onClick={() => {
setOpenModal(false);
}}
tabIndex={-1}>
{t("cancel")}
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
{t("save")}
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,68 @@
import ConnectionInfo from "@calcom/ee/sso/components/ConnectionInfo";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import OIDCConnection from "@calcom/features/ee/sso/components/OIDCConnection";
import SAMLConnection from "@calcom/features/ee/sso/components/SAMLConnection";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Meta, Alert, SkeletonContainer, SkeletonText } from "@calcom/ui";
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
return (
<SkeletonContainer>
<Meta title={title} description={description} borderInShellHeader={true} />
<div className="divide-subtle border-subtle space-y-6 rounded-b-xl border border-t-0 px-6 py-4">
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
</div>
</SkeletonContainer>
);
};
export default function SSOConfiguration({ teamId }: { teamId: number | null }) {
const { t } = useLocale();
const { data: connection, isPending, error } = trpc.viewer.saml.get.useQuery({ teamId });
if (isPending) {
return <SkeletonLoader title={t("sso_configuration")} description={t("sso_configuration_description")} />;
}
if (error) {
return (
<>
<Meta
title={t("sso_configuration")}
description={t("sso_configuration_description")}
borderInShellHeader={true}
/>
<Alert severity="warning" message={t(error.message)} className="mt-4" />
</>
);
}
// No connection found
if (!connection) {
return (
<LicenseRequired>
<div className="[&>*]:border-subtle flex flex-col [&>*:last-child]:rounded-b-xl [&>*]:border [&>*]:border-t-0 [&>*]:px-4 [&>*]:py-6 [&>*]:sm:px-6">
<SAMLConnection teamId={teamId} connection={null} />
<OIDCConnection teamId={teamId} connection={null} />
</div>
</LicenseRequired>
);
}
return (
<LicenseRequired>
<div className="[&>*]:border-subtle flex flex-col [&>*:last-child]:rounded-b-xl [&>*]:border [&>*]:border-t-0 [&>*]:px-4 [&>*]:py-6 [&>*]:sm:px-6">
{connection.type === "saml" ? (
<SAMLConnection teamId={teamId} connection={connection} />
) : (
<OIDCConnection teamId={teamId} connection={connection} />
)}
<ConnectionInfo teamId={teamId} connection={connection} />
</div>
</LicenseRequired>
);
}

View File

@@ -0,0 +1,63 @@
import type {
IConnectionAPIController,
IOAuthController,
ISPSSOConfig,
JacksonOption,
IDirectorySyncController,
} from "@boxyhq/saml-jackson";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { clientSecretVerifier, oidcPath, samlAudience, samlDatabaseUrl, samlPath } from "./saml";
// Set the required options. Refer to https://github.com/boxyhq/jackson#configuration for the full list
const opts: JacksonOption = {
externalUrl: WEBAPP_URL,
samlPath,
samlAudience,
oidcPath,
scimPath: "/api/scim/v2.0",
db: {
engine: "sql",
type: "postgres",
url: samlDatabaseUrl,
encryptionKey: process.env.CALENDSO_ENCRYPTION_KEY,
},
idpEnabled: true,
clientSecretVerifier,
ory: {
projectId: undefined,
sdkToken: undefined,
},
};
declare global {
/* eslint-disable no-var */
var connectionController: IConnectionAPIController | undefined;
var oauthController: IOAuthController | undefined;
var samlSPConfig: ISPSSOConfig | undefined;
var dsyncController: IDirectorySyncController | undefined;
/* eslint-enable no-var */
}
export default async function init() {
if (
!globalThis.connectionController ||
!globalThis.oauthController ||
!globalThis.samlSPConfig ||
!globalThis.dsyncController
) {
const ret = await (await import("@boxyhq/saml-jackson")).controllers(opts);
globalThis.connectionController = ret.connectionAPIController;
globalThis.oauthController = ret.oauthController;
globalThis.samlSPConfig = ret.spConfig;
globalThis.dsyncController = ret.directorySyncController;
}
return {
connectionController: globalThis.connectionController,
oauthController: globalThis.oauthController,
samlSPConfig: globalThis.samlSPConfig,
dsyncController: globalThis.dsyncController,
};
}

View File

@@ -0,0 +1,72 @@
import type { SAMLSSORecord, OIDCSSORecord } from "@boxyhq/saml-jackson";
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
import { isTeamAdmin } from "@calcom/lib/server/queries/teams";
export const samlDatabaseUrl = process.env.SAML_DATABASE_URL || "";
export const isSAMLLoginEnabled = samlDatabaseUrl.length > 0;
export const samlTenantID = "Cal.com";
export const samlProductID = "Cal.com";
export const samlAudience = "https://saml.cal.com";
export const samlPath = "/api/auth/saml/callback";
export const oidcPath = "/api/auth/oidc";
export const clientSecretVerifier = process.env.SAML_CLIENT_SECRET_VERIFIER || "dummy";
export const hostedCal = Boolean(HOSTED_CAL_FEATURES);
export const tenantPrefix = "team-";
const samlAdmins = (process.env.SAML_ADMINS || "").split(",");
export const isSAMLAdmin = (email: string) => {
for (const admin of samlAdmins) {
if (admin.toLowerCase() === email.toLowerCase() && admin.toUpperCase() === email.toUpperCase()) {
return true;
}
}
return false;
};
export const canAccess = async (user: { id: number; email: string }, teamId: number | null) => {
const { id: userId, email } = user;
if (!isSAMLLoginEnabled) {
return {
message: "To enable this feature, add value for `SAML_DATABASE_URL` and `SAML_ADMINS` to your `.env`",
access: false,
};
}
// Hosted
if (HOSTED_CAL_FEATURES) {
if (teamId === null || !(await isTeamAdmin(userId, teamId))) {
return {
message: "dont_have_permission",
access: false,
};
}
}
// Self-hosted
if (!HOSTED_CAL_FEATURES) {
if (!isSAMLAdmin(email)) {
return {
message: "dont_have_permission",
access: false,
};
}
}
return {
message: "success",
access: true,
};
};
export type SSOConnection = (SAMLSSORecord | OIDCSSORecord) & {
type: string;
acsUrl: string | null;
entityId: string | null;
callbackUrl: string | null;
};

View File

@@ -0,0 +1,111 @@
import createUsersAndConnectToOrg from "@calcom/features/ee/dsync/lib/users/createUsersAndConnectToOrg";
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
import type { PrismaClient } from "@calcom/prisma";
import { IdentityProvider } from "@calcom/prisma/enums";
import { TRPCError } from "@calcom/trpc/server";
import jackson from "./jackson";
import { tenantPrefix, samlProductID } from "./saml";
const getAllAcceptedMemberships = async ({ prisma, email }: { prisma: PrismaClient; email: string }) => {
return await prisma.membership.findMany({
select: {
teamId: true,
},
where: {
accepted: true,
user: {
email,
},
},
});
};
const getVerifiedOrganizationByAutoAcceptEmailDomain = async ({
prisma,
domain,
}: {
prisma: PrismaClient;
domain: string;
}) => {
return await prisma.team.findFirst({
where: {
organizationSettings: {
isOrganizationVerified: true,
orgAutoAcceptEmail: domain,
},
},
select: {
id: true,
},
});
};
export const ssoTenantProduct = async (prisma: PrismaClient, email: string) => {
const { connectionController } = await jackson();
let memberships = await getAllAcceptedMemberships({ prisma, email });
if (!memberships || memberships.length === 0) {
if (!HOSTED_CAL_FEATURES)
throw new TRPCError({
code: "UNAUTHORIZED",
message: "no_account_exists",
});
const domain = email.split("@")[1];
const organization = await getVerifiedOrganizationByAutoAcceptEmailDomain({ prisma, domain });
if (!organization)
throw new TRPCError({
code: "UNAUTHORIZED",
message: "no_account_exists",
});
const organizationId = organization.id;
const createUsersAndConnectToOrgProps = {
emailsToCreate: [email],
organizationId,
identityProvider: IdentityProvider.SAML,
identityProviderId: email,
};
await createUsersAndConnectToOrg(createUsersAndConnectToOrgProps);
memberships = await getAllAcceptedMemberships({ prisma, email });
if (!memberships || memberships.length === 0)
throw new TRPCError({
code: "UNAUTHORIZED",
message: "no_account_exists",
});
}
// Check SSO connections for each team user is a member of
// We'll use the first one we find
const promises = memberships.map(({ teamId }) =>
connectionController.getConnections({
tenant: `${tenantPrefix}${teamId}`,
product: samlProductID,
})
);
const connectionResults = await Promise.allSettled(promises);
const connectionsFound = connectionResults
.filter((result) => result.status === "fulfilled")
.map((result) => (result.status === "fulfilled" ? result.value : []))
.filter((connections) => connections.length > 0);
if (connectionsFound.length === 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Could not find a SSO Identity Provider for your email. Please contact your admin to ensure you have been given access to Cal",
});
}
return {
tenant: connectionsFound[0][0].tenant,
product: samlProductID,
};
};

View File

@@ -0,0 +1,41 @@
"use client";
import { useSession } from "next-auth/react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { MembershipRole } from "@calcom/prisma/enums";
import { AppSkeletonLoader as SkeletonLoader, Meta } from "@calcom/ui";
import { getLayout } from "../../../settings/layouts/SettingsLayout";
import SSOConfiguration from "../components/SSOConfiguration";
const SAMLSSO = () => {
const { t } = useLocale();
const { data, status } = useSession();
const org = data?.user.org;
if (status === "loading")
<SkeletonLoader title={t("sso_saml_heading")} description={t("sso_configuration_description_orgs")} />;
if (!org) {
return null;
}
const isAdminOrOwner = org.role === MembershipRole.OWNER || org.role === MembershipRole.ADMIN;
return !!isAdminOrOwner ? (
<div className="bg-default w-full sm:mx-0 xl:mt-0">
<Meta title={t("sso_configuration")} description={t("sso_configuration_description_orgs")} />
<SSOConfiguration teamId={org.id} />
</div>
) : (
<div className="py-5">
<span className="text-default text-sm">{t("only_admin_can_manage_sso_org")}</span>
</div>
);
};
SAMLSSO.getLayout = getLayout;
export default SAMLSSO;

View File

@@ -0,0 +1,57 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
import { trpc } from "@calcom/trpc/react";
import { AppSkeletonLoader as SkeletonLoader, Meta } from "@calcom/ui";
import { getLayout } from "../../../settings/layouts/SettingsLayout";
import SSOConfiguration from "../components/SSOConfiguration";
const SAMLSSO = () => {
const params = useParamsWithFallback();
const { t } = useLocale();
const router = useRouter();
const teamId = Number(params.id);
const { data: team, isPending, error } = trpc.viewer.teams.get.useQuery({ teamId });
useEffect(() => {
if (!HOSTED_CAL_FEATURES) {
router.push("/404");
}
}, []);
useEffect(
function refactorMeWithoutEffect() {
if (error) {
router.replace("/teams");
}
},
[error]
);
if (isPending) {
return <SkeletonLoader />;
}
if (!team) {
router.push("/404");
return;
}
return (
<div className="bg-default w-full sm:mx-0 xl:mt-0">
<Meta title={t("sso_configuration")} description={t("sso_configuration_description")} />
<SSOConfiguration teamId={teamId} />
</div>
);
};
SAMLSSO.getLayout = getLayout;
export default SAMLSSO;

View File

@@ -0,0 +1,37 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Meta } from "@calcom/ui";
import { getLayout } from "../../../settings/layouts/SettingsLayout";
import SSOConfiguration from "../components/SSOConfiguration";
const SAMLSSO = () => {
const { t } = useLocale();
const router = useRouter();
useEffect(() => {
if (HOSTED_CAL_FEATURES) {
router.push("/404");
}
}, []);
return (
<div className="bg-default w-full sm:mx-0">
<Meta
title={t("sso_configuration")}
description={t("sso_configuration_description")}
borderInShellHeader={true}
/>
<SSOConfiguration teamId={null} />
</div>
);
};
SAMLSSO.getLayout = getLayout;
export default SAMLSSO;

View File

@@ -0,0 +1,44 @@
import { JOIN_DISCORD } from "@calcom/lib/constants";
import { useHasPaidPlan } from "@calcom/lib/hooks/useHasPaidPlan";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Icon, UpgradeTeamsBadge } from "@calcom/ui";
import FreshChatMenuItem from "../lib/freshchat/FreshChatMenuItem";
import HelpscoutMenuItem from "../lib/helpscout/HelpscoutMenuItem";
import ZendeskMenuItem from "../lib/zendesk/ZendeskMenuItem";
interface ContactMenuItem {
onHelpItemSelect: () => void;
}
export default function ContactMenuItem(props: ContactMenuItem) {
const { t } = useLocale();
const { onHelpItemSelect } = props;
const { hasPaidPlan } = useHasPaidPlan();
return (
<>
{hasPaidPlan ? (
<>
<ZendeskMenuItem onHelpItemSelect={onHelpItemSelect} />
<HelpscoutMenuItem onHelpItemSelect={onHelpItemSelect} />
<FreshChatMenuItem onHelpItemSelect={onHelpItemSelect} />
</>
) : (
<div className=" hover:text-emphasis text-default flex w-full cursor-not-allowed justify-between px-5 py-2 pr-4 text-sm font-medium">
{t("premium_support")}
<UpgradeTeamsBadge />
</div>
)}
<a
href={JOIN_DISCORD}
target="_blank"
className="hover:bg-subtle hover:text-emphasis text-default flex w-full px-5 py-2 pr-4 text-sm font-medium">
{t("community_support")}{" "}
<Icon
name="external-link"
className="group-hover:text-subtle text-muted ml-1 mt-px h-4 w-4 flex-shrink-0 ltr:mr-3"
/>
</a>
</>
);
}

View File

@@ -0,0 +1,266 @@
import { useState } from "react";
import { useChat } from "react-live-chat-loader";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { localStorage } from "@calcom/lib/webstorage";
import { trpc } from "@calcom/trpc/react";
import { Button, showToast, TextArea } from "@calcom/ui";
import { Icon } from "@calcom/ui";
import { useFreshChat } from "../lib/freshchat/FreshChatProvider";
import { isFreshChatEnabled } from "../lib/freshchat/FreshChatScript";
import { isInterComEnabled, useIntercom } from "../lib/intercom/useIntercom";
import ContactMenuItem from "./ContactMenuItem";
interface HelpMenuItemProps {
onHelpItemSelect: () => void;
}
export default function HelpMenuItem({ onHelpItemSelect }: HelpMenuItemProps) {
const [rating, setRating] = useState<null | string>(null);
const [showIntercom, setShowIntercom] = useState<boolean>(
localStorage.getItem("showIntercom") === "false" ? false : true
);
const { open, shutdown } = useIntercom();
const [comment, setComment] = useState("");
const [disableSubmit, setDisableSubmit] = useState(true);
const [active, setActive] = useState(false);
const [, loadChat] = useChat();
const { t } = useLocale();
const toggleIntercom = (value: boolean) => {
setShowIntercom(value);
localStorage.setItem("showIntercom", String(value));
};
const { setActive: setFreshChat } = useFreshChat();
const mutation = trpc.viewer.submitFeedback.useMutation({
onSuccess: () => {
setDisableSubmit(true);
showToast("Thank you, feedback submitted", "success");
onHelpItemSelect();
},
});
const onRatingClick = (value: string) => {
setRating(value);
setDisableSubmit(false);
};
const sendFeedback = async (rating: string, comment: string) => {
mutation.mutate({ rating: rating, comment: comment });
};
return (
<div className="bg-default border-default w-full rounded-md">
<div className="w-full py-5">
<p className="text-subtle mb-1 px-5">{t("resources").toUpperCase()}</p>
<a
onClick={() => onHelpItemSelect()}
href="https://cal.com/docs/"
target="_blank"
className="hover:bg-subtle hover:text-emphasis text-default flex w-full px-5 py-2 pr-4 text-sm font-medium"
rel="noreferrer">
{t("documentation")}
<Icon
name="external-link"
className={classNames(
"group-hover:text-subtle text-muted",
"ml-1 mt-px h-4 w-4 flex-shrink-0 ltr:mr-3"
)}
/>
</a>
<div>
<ContactMenuItem onHelpItemSelect={onHelpItemSelect} />
</div>
</div>
<hr className="border-muted" />
<div className="w-full p-5">
<p className="text-subtle mb-1">{t("feedback").toUpperCase()}</p>
<p className="text-default flex w-full py-2 text-sm font-medium">{t("comments")}</p>
<TextArea id="comment" name="comment" rows={3} onChange={(event) => setComment(event.target.value)} />
<div className="my-3 flex justify-end">
<button
className={classNames(
"m-1 items-center justify-center p-1.5 grayscale hover:opacity-100 hover:grayscale-0",
rating === "Extremely unsatisfied" ? "grayscale-0" : "opacity-50"
)}
onClick={() => onRatingClick("Extremely unsatisfied")}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" width="1.5em" height="1.5em">
<path
fill="#FFCC4D"
d="M36 18c0 9.941-8.059 18-18 18S0 27.941 0 18 8.059 0 18 0s18 8.059 18 18"
/>
<path
fill="#664500"
d="M22 27c0 2.763-1.791 3-4 3-2.21 0-4-.237-4-3 0-2.761 1.79-6 4-6 2.209 0 4 3.239 4 6zm8-12c-.124 0-.25-.023-.371-.072-5.229-2.091-7.372-5.241-7.461-5.374-.307-.46-.183-1.081.277-1.387.459-.306 1.077-.184 1.385.274.019.027 1.93 2.785 6.541 4.629.513.206.763.787.558 1.3-.157.392-.533.63-.929.63zM6 15c-.397 0-.772-.238-.929-.629-.205-.513.044-1.095.557-1.3 4.612-1.844 6.523-4.602 6.542-4.629.308-.456.929-.577 1.387-.27.457.308.581.925.275 1.383-.089.133-2.232 3.283-7.46 5.374C6.25 14.977 6.124 15 6 15z"
/>
<path fill="#5DADEC" d="M24 16h4v19l-4-.046V16zM8 35l4-.046V16H8v19z" />
<path
fill="#664500"
d="M14.999 18c-.15 0-.303-.034-.446-.105-3.512-1.756-7.07-.018-7.105 0-.495.249-1.095.046-1.342-.447-.247-.494-.047-1.095.447-1.342.182-.09 4.498-2.197 8.895 0 .494.247.694.848.447 1.342-.176.35-.529.552-.896.552zm14 0c-.15 0-.303-.034-.446-.105-3.513-1.756-7.07-.018-7.105 0-.494.248-1.094.047-1.342-.447-.247-.494-.047-1.095.447-1.342.182-.09 4.501-2.196 8.895 0 .494.247.694.848.447 1.342-.176.35-.529.552-.896.552z"
/>
<ellipse fill="#5DADEC" cx="18" cy="34" rx="18" ry="2" />
<ellipse fill="#E75A70" cx="18" cy="27" rx="3" ry="2" />
</svg>
</button>
<button
className={classNames(
"m-1 items-center justify-center p-1.5 grayscale hover:opacity-100 hover:grayscale-0",
rating === "Unsatisfied" ? "grayscale-0" : "opacity-50"
)}
onClick={() => onRatingClick("Unsatisfied")}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" width="1.5em" height="1.5em">
<path
fill="#FFCC4D"
d="M36 18c0 9.941-8.059 18-18 18-9.94 0-18-8.059-18-18C0 8.06 8.06 0 18 0c9.941 0 18 8.06 18 18"
/>
<ellipse fill="#664500" cx="11.5" cy="14.5" rx="2.5" ry="3.5" />
<ellipse fill="#664500" cx="24.5" cy="14.5" rx="2.5" ry="3.5" />
<path
fill="#664500"
d="M8.665 27.871c.178.161.444.171.635.029.039-.029 3.922-2.9 8.7-2.9 4.766 0 8.662 2.871 8.7 2.9.191.142.457.13.635-.029.177-.16.217-.424.094-.628C27.3 27.029 24.212 22 18 22s-9.301 5.028-9.429 5.243c-.123.205-.084.468.094.628z"
/>
</svg>
</button>
<button
className={classNames(
"m-1 items-center justify-center p-1.5 grayscale hover:opacity-100 hover:grayscale-0",
rating === "Satisfied" ? "grayscale-0" : "opacity-50"
)}
onClick={() => onRatingClick("Satisfied")}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" width="1.5em" height="1.5em">
<path
fill="#FFCC4D"
d="M36 18c0 9.941-8.059 18-18 18-9.94 0-18-8.059-18-18C0 8.06 8.06 0 18 0c9.941 0 18 8.06 18 18"
/>
<path
fill="#664500"
d="M28.457 17.797c-.06-.135-1.499-3.297-4.457-3.297-2.957 0-4.397 3.162-4.457 3.297-.092.207-.032.449.145.591.175.142.426.147.61.014.012-.009 1.262-.902 3.702-.902 2.426 0 3.674.881 3.702.901.088.066.194.099.298.099.11 0 .221-.037.312-.109.177-.142.238-.386.145-.594zm-12 0c-.06-.135-1.499-3.297-4.457-3.297-2.957 0-4.397 3.162-4.457 3.297-.092.207-.032.449.144.591.176.142.427.147.61.014.013-.009 1.262-.902 3.703-.902 2.426 0 3.674.881 3.702.901.088.066.194.099.298.099.11 0 .221-.037.312-.109.178-.142.237-.386.145-.594zM18 22c-3.623 0-6.027-.422-9-1-.679-.131-2 0-2 2 0 4 4.595 9 11 9 6.404 0 11-5 11-9 0-2-1.321-2.132-2-2-2.973.578-5.377 1-9 1z"
/>
<path fill="#FFF" d="M9 23s3 1 9 1 9-1 9-1-2 4-9 4-9-4-9-4z" />
</svg>
</button>
<button
className={classNames(
"m-1 items-center justify-center p-1.5 grayscale hover:opacity-100 hover:grayscale-0",
rating === "Extremely satisfied" ? "grayscale-0" : "opacity-50"
)}
onClick={() => onRatingClick("Extremely satisfied")}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" width="1.5em" height="1.5em">
<path
fill="#FFCC4D"
d="M36 18c0 9.941-8.059 18-18 18S0 27.941 0 18 8.059 0 18 0s18 8.059 18 18"
/>
<path
fill="#664500"
d="M18 21c-3.623 0-6.027-.422-9-1-.679-.131-2 0-2 2 0 4 4.595 9 11 9 6.404 0 11-5 11-9 0-2-1.321-2.132-2-2-2.973.578-5.377 1-9 1z"
/>
<path fill="#FFF" d="M9 22s3 1 9 1 9-1 9-1-2 4-9 4-9-4-9-4z" />
<path
fill="#E95F28"
d="M15.682 4.413l-4.542.801L8.8.961C8.542.492 8.012.241 7.488.333c-.527.093-.937.511-1.019 1.039l-.745 4.797-4.542.801c-.535.094-.948.525-1.021 1.064s.211 1.063.703 1.297l4.07 1.932-.748 4.812c-.083.536.189 1.064.673 1.309.179.09.371.133.562.133.327 0 .65-.128.891-.372l3.512-3.561 4.518 2.145c.49.232 1.074.123 1.446-.272.372-.395.446-.984.185-1.459L13.625 9.73l3.165-3.208c.382-.387.469-.977.217-1.459-.254-.482-.793-.743-1.325-.65zm4.636 0l4.542.801L27.2.961c.258-.469.788-.72 1.312-.628.526.093.936.511 1.018 1.039l.745 4.797 4.542.801c.536.094.949.524 1.021 1.063s-.211 1.063-.703 1.297l-4.07 1.932.748 4.812c.083.536-.189 1.064-.673 1.309-.179.09-.371.133-.562.133-.327 0-.65-.128-.891-.372l-3.512-3.561-4.518 2.145c-.49.232-1.074.123-1.446-.272-.372-.395-.446-.984-.185-1.459l2.348-4.267-3.165-3.208c-.382-.387-.469-.977-.217-1.459.255-.482.794-.743 1.326-.65z"
/>
</svg>
</button>
</div>
<div className="my-2 flex justify-end">
<Button
disabled={disableSubmit}
loading={mutation.isPending}
onClick={async () => {
if (rating && comment) {
await sendFeedback(rating, comment);
}
}}>
{t("submit")}
</Button>
</div>
{mutation.isError && (
<div className="bg-error mb-4 flex p-4 text-sm text-red-700">
<div className="flex-shrink-0">
<Icon name="triangle-alert" className="h-5 w-5" />
</div>
<div className="ml-3 flex-grow">
<p className="font-medium">{t("feedback_error")}</p>
<p>{t("please_try_again")}</p>
</div>
</div>
)}
</div>
{/* visible on desktop */}
<div className="text-subtle bg-muted hidden w-full flex-col p-5 md:block">
<p className="">{showIntercom ? t("no_support_needed") : t("specific_issue")}</p>
<button
className="hover:text-emphasis text-defualt font-medium underline"
onClick={async () => {
setActive(true);
if (showIntercom) {
if (isFreshChatEnabled) {
setFreshChat(false);
} else if (isInterComEnabled) {
shutdown();
toggleIntercom(false);
}
} else {
if (isFreshChatEnabled) {
setFreshChat(true);
} else if (isInterComEnabled) {
await open();
toggleIntercom(true);
} else {
loadChat({ open: true });
}
}
onHelpItemSelect();
}}>
{showIntercom ? t("hide_support") : t("contact_support")}
</button>
<span> {t("or").toLowerCase()} </span>
<a
onClick={() => onHelpItemSelect()}
className="hover:text-emphasis text-defualt font-medium underline"
href="https://cal.com/docs"
target="_blank"
rel="noreferrer">
{t("browse_our_docs")}
</a>
.
</div>
{/* visible on mobile */}
<div className="text-subtle bg-muted w-full p-5 md:hidden">
<p className="">{t("specific_issue")}</p>
<button
className="hover:text-emphasis text-defualt font-medium underline"
onClick={async () => {
setActive(true);
if (isFreshChatEnabled) {
setFreshChat(true);
} else if (isInterComEnabled) {
await open();
} else {
loadChat({ open: true });
}
onHelpItemSelect();
}}>
{t("contact_support")}
</button>
<span> {t("or").toLowerCase()} </span>
<a
onClick={() => onHelpItemSelect()}
className="hover:text-emphasis text-defualt font-medium underline"
href="https://cal.com/docs"
target="_blank"
rel="noreferrer">
{t("browse_our_docs")}
</a>
.
</div>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useFreshChat } from "./FreshChatProvider";
import { isFreshChatEnabled } from "./FreshChatScript";
interface FreshChatMenuItemProps {
onHelpItemSelect: () => void;
}
export default function FreshChatMenuItem(props: FreshChatMenuItemProps) {
const { onHelpItemSelect } = props;
const { t } = useLocale();
const { setActive } = useFreshChat();
if (!isFreshChatEnabled) return null;
return (
<>
<button
onClick={() => {
setActive(true);
onHelpItemSelect();
}}
className="hover:bg-subtle hover:text-emphasis text-default flex w-full px-5 py-2 pr-4 text-sm font-medium">
{t("contact_support")}
</button>
</>
);
}

View File

@@ -0,0 +1,25 @@
import type { ReactNode, Dispatch, SetStateAction } from "react";
import { createContext, useState, useContext } from "react";
import FreshChatScript from "./FreshChatScript";
type FreshChatContextType = { active: boolean; setActive: Dispatch<SetStateAction<boolean>> };
const FreshChatContext = createContext<FreshChatContextType>({ active: false, setActive: () => undefined });
interface FreshChatProviderProps {
children: ReactNode;
}
export const useFreshChat = () => useContext(FreshChatContext);
export default function FreshChatProvider(props: FreshChatProviderProps) {
const [active, setActive] = useState(false);
return (
<FreshChatContext.Provider value={{ active, setActive }}>
{props.children}
{active && <FreshChatScript />}
</FreshChatContext.Provider>
);
}

View File

@@ -0,0 +1,43 @@
import Script from "next/script";
import { z } from "zod";
import { trpc } from "@calcom/trpc/react";
const nonEmptySchema = z.string().min(1);
declare global {
interface Window {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fcWidget: any;
}
}
// eslint-disable-next-line turbo/no-undeclared-env-vars
const host = process.env.NEXT_PUBLIC_FRESHCHAT_HOST;
// eslint-disable-next-line turbo/no-undeclared-env-vars
const token = process.env.NEXT_PUBLIC_FRESHCHAT_TOKEN;
export const isFreshChatEnabled =
nonEmptySchema.safeParse(host).success && nonEmptySchema.safeParse(token).success;
export default function FreshChatScript() {
const { data } = trpc.viewer.me.useQuery();
return (
<Script
id="fresh-chat-sdk"
src="https://wchat.freshchat.com/js/widget.js"
onLoad={() => {
window.fcWidget.init({
token,
host,
externalId: data?.id,
lastName: data?.name,
email: data?.email,
meta: {
username: data?.username,
},
open: true,
});
}}
/>
);
}

View File

@@ -0,0 +1,37 @@
import { useState } from "react";
import { HelpScout, useChat } from "react-live-chat-loader";
import { useLocale } from "@calcom/lib/hooks/useLocale";
interface HelpscoutMenuItemProps {
onHelpItemSelect: () => void;
}
export default function HelpscoutMenuItem(props: HelpscoutMenuItemProps) {
const { onHelpItemSelect } = props;
const { t } = useLocale();
const [active, setActive] = useState(false);
const [, loadChat] = useChat();
function handleClick() {
setActive(true);
loadChat({ open: true });
onHelpItemSelect();
}
// eslint-disable-next-line turbo/no-undeclared-env-vars
if (!process.env.NEXT_PUBLIC_HELPSCOUT_KEY) return null;
return (
<>
<button
onClick={handleClick}
className="hover:bg-subtle hover:text-emphasis text-default flex w-full px-5 py-2 pr-4 text-sm font-medium">
{t("contact_support")}
</button>
{active && <HelpScout color="#292929" icon="message" horizontalPosition="right" zIndex="1" />}
</>
);
}

View File

@@ -0,0 +1,10 @@
import { FC } from "react";
import { LiveChatLoaderProvider } from "react-live-chat-loader";
const Provider: FC<{ children: React.ReactNode }> = ({ children }) => (
<LiveChatLoaderProvider providerKey={process.env.NEXT_PUBLIC_HELPSCOUT_KEY || ""} provider="helpScout">
<>{children}</>
</LiveChatLoaderProvider>
);
export default Provider;

View File

@@ -0,0 +1,8 @@
import dynamic from "next/dynamic";
import { Fragment } from "react";
const DynamicHelpscoutProvider = process.env.NEXT_PUBLIC_HELPSCOUT_KEY
? dynamic(() => import("./provider"))
: Fragment;
export default DynamicHelpscoutProvider;

View File

@@ -0,0 +1,8 @@
import { FC } from "react";
import { IntercomProvider } from "react-use-intercom";
const Provider: FC<{ children: React.ReactNode }> = ({ children }) => (
<IntercomProvider appId={process.env.NEXT_PUBLIC_INTERCOM_APP_ID || ""}>{children}</IntercomProvider>
);
export default Provider;

View File

@@ -0,0 +1,8 @@
import dynamic from "next/dynamic";
import { Fragment } from "react";
const DynamicIntercomProvider = process.env.NEXT_PUBLIC_INTERCOM_APP_ID
? dynamic(() => import("./provider"))
: Fragment;
export default DynamicIntercomProvider;

View File

@@ -0,0 +1,120 @@
// eslint-disable-next-line no-restricted-imports
import { noop } from "lodash";
import { useIntercom as useIntercomLib } from "react-use-intercom";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
import { useHasTeamPlan, useHasPaidPlan } from "@calcom/lib/hooks/useHasPaidPlan";
import { trpc } from "@calcom/trpc/react";
// eslint-disable-next-line turbo/no-undeclared-env-vars
export const isInterComEnabled = z.string().min(1).safeParse(process.env.NEXT_PUBLIC_INTERCOM_APP_ID).success;
const useIntercomHook = isInterComEnabled
? useIntercomLib
: () => {
return {
boot: noop,
show: noop,
shutdown: noop,
};
};
export const useIntercom = () => {
const hookData = useIntercomHook();
const { data } = trpc.viewer.me.useQuery();
const { hasPaidPlan } = useHasPaidPlan();
const { hasTeamPlan } = useHasTeamPlan();
const boot = async () => {
if (!data) return;
let userHash;
const req = await fetch(`/api/intercom-hash`);
const res = await req.json();
if (res?.hash) {
userHash = res.hash;
}
hookData.boot({
...(data && data?.name && { name: data.name }),
...(data && data?.email && { email: data.email }),
...(data && data?.id && { userId: data.id }),
createdAt: String(dayjs(data?.createdDate).unix()),
...(userHash && { userHash }),
customAttributes: {
//keys should be snake cased
user_name: data?.username,
link: `${WEBSITE_URL}/${data?.username}`,
admin_link: `${WEBAPP_URL}/settings/admin/users/${data?.id}/edit`,
impersonate_user: `${WEBAPP_URL}/settings/admin/impersonation?username=${
data?.email ?? data?.username
}`,
identity_provider: data?.identityProvider,
timezone: data?.timeZone,
locale: data?.locale,
has_paid_plan: hasPaidPlan,
has_team_plan: hasTeamPlan,
metadata: data?.metadata,
completed_onboarding: data.completedOnboarding,
is_logged_in: !!data,
sum_of_bookings: data?.sumOfBookings,
sum_of_calendars: data?.sumOfCalendars,
sum_of_teams: data?.sumOfTeams,
has_orgs_plan: !!data?.organizationId,
organization: data?.organization?.slug,
sum_of_event_types: data?.sumOfEventTypes,
sum_of_team_event_types: data?.sumOfTeamEventTypes,
is_premium: data?.isPremium,
},
});
};
const open = async () => {
let userHash;
const req = await fetch(`/api/intercom-hash`);
const res = await req.json();
if (res?.hash) {
userHash = res.hash;
}
hookData.boot({
...(data && data?.name && { name: data.name }),
...(data && data?.email && { email: data.email }),
...(data && data?.id && { userId: data.id }),
createdAt: String(dayjs(data?.createdDate).unix()),
...(userHash && { userHash }),
customAttributes: {
//keys should be snake cased
user_name: data?.username,
link: `${WEBSITE_URL}/${data?.username}`,
admin_link: `${WEBAPP_URL}/settings/admin/users/${data?.id}/edit`,
impersonate_user: `${WEBAPP_URL}/settings/admin/impersonation?username=${
data?.email ?? data?.username
}`,
identity_provider: data?.identityProvider,
timezone: data?.timeZone,
locale: data?.locale,
has_paid_plan: hasPaidPlan,
has_team_plan: hasTeamPlan,
metadata: data?.metadata,
completed_onboarding: data?.completedOnboarding,
is_logged_in: !!data,
sum_of_bookings: data?.sumOfBookings,
sum_of_calendars: data?.sumOfCalendars,
sum_of_teams: data?.sumOfTeams,
has_orgs_plan: !!data?.organizationId,
organization: data?.organization?.slug,
sum_of_event_types: data?.sumOfEventTypes,
sum_of_team_event_types: data?.sumOfTeamEventTypes,
is_premium: data?.isPremium,
},
});
hookData.show();
};
return { ...hookData, open, boot };
};
export default useIntercom;

View File

@@ -0,0 +1,35 @@
import Script from "next/script";
import { useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
// eslint-disable-next-line turbo/no-undeclared-env-vars
const ZENDESK_KEY = process.env.NEXT_PUBLIC_ZENDESK_KEY;
interface ZendeskMenuItemProps {
onHelpItemSelect: () => void;
}
export default function ZendeskMenuItem(props: ZendeskMenuItemProps) {
const { onHelpItemSelect } = props;
const [active, setActive] = useState(false);
const { t } = useLocale();
if (!ZENDESK_KEY) return null;
return (
<>
<button
onClick={() => {
setActive(true);
onHelpItemSelect();
}}
className="hover:bg-subtle hover:text-emphasis text-default flex w-full px-5 py-2 pr-4 text-sm font-medium">
{t("contact_support")}
</button>
{active && (
<Script id="ze-snippet" src={`https://static.zdassets.com/ekr/snippet.js?key=${ZENDESK_KEY}`} />
)}
</>
);
}

Some files were not shown because too many files have changed in this diff Show More