first commit
This commit is contained in:
42
calcom/packages/features/ee/LICENSE
Normal file
42
calcom/packages/features/ee/LICENSE
Normal 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.
|
||||
38
calcom/packages/features/ee/README.md
Normal file
38
calcom/packages/features/ee/README.md
Normal 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.
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
10
calcom/packages/features/ee/api-keys/lib/apiKeys.ts
Normal file
10
calcom/packages/features/ee/api-keys/lib/apiKeys.ts
Normal 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,
|
||||
];
|
||||
26
calcom/packages/features/ee/api-keys/lib/findValidApiKey.ts
Normal file
26
calcom/packages/features/ee/api-keys/lib/findValidApiKey.ts
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
52
calcom/packages/features/ee/common/server/checkLicense.ts
Normal file
52
calcom/packages/features/ee/common/server/checkLicense.ts
Normal 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;
|
||||
129
calcom/packages/features/ee/components/BrandColorsForm.tsx
Normal file
129
calcom/packages/features/ee/components/BrandColorsForm.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
34
calcom/packages/features/ee/components/PoweredBy.tsx
Normal file
34
calcom/packages/features/ee/components/PoweredBy.tsx
Normal 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;
|
||||
@@ -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 || "";
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
126
calcom/packages/features/ee/dsync/components/CreateDirectory.tsx
Normal file
126
calcom/packages/features/ee/dsync/components/CreateDirectory.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
116
calcom/packages/features/ee/dsync/components/GroupNameCell.tsx
Normal file
116
calcom/packages/features/ee/dsync/components/GroupNameCell.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
22
calcom/packages/features/ee/dsync/lib/directoryProviders.ts
Normal file
22
calcom/packages/features/ee/dsync/lib/directoryProviders.ts
Normal 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",
|
||||
},
|
||||
];
|
||||
191
calcom/packages/features/ee/dsync/lib/handleGroupEvents.ts
Normal file
191
calcom/packages/features/ee/dsync/lib/handleGroupEvents.ts
Normal 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;
|
||||
93
calcom/packages/features/ee/dsync/lib/handleUserEvents.ts
Normal file
93
calcom/packages/features/ee/dsync/lib/handleUserEvents.ts
Normal 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;
|
||||
@@ -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;
|
||||
11
calcom/packages/features/ee/dsync/lib/removeUserFromOrg.ts
Normal file
11
calcom/packages/features/ee/dsync/lib/removeUserFromOrg.ts
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
61
calcom/packages/features/ee/dsync/page/team-dsync-view.tsx
Normal file
61
calcom/packages/features/ee/dsync/page/team-dsync-view.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
1
calcom/packages/features/ee/index.ts
Normal file
1
calcom/packages/features/ee/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "./common/server/checkLicense";
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
}
|
||||
73
calcom/packages/features/ee/organizations/README.md
Normal file
73
calcom/packages/features/ee/organizations/README.md
Normal 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.
|
||||
38
calcom/packages/features/ee/organizations/api/subteams.ts
Normal file
38
calcom/packages/features/ee/organizations/api/subteams.ts
Normal 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) }),
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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}`, "");
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { CreateANewOrganizationForm } from "./CreateANewOrganizationForm";
|
||||
export { AboutOrganizationForm } from "./AboutOrganizationForm";
|
||||
export { AddNewTeamsForm } from "./AddNewTeamsForm";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
108
calcom/packages/features/ee/organizations/lib/orgDomains.test.ts
Normal file
108
calcom/packages/features/ee/organizations/lib/orgDomains.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
161
calcom/packages/features/ee/organizations/lib/orgDomains.ts
Normal file
161
calcom/packages/features/ee/organizations/lib/orgDomains.ts
Normal 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;
|
||||
}
|
||||
6
calcom/packages/features/ee/organizations/lib/types.ts
Normal file
6
calcom/packages/features/ee/organizations/lib/types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface NewOrganizationFormValues {
|
||||
name: string;
|
||||
slug: string;
|
||||
logo: string;
|
||||
adminEmail: string;
|
||||
}
|
||||
8
calcom/packages/features/ee/organizations/lib/utils.ts
Normal file
8
calcom/packages/features/ee/organizations/lib/utils.ts
Normal 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];
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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")}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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: {},
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
22
calcom/packages/features/ee/package.json
Normal file
22
calcom/packages/features/ee/package.json
Normal 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": "*"
|
||||
}
|
||||
}
|
||||
173
calcom/packages/features/ee/payments/api/webhook.ts
Normal file
173
calcom/packages/features/ee/payments/api/webhook.ts
Normal 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 });
|
||||
}
|
||||
216
calcom/packages/features/ee/payments/components/Payment.tsx
Normal file
216
calcom/packages/features/ee/payments/components/Payment.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
183
calcom/packages/features/ee/payments/components/PaymentPage.tsx
Normal file
183
calcom/packages/features/ee/payments/components/PaymentPage.tsx
Normal 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">
|
||||
​
|
||||
</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;
|
||||
@@ -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 "";
|
||||
}
|
||||
148
calcom/packages/features/ee/payments/pages/payment.tsx
Normal file
148
calcom/packages/features/ee/payments/pages/payment.tsx
Normal 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,
|
||||
},
|
||||
};
|
||||
};
|
||||
18
calcom/packages/features/ee/payments/server/stripe.ts
Normal file
18
calcom/packages/features/ee/payments/server/stripe.ts
Normal 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;
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
1
calcom/packages/features/ee/platform/components/index.ts
Normal file
1
calcom/packages/features/ee/platform/components/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { CreateANewPlatformForm } from "./CreateANewPlatformForm";
|
||||
172
calcom/packages/features/ee/sso/components/ConnectionInfo.tsx
Normal file
172
calcom/packages/features/ee/sso/components/ConnectionInfo.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
166
calcom/packages/features/ee/sso/components/OIDCConnection.tsx
Normal file
166
calcom/packages/features/ee/sso/components/OIDCConnection.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
128
calcom/packages/features/ee/sso/components/SAMLConnection.tsx
Normal file
128
calcom/packages/features/ee/sso/components/SAMLConnection.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
63
calcom/packages/features/ee/sso/lib/jackson.ts
Normal file
63
calcom/packages/features/ee/sso/lib/jackson.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
72
calcom/packages/features/ee/sso/lib/saml.ts
Normal file
72
calcom/packages/features/ee/sso/lib/saml.ts
Normal 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;
|
||||
};
|
||||
111
calcom/packages/features/ee/sso/lib/sso.ts
Normal file
111
calcom/packages/features/ee/sso/lib/sso.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
41
calcom/packages/features/ee/sso/page/orgs-sso-view.tsx
Normal file
41
calcom/packages/features/ee/sso/page/orgs-sso-view.tsx
Normal 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;
|
||||
57
calcom/packages/features/ee/sso/page/teams-sso-view.tsx
Normal file
57
calcom/packages/features/ee/sso/page/teams-sso-view.tsx
Normal 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;
|
||||
37
calcom/packages/features/ee/sso/page/user-sso-view.tsx
Normal file
37
calcom/packages/features/ee/sso/page/user-sso-view.tsx
Normal 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;
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
266
calcom/packages/features/ee/support/components/HelpMenuItem.tsx
Normal file
266
calcom/packages/features/ee/support/components/HelpMenuItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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" />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
120
calcom/packages/features/ee/support/lib/intercom/useIntercom.ts
Normal file
120
calcom/packages/features/ee/support/lib/intercom/useIntercom.ts
Normal 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;
|
||||
@@ -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
Reference in New Issue
Block a user