feat: SCIM 2.0 Automatic User provisioning (#11169)

Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
This commit is contained in:
Kiran K
2024-03-06 16:34:21 -07:00
committed by GitHub
co-authored by Alex van Andel Keith Williams Peer Richelsen Joe Au-Yeung Joe Au-Yeung sean-brydon Udit Takkar
parent dfd683ffc4
commit 1064e1181d
32 changed files with 1138 additions and 158 deletions
@@ -0,0 +1,130 @@
import type { DirectorySyncEvent, DirectorySyncRequest, User } from "@boxyhq/saml-jackson";
import type { NextApiRequest, NextApiResponse } from "next";
import createUserAndInviteToOrg from "@calcom/features/ee/dsync/lib/createUserAndInviteToOrg";
import inviteExistingUserToOrg from "@calcom/features/ee/dsync/lib/inviteExistingUserToOrg";
import removeUserFromOrg from "@calcom/features/ee/dsync/lib/removeUserFromOrg";
import jackson from "@calcom/features/ee/sso/lib/jackson";
import { getTranslation } from "@calcom/lib/server/i18n";
import prisma from "@calcom/prisma";
import type { UserWithMembership } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/utils";
import { getTeamOrThrow } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/utils";
// This is the handler for the SCIM API requests
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { dsyncController } = await jackson();
const { method, query, body } = req;
const [directoryId, path, resourceId] = query.directory as string[];
// Handle the SCIM API requests
const request: DirectorySyncRequest = {
method: method as string,
directoryId,
resourceId,
apiSecret: extractAuthToken(req),
resourceType: path === "Users" ? "users" : "groups",
body: body ? JSON.parse(body) : undefined,
query: {
count: req.query.count ? parseInt(req.query.count as string) : undefined,
startIndex: req.query.startIndex ? parseInt(req.query.startIndex as string) : undefined,
filter: req.query.filter as string,
},
};
const { status, data } = await dsyncController.requests.handle(request, handleEvents);
res.status(status).json(data);
}
// Fetch the auth token from the request headers
export const extractAuthToken = (req: NextApiRequest): string | null => {
const authHeader = req.headers.authorization || null;
return authHeader ? authHeader.split(" ")[1] : null;
};
// Handle the SCIM events
const handleEvents = async (event: DirectorySyncEvent) => {
console.log("Received event", event);
const dSyncData = await prisma.dSyncData.findFirst({
where: {
directoryId: event.directory_id,
},
select: {
id: true,
orgId: true,
},
});
if (!dSyncData) {
throw new Error("Directory sync data not found");
}
const { orgId } = dSyncData;
if (!orgId) {
throw new Error(`Org ID not found for dsync ${dSyncData.id}`);
}
if (event.event === "user.created" || event.event === "user.updated") {
const eventData = event.data as User;
const userEmail = eventData.email;
const translation = await getTranslation("en", "common");
// Check if user exists in DB
const user = await prisma.user.findFirst({
where: {
email: userEmail,
},
select: {
id: true,
email: true,
username: true,
organizationId: true,
completedOnboarding: true,
identityProvider: true,
profiles: true,
password: {
select: {
hash: true,
},
},
},
});
// User is already a part of that org
if (user?.organizationId && eventData.active) {
return;
}
const org = await getTeamOrThrow(orgId);
if (!org) {
throw new Error("Org not found");
}
if (user) {
// If data.active is true then provision the user into the org
eventData.active
? await inviteExistingUserToOrg({
user: user as UserWithMembership,
org,
translation,
})
: // If data.active is false then remove the user from the org
await removeUserFromOrg({
userId: user.id,
orgId,
});
// If user is not in DB, create user and add to the org
} else {
await createUserAndInviteToOrg({
userEmail,
org,
translation,
});
}
}
};
+4
View File
@@ -0,0 +1,4 @@
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { dsyncRouter } from "@calcom/trpc/server/routers/viewer/dsync/_router";
export default createNextApiHandler(dsyncRouter);
@@ -0,0 +1,9 @@
import DirectorySyncTeamView from "@calcom/features/ee/dsync/page/team-dsync-view";
import type { CalPageWrapper } from "@components/PageWrapper";
import PageWrapper from "@components/PageWrapper";
const Page = DirectorySyncTeamView as CalPageWrapper;
Page.PageWrapper = PageWrapper;
export default Page;
+42 -1
View File
@@ -1,22 +1,63 @@
"use client";
import Head from "next/head";
import { useRouter } from "next/navigation";
import { z } from "zod";
import { CreateANewTeamForm } from "@calcom/features/ee/teams/components";
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import type { RouterOutputs } from "@calcom/trpc/react";
import { WizardLayout } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
const querySchema = z.object({
returnTo: z.string().optional(),
slug: z.string().optional(),
});
const CreateNewTeamPage = () => {
const { t } = useLocale();
const params = useParamsWithFallback();
const parsedQuery = querySchema.safeParse(params);
const router = useRouter();
const telemetry = useTelemetry();
const isTeamBillingEnabledClient = !!process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY && HOSTED_CAL_FEATURES;
const flag = isTeamBillingEnabledClient
? {
telemetryEvent: telemetryEventTypes.team_checkout_session_created,
submitLabel: "checkout",
}
: {
telemetryEvent: telemetryEventTypes.team_created,
submitLabel: "continue",
};
const returnToParam =
(parsedQuery.success ? getSafeRedirectUrl(parsedQuery.data.returnTo) : "/teams") || "/teams";
const onSuccess = (data: RouterOutputs["viewer"]["teams"]["create"]) => {
telemetry.event(flag.telemetryEvent);
router.push(data.url);
};
return (
<>
<Head>
<title>{t("create_new_team")}</title>
<meta name="description" content={t("create_new_team_description")} />
</Head>
<CreateANewTeamForm />
<CreateANewTeamForm
slug={parsedQuery.success ? parsedQuery.data.slug : ""}
onCancel={() => router.push(returnToParam)}
submitLabel={flag.submitLabel}
onSuccess={onSuccess}
/>
</>
);
};
+19 -1
View File
@@ -2142,6 +2142,24 @@
"scheduling_for_your_team":"Workflow automation",
"scheduling_for_your_team_description":"Schedule for your team with collective and round-robin scheduling",
"no_members_found": "No members found",
"directory_sync" : "Directory Sync",
"directory_name": "Directory Name",
"directory_provider": "Directory Provider",
"directory_scim_url": "SCIM Base URL",
"directory_scim_token": "SCIM Bearer Token",
"directory_scim_url_copied": "SCIM Base URL copied",
"directory_scim_token_copied": "SCIM Bearer Token copied",
"directory_sync_info_description": "Your Identity Provider will ask for the following information to configure SCIM. Follow the instructions to finish the setup.",
"directory_sync_configure" : "Configure Directory Sync",
"directory_sync_configure_description" : "Choose an identity provider to configure directory for your team.",
"directory_sync_title": "Configure an identity provider to get started with SCIM.",
"directory_sync_created": "Directory sync connection created.",
"directory_sync_description": "Provision and de-provision users with your directory provider.",
"directory_sync_deleted": "Directory sync connection deleted.",
"directory_sync_delete_connection": "Delete Connection",
"directory_sync_delete_title": "Delete Directory Sync Connection",
"directory_sync_delete_description": "Are you sure you want to delete this directory sync connection?",
"directory_sync_delete_confirmation": "This action cannot be undone. This will permanently delete the directory sync connection.",
"event_setup_length_error":"Event Setup: The duration must be at least 1 minute.",
"availability_schedules":"Availability Schedules",
"unauthorized":"Unauthorized",
@@ -2277,4 +2295,4 @@
"confirm_email_description": "We sent an email to <strong>{{email}}</strong>. Click the link in the email to verify this address.",
"send_event_details_to": "Send event details to",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
}
@@ -0,0 +1,105 @@
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 { AlertTriangle, Trash2 } from "@calcom/ui/components/icon";
import CreateDirectory from "./CreateDirectory";
import DirectoryInfo from "./DirectoryInfo";
const ConfigureDirectorySync = ({ orgId }: { orgId: number | null }) => {
const { t } = useLocale();
const utils = trpc.useContext();
const [deleteDirectoryOpen, setDeleteDirectoryOpen] = useState(false);
const { data, isLoading, isError, error } = trpc.viewer.dsync.get.useQuery({ orgId });
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 ? data[0] : null;
const onDeleteConfirmation = (e: Event | React.MouseEvent<HTMLElement, MouseEvent>) => {
e.preventDefault();
if (!directory) {
return;
}
deleteMutation.mutate({ orgId, directoryId: directory.id });
};
if (error || isError) {
return (
<div>
<EmptyScreen
headline="Error"
description={error.message || "Error getting dsync data"}
Icon={AlertTriangle}
/>
</div>
);
}
return (
<div>
{!directory ? (
<CreateDirectory orgId={orgId} />
) : (
<>
<DirectoryInfo directory={directory} />
<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={Trash2}>
{t("directory_sync_delete_connection")}
</Button>
</DialogTrigger>
<DialogContent
title={t("directory_sync_delete_title")}
description={t("directory_sync_delete_description")}
type="creation"
Icon={AlertTriangle}>
<>
<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}>
{t("directory_sync_delete_connection")}
</Button>
</DialogFooter>
</>
</DialogContent>
</Dialog>
</>
)}
</div>
);
};
export default ConfigureDirectorySync;
@@ -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.useContext();
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,
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}>
{t("save")}
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
</>
);
};
export default CreateDirectory;
@@ -0,0 +1,63 @@
import type { Directory } from "@boxyhq/saml-jackson";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, showToast, Label, Tooltip } from "@calcom/ui";
import { Clipboard } from "@calcom/ui/components/icon";
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;
@@ -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;
@@ -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",
},
];
@@ -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,
existingUsersWithMembersips: [user],
language: translation,
isOrg: true,
teamId: org.id,
isAutoJoin: true,
currentUserParentTeamName: org?.parent?.name,
});
};
export default inviteExistingUserToOrg;
@@ -0,0 +1,11 @@
import removeMember from "@calcom/features/ee/teams/lib/removeMember";
const removeUserFromOrg = async ({ userId, orgId }: { userId: number; orgId: number }) => {
await removeMember({
memberId: userId,
teamId: orgId,
isOrg: true,
});
};
export default removeUserFromOrg;
@@ -0,0 +1,48 @@
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 } from "@calcom/ui";
import { 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 orgId={currentOrg?.id || null} />}
</div>
);
};
DirectorySync.getLayout = getLayout;
export default DirectorySync;
+11 -1
View File
@@ -3,6 +3,7 @@ import type {
IOAuthController,
ISPSSOConfig,
JacksonOption,
IDirectorySyncController,
} from "@boxyhq/saml-jackson";
import { WEBAPP_URL } from "@calcom/lib/constants";
@@ -15,6 +16,7 @@ const opts: JacksonOption = {
samlPath,
samlAudience,
oidcPath,
scimPath: "/api/scim/v2.0",
db: {
engine: "sql",
type: "postgres",
@@ -34,20 +36,28 @@ declare global {
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) {
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,
};
}
@@ -1,61 +1,39 @@
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
import slugify from "@calcom/lib/slugify";
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import { trpc } from "@calcom/trpc/react";
import { Alert, Button, Form, TextField } from "@calcom/ui";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Alert, Button, Form, TextField, DialogFooter } from "@calcom/ui";
import { ArrowRight } from "@calcom/ui/components/icon";
import { useOrgBranding } from "../../organizations/context/provider";
import { subdomainSuffix } from "../../organizations/lib/orgDomains";
import type { NewTeamFormValues } from "../lib/types";
const querySchema = z.object({
returnTo: z.string().optional(),
slug: z.string().optional(),
});
interface CreateANewTeamFormProps {
onCancel: () => void;
submitLabel: string;
onSuccess: (data: RouterOutputs["viewer"]["teams"]["create"]) => void;
inDialog?: boolean;
slug?: string;
}
const isTeamBillingEnabledClient = !!process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY && HOSTED_CAL_FEATURES;
const flag = isTeamBillingEnabledClient
? {
telemetryEvent: telemetryEventTypes.team_checkout_session_created,
submitLabel: "checkout",
}
: {
telemetryEvent: telemetryEventTypes.team_created,
submitLabel: "continue",
};
export const CreateANewTeamForm = () => {
export const CreateANewTeamForm = (props: CreateANewTeamFormProps) => {
const { inDialog, onCancel, slug, submitLabel, onSuccess } = props;
const { t, isLocaleReady } = useLocale();
const router = useRouter();
const telemetry = useTelemetry();
const params = useParamsWithFallback();
const parsedQuery = querySchema.safeParse(params);
const [serverErrorMessage, setServerErrorMessage] = useState<string | null>(null);
const orgBranding = useOrgBranding();
const returnToParam =
(parsedQuery.success ? getSafeRedirectUrl(parsedQuery.data.returnTo) : "/teams") || "/teams";
const newTeamFormMethods = useForm<NewTeamFormValues>({
defaultValues: {
slug: parsedQuery.success ? parsedQuery.data.slug : "",
slug,
},
});
const createTeamMutation = trpc.viewer.teams.create.useMutation({
onSuccess: (data) => {
telemetry.event(flag.telemetryEvent);
router.push(data.url);
},
onSuccess: (data) => onSuccess(data),
onError: (err) => {
if (err.message === "team_url_taken") {
newTeamFormMethods.setError("slug", { type: "custom", message: t("url_taken") });
@@ -65,6 +43,27 @@ export const CreateANewTeamForm = () => {
},
});
const FormButtons = () => (
<>
<Button
disabled={createTeamMutation.isPending}
color="secondary"
onClick={onCancel}
className="w-full justify-center">
{t("cancel")}
</Button>
<Button
disabled={newTeamFormMethods.formState.isSubmitting || createTeamMutation.isPending}
color="primary"
EndIcon={ArrowRight}
type="submit"
className="w-full justify-center"
data-testid="continue-button">
{t(submitLabel)}
</Button>
</>
);
return (
<>
<Form
@@ -144,24 +143,17 @@ export const CreateANewTeamForm = () => {
/>
</div>
<div className="flex space-x-2 rtl:space-x-reverse">
<Button
disabled={createTeamMutation.isPending}
color="secondary"
href={returnToParam}
className="w-full justify-center">
{t("cancel")}
</Button>
<Button
disabled={newTeamFormMethods.formState.isSubmitting || createTeamMutation.isPending}
color="primary"
EndIcon={ArrowRight}
type="submit"
className="w-full justify-center"
data-testid="continue-button">
{t(flag.submitLabel)}
</Button>
</div>
{inDialog ? (
<DialogFooter>
<div className="flex space-x-2 rtl:space-x-reverse">
<FormButtons />
</div>
</DialogFooter>
) : (
<div className="flex space-x-2 rtl:space-x-reverse">
<FormButtons />
</div>
)}
</Form>
</>
);
@@ -0,0 +1,117 @@
import logger from "@calcom/lib/logger";
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
import prisma from "@calcom/prisma";
import { TRPCError } from "@trpc/server";
const log = logger.getSubLogger({ prefix: ["removeMember"] });
const removeMember = async ({
memberId,
teamId,
isOrg,
}: {
memberId: number;
teamId: number;
isOrg: boolean;
}) => {
const [membership] = await prisma.$transaction([
prisma.membership.delete({
where: {
userId_teamId: { userId: memberId, teamId: teamId },
},
include: {
user: true,
team: true,
},
}),
// remove user as host from team events associated with this membership
prisma.host.deleteMany({
where: {
userId: memberId,
eventType: {
teamId: teamId,
},
},
}),
]);
if (isOrg) {
log.debug("Removing a member from the organization");
// Deleting membership from all child teams
const foundUser = await prisma.user.findUnique({
where: { id: memberId },
select: {
id: true,
movedToProfileId: true,
email: true,
username: true,
completedOnboarding: true,
},
});
const orgInfo = await prisma.team.findUnique({
where: { id: teamId },
select: {
isOrganization: true,
organizationSettings: true,
id: true,
metadata: true,
},
});
const orgMetadata = orgInfo?.organizationSettings;
if (!foundUser || !orgInfo) throw new TRPCError({ code: "NOT_FOUND" });
// Delete all sub-team memberships where this team is the organization
await prisma.membership.deleteMany({
where: {
team: {
parentId: teamId,
},
userId: membership.userId,
},
});
const userToDeleteMembershipOf = foundUser;
const profileToDelete = await ProfileRepository.findByUserIdAndOrgId({
userId: userToDeleteMembershipOf.id,
organizationId: orgInfo.id,
});
if (
userToDeleteMembershipOf.username &&
userToDeleteMembershipOf.movedToProfileId === profileToDelete?.id
) {
log.debug("Cleaning up tempOrgRedirect for user", userToDeleteMembershipOf.username);
await prisma.tempOrgRedirect.deleteMany({
where: {
from: userToDeleteMembershipOf.username,
},
});
}
await prisma.$transaction([
prisma.user.update({
where: { id: membership.userId },
data: { organizationId: null },
}),
ProfileRepository.delete({
userId: membership.userId,
organizationId: orgInfo.id,
}),
]);
}
// Deleted managed event types from this team from this member
await prisma.eventType.deleteMany({
where: { parent: { teamId: teamId }, userId: membership.userId },
});
return { membership };
};
export default removeMember;
@@ -100,6 +100,10 @@ const tabs: VerticalTabItemProps[] = [
name: "billing",
href: "/settings/organizations/billing",
},
{
name: "directory_sync",
href: "/settings/organizations/dsync",
},
],
},
{
@@ -126,9 +130,10 @@ const tabs: VerticalTabItemProps[] = [
];
tabs.find((tab) => {
// Add "SAML SSO" to the tab
if (tab.name === "security" && !HOSTED_CAL_FEATURES) {
tab.children?.push({ name: "sso_configuration", href: "/settings/security/sso" });
// TODO: Enable dsync for self hosters
// tab.children?.push({ name: "directory_sync", href: "/settings/security/dsync" });
}
});
@@ -0,0 +1,15 @@
-- CreateTable
CREATE TABLE "DSyncData" (
"id" SERIAL NOT NULL,
"directoryId" TEXT NOT NULL,
"tenant" TEXT NOT NULL,
"orgId" INTEGER,
CONSTRAINT "DSyncData_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "DSyncData_orgId_key" ON "DSyncData"("orgId");
-- AddForeignKey
ALTER TABLE "DSyncData" ADD CONSTRAINT "DSyncData_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+9
View File
@@ -367,6 +367,7 @@ model Team {
instantMeetingTokens InstantMeetingToken[]
orgProfiles Profile[]
pendingPayment Boolean @default(false)
dSyncData DSyncData?
@@unique([slug, parentId])
@@index([parentId])
@@ -1146,6 +1147,14 @@ model OutOfOfficeEntry {
@@index([start, end])
}
model DSyncData {
id Int @id @default(autoincrement())
directoryId String
tenant String
orgId Int? @unique
org Team? @relation(fields: [orgId], references: [id], onDelete: Cascade)
}
model SecondaryEmail {
id Int @id @default(autoincrement())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+1
View File
@@ -10,6 +10,7 @@ export const ENDPOINTS = [
"appBasecamp3",
"bookings",
"deploymentSetup",
"dsync",
"eventTypes",
"features",
"insights",
-1
View File
@@ -19,7 +19,6 @@ type Maybe<T> = T | null | undefined;
* We deploy our tRPC router on multiple lambdas to keep number of imports as small as possible
* TODO: Make this dynamic based on folders in trpc server?
*/
export type Endpoint = (typeof ENDPOINTS)[number];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -14,6 +14,7 @@ import { authRouter } from "./auth/_router";
import { availabilityRouter } from "./availability/_router";
import { bookingsRouter } from "./bookings/_router";
import { deploymentSetupRouter } from "./deploymentSetup/_router";
import { dsyncRouter } from "./dsync/_router";
import { eventTypesRouter } from "./eventTypes/_router";
import { googleWorkspaceRouter } from "./googleWorkspace/_router";
import { oAuthRouter } from "./oAuth/_router";
@@ -42,6 +43,7 @@ export const viewerRouter = mergeRouters(
slots: slotsRouter,
workflows: workflowsRouter,
saml: ssoRouter,
dsync: dsyncRouter,
insights: insightsRouter,
payments: paymentsRouter,
// NOTE: Add all app related routes in the bottom till the problem described in @calcom/app-store/trpc-routers.ts is solved.
@@ -0,0 +1,66 @@
import authedOrgAdminProcedure from "../../../procedures/authedProcedure";
import { router } from "../../../trpc";
import { ZCreateInputSchema } from "./create.schema";
import { ZDeleteInputSchema } from "./delete.schema";
import { ZGetInputSchema } from "./get.schema";
type DsyncRouterHandlerCache = {
create?: typeof import("./create.handler").createHandler;
get?: typeof import("./get.handler").getHandler;
delete?: typeof import("./delete.handler").deleteHandler;
};
const UNSTABLE_HANDLER_CACHE: DsyncRouterHandlerCache = {};
export const dsyncRouter = router({
// Create directory sync connection
create: authedOrgAdminProcedure.input(ZCreateInputSchema).mutation(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.create) {
UNSTABLE_HANDLER_CACHE.create = await import("./create.handler").then((mod) => mod.createHandler);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.create) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.create({
ctx,
input,
});
}),
// Get directory sync connection
get: authedOrgAdminProcedure.input(ZGetInputSchema).query(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.get) {
UNSTABLE_HANDLER_CACHE.get = await import("./get.handler").then((mod) => mod.getHandler);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.get) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.get({
ctx,
input,
});
}),
// Delete directory sync connection
delete: authedOrgAdminProcedure.input(ZDeleteInputSchema).mutation(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.delete) {
UNSTABLE_HANDLER_CACHE.delete = await import("./delete.handler").then((mod) => mod.deleteHandler);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.delete) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.delete({
ctx,
input,
});
}),
});
@@ -0,0 +1,56 @@
import type { DirectoryType } from "@boxyhq/saml-jackson";
import jackson from "@calcom/features/ee/sso/lib/jackson";
import { canAccess, samlProductID, samlTenantID, tenantPrefix } from "@calcom/features/ee/sso/lib/saml";
import prisma from "@calcom/prisma";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../trpc";
import type { ZCreateInputSchema } from "./create.schema";
type Options = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: ZCreateInputSchema;
};
// Create directory sync connection for a team
export const createHandler = async ({ ctx, input }: Options) => {
const { orgId } = input;
const { dsyncController } = await jackson();
const { message, access } = await canAccess(ctx.user, orgId);
if (!access) {
throw new TRPCError({
code: "BAD_REQUEST",
message,
});
}
const tenant = orgId ? `${tenantPrefix}${orgId}` : (samlTenantID as string);
const { data, error } = await dsyncController.directories.create({
tenant,
product: samlProductID,
name: input.name,
type: input.provider as DirectoryType,
});
if (error || !data) {
console.error("Error creating directory sync connection", error);
throw new TRPCError({ code: "BAD_REQUEST", message: error.message });
}
await prisma.dSyncData.create({
data: {
directoryId: data.id,
tenant,
...(orgId && { orgId }),
},
});
return data;
};
@@ -0,0 +1,9 @@
import { z } from "zod";
export const ZCreateInputSchema = z.object({
orgId: z.union([z.number(), z.null()]),
name: z.string(),
provider: z.string(),
});
export type ZCreateInputSchema = z.infer<typeof ZCreateInputSchema>;
@@ -0,0 +1,38 @@
import jackson from "@calcom/features/ee/sso/lib/jackson";
import { canAccess } from "@calcom/features/ee/sso/lib/saml";
import prisma from "@calcom/prisma";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../trpc";
import type { ZDeleteInputSchema } from "./delete.schema";
type Options = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: ZDeleteInputSchema;
};
// Delete directory sync connection for a team
export const deleteHandler = async ({ ctx, input }: Options) => {
const { dsyncController } = await jackson();
const { message, access } = await canAccess(ctx.user, input.orgId);
if (!access) {
throw new TRPCError({
code: "BAD_REQUEST",
message,
});
}
await prisma.dSyncData.delete({
where: {
orgId: input.orgId || undefined,
},
});
await dsyncController.directories.delete(input.directoryId);
return null;
};
@@ -0,0 +1,8 @@
import { z } from "zod";
export const ZDeleteInputSchema = z.object({
orgId: z.union([z.number(), z.null()]),
directoryId: z.string(),
});
export type ZDeleteInputSchema = z.infer<typeof ZDeleteInputSchema>;
@@ -0,0 +1,39 @@
import jackson from "@calcom/features/ee/sso/lib/jackson";
import { canAccess, samlProductID, samlTenantID, tenantPrefix } from "@calcom/features/ee/sso/lib/saml";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../trpc";
import type { ZGetInputSchema } from "./get.schema";
type Options = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: ZGetInputSchema;
};
// Get directory sync connection for a team
export const getHandler = async ({ ctx, input }: Options) => {
const { dsyncController } = await jackson();
const { message, access } = await canAccess(ctx.user, input.orgId);
if (!access) {
throw new TRPCError({
code: "BAD_REQUEST",
message,
});
}
const tenant = input.orgId ? `${tenantPrefix}${input.orgId}` : (samlTenantID as string);
const { data, error } = await dsyncController.directories.getByTenantAndProduct(tenant, samlProductID);
if (error) {
console.error("Error fetching directory sync connection", error);
throw new TRPCError({ code: "BAD_REQUEST", message: error.message });
}
return data;
};
@@ -0,0 +1,7 @@
import { z } from "zod";
export const ZGetInputSchema = z.object({
orgId: z.union([z.number(), z.null()]),
});
export type ZGetInputSchema = z.infer<typeof ZGetInputSchema>;
@@ -119,7 +119,7 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) =
usernameOrEmail,
team,
translation,
ctx,
inviterName: ctx.user.name ?? "",
input,
});
});
@@ -19,7 +19,6 @@ import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../../trpc";
import { isEmail } from "../util";
import type { InviteMemberOptions, TeamWithParent } from "./types";
@@ -369,13 +368,13 @@ export async function sendSignupToOrganizationEmail({
usernameOrEmail,
team,
translation,
ctx,
inviterName,
input,
}: {
usernameOrEmail: string;
team: Awaited<ReturnType<typeof getTeamOrThrow>>;
translation: TFunction;
ctx: { user: NonNullable<TrpcSessionUser> };
inviterName: string;
input: {
teamId: number;
role: "ADMIN" | "MEMBER" | "OWNER";
@@ -400,7 +399,7 @@ export async function sendSignupToOrganizationEmail({
});
await sendTeamInviteEmail({
language: translation,
from: ctx.user.name || `${team.name}'s admin`,
from: inviterName || `${team.name}'s admin`,
to: usernameOrEmail,
teamName: team.name,
joinLink: `${WEBAPP_URL}/signup?token=${token}&callbackUrl=/getting-started`,
@@ -1,9 +1,9 @@
import { updateQuantitySubscriptionFromStripe } from "@calcom/features/ee/teams/lib/payments";
import removeMember from "@calcom/features/ee/teams/lib/removeMember";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { isTeamAdmin, isTeamOwner } from "@calcom/lib/server/queries/teams";
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
import { closeComDeleteTeamMembership } from "@calcom/lib/sync/SyncServiceManager";
import type { PrismaClient } from "@calcom/prisma";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
@@ -43,100 +43,10 @@ export const removeMemberHandler = async ({ ctx, input }: RemoveMemberOptions) =
message: "You can not remove yourself from a team you own.",
});
const [membership] = await ctx.prisma.$transaction([
ctx.prisma.membership.delete({
where: {
userId_teamId: { userId: input.memberId, teamId: input.teamId },
},
include: {
user: true,
team: true,
},
}),
// remove user as host from team events associated with this membership
ctx.prisma.host.deleteMany({
where: {
userId: input.memberId,
eventType: {
teamId: input.teamId,
},
},
}),
]);
if (input.isOrg) {
log.debug("Removing a member from the organization");
// Deleting membership from all child teams
const foundUser = await ctx.prisma.user.findUnique({
where: { id: input.memberId },
select: {
id: true,
movedToProfileId: true,
email: true,
username: true,
completedOnboarding: true,
},
});
const orgInfo = await ctx.prisma.team.findUnique({
where: { id: input.teamId },
select: {
isOrganization: true,
organizationSettings: true,
id: true,
metadata: true,
},
});
const orgMetadata = orgInfo?.organizationSettings;
if (!foundUser || !orgInfo) throw new TRPCError({ code: "NOT_FOUND" });
// Delete all sub-team memberships where this team is the organization
await ctx.prisma.membership.deleteMany({
where: {
team: {
parentId: input.teamId,
},
userId: membership.userId,
},
});
const userToDeleteMembershipOf = foundUser;
const profileToDelete = await ProfileRepository.findByUserIdAndOrgId({
userId: userToDeleteMembershipOf.id,
organizationId: orgInfo.id,
});
if (
userToDeleteMembershipOf.username &&
userToDeleteMembershipOf.movedToProfileId === profileToDelete?.id
) {
log.debug("Cleaning up tempOrgRedirect for user", userToDeleteMembershipOf.username);
await ctx.prisma.tempOrgRedirect.deleteMany({
where: {
from: userToDeleteMembershipOf.username,
},
});
}
await ctx.prisma.$transaction([
ctx.prisma.user.update({
where: { id: membership.userId },
data: { organizationId: null },
}),
ProfileRepository.delete({
userId: membership.userId,
organizationId: orgInfo.id,
}),
]);
}
// Deleted managed event types from this team from this member
await ctx.prisma.eventType.deleteMany({
where: { parent: { teamId: input.teamId }, userId: membership.userId },
const { membership } = await removeMember({
teamId: input.teamId,
memberId: input.memberId,
isOrg: input.isOrg,
});
// Sync Services