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:
co-authored by
Alex van Andel
Keith Williams
Peer Richelsen
Joe Au-Yeung
Joe Au-Yeung
sean-brydon
Udit Takkar
parent
dfd683ffc4
commit
1064e1181d
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
|
||||
@@ -10,6 +10,7 @@ export const ENDPOINTS = [
|
||||
"appBasecamp3",
|
||||
"bookings",
|
||||
"deploymentSetup",
|
||||
"dsync",
|
||||
"eventTypes",
|
||||
"features",
|
||||
"insights",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user