diff --git a/apps/web/pages/api/scim/v2.0/[...directory].ts b/apps/web/pages/api/scim/v2.0/[...directory].ts
new file mode 100644
index 0000000000..ff86e0daf5
--- /dev/null
+++ b/apps/web/pages/api/scim/v2.0/[...directory].ts
@@ -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,
+ });
+ }
+ }
+};
diff --git a/apps/web/pages/api/trpc/dsync/[trpc].ts b/apps/web/pages/api/trpc/dsync/[trpc].ts
new file mode 100644
index 0000000000..c13f6ede6e
--- /dev/null
+++ b/apps/web/pages/api/trpc/dsync/[trpc].ts
@@ -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);
diff --git a/apps/web/pages/settings/organizations/dsync.tsx b/apps/web/pages/settings/organizations/dsync.tsx
new file mode 100644
index 0000000000..ed9546b190
--- /dev/null
+++ b/apps/web/pages/settings/organizations/dsync.tsx
@@ -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;
diff --git a/apps/web/pages/settings/teams/new/index.tsx b/apps/web/pages/settings/teams/new/index.tsx
index 9a1ba88808..3ea1090905 100644
--- a/apps/web/pages/settings/teams/new/index.tsx
+++ b/apps/web/pages/settings/teams/new/index.tsx
@@ -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 (
<>
{t("create_new_team")}
-
+ router.push(returnToParam)}
+ submitLabel={flag.submitLabel}
+ onSuccess={onSuccess}
+ />
>
);
};
diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json
index fdc8b0fc4b..97ecac1992 100644
--- a/apps/web/public/static/locales/en/common.json
+++ b/apps/web/public/static/locales/en/common.json
@@ -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 {{email}}. 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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
-}
+}
\ No newline at end of file
diff --git a/packages/features/ee/dsync/components/ConfigureDirectorySync.tsx b/packages/features/ee/dsync/components/ConfigureDirectorySync.tsx
new file mode 100644
index 0000000000..d6b68d6883
--- /dev/null
+++ b/packages/features/ee/dsync/components/ConfigureDirectorySync.tsx
@@ -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 ;
+ }
+
+ const directory = data ? data[0] : null;
+
+ const onDeleteConfirmation = (e: Event | React.MouseEvent) => {
+ e.preventDefault();
+
+ if (!directory) {
+ return;
+ }
+
+ deleteMutation.mutate({ orgId, directoryId: directory.id });
+ };
+
+ if (error || isError) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {!directory ? (
+
+ ) : (
+ <>
+
+
+
+ {/* Delete directory sync connection */}
+
+ >
+ )}
+
+ );
+};
+
+export default ConfigureDirectorySync;
diff --git a/packages/features/ee/dsync/components/CreateDirectory.tsx b/packages/features/ee/dsync/components/CreateDirectory.tsx
new file mode 100644
index 0000000000..71def416ad
--- /dev/null
+++ b/packages/features/ee/dsync/components/CreateDirectory.tsx
@@ -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 (
+ <>
+
+
+
+ {t("directory_sync_title")}
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
+export default CreateDirectory;
diff --git a/packages/features/ee/dsync/components/DirectoryInfo.tsx b/packages/features/ee/dsync/components/DirectoryInfo.tsx
new file mode 100644
index 0000000000..9fd2e3ba7c
--- /dev/null
+++ b/packages/features/ee/dsync/components/DirectoryInfo.tsx
@@ -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 (
+
+
+ {t("directory_sync_info_description")}
+
+
+
+
+
+
+
+ {directory.scim.endpoint}
+
+
+
+
+
+
+
+
+
+
+
+
+ {directory.scim.secret}
+
+
+
+
+
+
+
+ );
+};
+
+export default DirectoryInfo;
diff --git a/packages/features/ee/dsync/lib/createUserAndInviteToOrg.ts b/packages/features/ee/dsync/lib/createUserAndInviteToOrg.ts
new file mode 100644
index 0000000000..9533f7e90c
--- /dev/null
+++ b/packages/features/ee/dsync/lib/createUserAndInviteToOrg.ts
@@ -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>;
+ 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;
diff --git a/packages/features/ee/dsync/lib/directoryProviders.ts b/packages/features/ee/dsync/lib/directoryProviders.ts
new file mode 100644
index 0000000000..8c2c3d58af
--- /dev/null
+++ b/packages/features/ee/dsync/lib/directoryProviders.ts
@@ -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",
+ },
+];
diff --git a/packages/features/ee/dsync/lib/inviteExistingUserToOrg.ts b/packages/features/ee/dsync/lib/inviteExistingUserToOrg.ts
new file mode 100644
index 0000000000..a1c990cf47
--- /dev/null
+++ b/packages/features/ee/dsync/lib/inviteExistingUserToOrg.ts
@@ -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;
diff --git a/packages/features/ee/dsync/lib/removeUserFromOrg.ts b/packages/features/ee/dsync/lib/removeUserFromOrg.ts
new file mode 100644
index 0000000000..953b7feeb5
--- /dev/null
+++ b/packages/features/ee/dsync/lib/removeUserFromOrg.ts
@@ -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;
diff --git a/packages/features/ee/dsync/page/team-dsync-view.tsx b/packages/features/ee/dsync/page/team-dsync-view.tsx
new file mode 100644
index 0000000000..1401db2e77
--- /dev/null
+++ b/packages/features/ee/dsync/page/team-dsync-view.tsx
@@ -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 ;
+ }
+
+ if (!currentOrg?.id) {
+ router.push("/404");
+ }
+
+ if (error) {
+ showToast(error.message, "error");
+ }
+
+ return (
+
+
+ {HOSTED_CAL_FEATURES && }
+
+ );
+};
+
+DirectorySync.getLayout = getLayout;
+
+export default DirectorySync;
diff --git a/packages/features/ee/sso/lib/jackson.ts b/packages/features/ee/sso/lib/jackson.ts
index d9e04b398c..96c2197486 100644
--- a/packages/features/ee/sso/lib/jackson.ts
+++ b/packages/features/ee/sso/lib/jackson.ts
@@ -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,
};
}
diff --git a/packages/features/ee/teams/components/CreateANewTeamForm.tsx b/packages/features/ee/teams/components/CreateANewTeamForm.tsx
index a1b2ccea2f..008211fa36 100644
--- a/packages/features/ee/teams/components/CreateANewTeamForm.tsx
+++ b/packages/features/ee/teams/components/CreateANewTeamForm.tsx
@@ -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(null);
const orgBranding = useOrgBranding();
- const returnToParam =
- (parsedQuery.success ? getSafeRedirectUrl(parsedQuery.data.returnTo) : "/teams") || "/teams";
-
const newTeamFormMethods = useForm({
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 = () => (
+ <>
+
+
+ >
+ );
+
return (
<>
>
);
diff --git a/packages/features/ee/teams/lib/removeMember.ts b/packages/features/ee/teams/lib/removeMember.ts
new file mode 100644
index 0000000000..d1bca7ff5e
--- /dev/null
+++ b/packages/features/ee/teams/lib/removeMember.ts
@@ -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;
diff --git a/packages/features/settings/layouts/SettingsLayout.tsx b/packages/features/settings/layouts/SettingsLayout.tsx
index d1986a75ad..48e60d7ccc 100644
--- a/packages/features/settings/layouts/SettingsLayout.tsx
+++ b/packages/features/settings/layouts/SettingsLayout.tsx
@@ -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" });
}
});
diff --git a/packages/prisma/migrations/20240220151951_add_dsync_data/migration.sql b/packages/prisma/migrations/20240220151951_add_dsync_data/migration.sql
new file mode 100644
index 0000000000..4f48867b70
--- /dev/null
+++ b/packages/prisma/migrations/20240220151951_add_dsync_data/migration.sql
@@ -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;
diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma
index 068e1addad..f9292ce632 100644
--- a/packages/prisma/schema.prisma
+++ b/packages/prisma/schema.prisma
@@ -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)
diff --git a/packages/trpc/react/shared.ts b/packages/trpc/react/shared.ts
index 14598e7011..dc62cd8200 100644
--- a/packages/trpc/react/shared.ts
+++ b/packages/trpc/react/shared.ts
@@ -10,6 +10,7 @@ export const ENDPOINTS = [
"appBasecamp3",
"bookings",
"deploymentSetup",
+ "dsync",
"eventTypes",
"features",
"insights",
diff --git a/packages/trpc/react/trpc.ts b/packages/trpc/react/trpc.ts
index 68aaadb841..0e319e6832 100644
--- a/packages/trpc/react/trpc.ts
+++ b/packages/trpc/react/trpc.ts
@@ -19,7 +19,6 @@ type Maybe = 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
diff --git a/packages/trpc/server/routers/viewer/_router.tsx b/packages/trpc/server/routers/viewer/_router.tsx
index 62abc6cbed..c0320a365d 100644
--- a/packages/trpc/server/routers/viewer/_router.tsx
+++ b/packages/trpc/server/routers/viewer/_router.tsx
@@ -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.
diff --git a/packages/trpc/server/routers/viewer/dsync/_router.tsx b/packages/trpc/server/routers/viewer/dsync/_router.tsx
new file mode 100644
index 0000000000..d785857be6
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/dsync/_router.tsx
@@ -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,
+ });
+ }),
+});
diff --git a/packages/trpc/server/routers/viewer/dsync/create.handler.ts b/packages/trpc/server/routers/viewer/dsync/create.handler.ts
new file mode 100644
index 0000000000..87b9a2f985
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/dsync/create.handler.ts
@@ -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;
+ };
+ 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;
+};
diff --git a/packages/trpc/server/routers/viewer/dsync/create.schema.ts b/packages/trpc/server/routers/viewer/dsync/create.schema.ts
new file mode 100644
index 0000000000..dd8e2e0bb6
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/dsync/create.schema.ts
@@ -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;
diff --git a/packages/trpc/server/routers/viewer/dsync/delete.handler.ts b/packages/trpc/server/routers/viewer/dsync/delete.handler.ts
new file mode 100644
index 0000000000..1fb3b60c50
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/dsync/delete.handler.ts
@@ -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;
+ };
+ 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;
+};
diff --git a/packages/trpc/server/routers/viewer/dsync/delete.schema.ts b/packages/trpc/server/routers/viewer/dsync/delete.schema.ts
new file mode 100644
index 0000000000..499348b9ab
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/dsync/delete.schema.ts
@@ -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;
diff --git a/packages/trpc/server/routers/viewer/dsync/get.handler.ts b/packages/trpc/server/routers/viewer/dsync/get.handler.ts
new file mode 100644
index 0000000000..32009156eb
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/dsync/get.handler.ts
@@ -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;
+ };
+ 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;
+};
diff --git a/packages/trpc/server/routers/viewer/dsync/get.schema.ts b/packages/trpc/server/routers/viewer/dsync/get.schema.ts
new file mode 100644
index 0000000000..893d5613ff
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/dsync/get.schema.ts
@@ -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;
diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts
index 5b991dd958..461192a21a 100644
--- a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts
+++ b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts
@@ -119,7 +119,7 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) =
usernameOrEmail,
team,
translation,
- ctx,
+ inviterName: ctx.user.name ?? "",
input,
});
});
diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts
index dc3adb8025..4301a1c84e 100644
--- a/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts
+++ b/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts
@@ -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>;
translation: TFunction;
- ctx: { user: NonNullable };
+ 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`,
diff --git a/packages/trpc/server/routers/viewer/teams/removeMember.handler.ts b/packages/trpc/server/routers/viewer/teams/removeMember.handler.ts
index b9dceaac2d..6270969065 100644
--- a/packages/trpc/server/routers/viewer/teams/removeMember.handler.ts
+++ b/packages/trpc/server/routers/viewer/teams/removeMember.handler.ts
@@ -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