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 */} + + + + + + <> +
+

{t("directory_sync_delete_confirmation")}

+
+ + + + + +
+
+ + )} +
+ ); +}; + +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")} +

+
+
+ +
+
+ + + +
{ + mutation.mutate({ + ...values, + orgId, + }); + }}> +
+

+ {t("directory_sync_configure")} +

+

{t("directory_sync_configure_description")}

+
+
+ ( + { + form.setValue("name", e?.target.value); + }} + type="text" + required + /> + )} + /> + ( + { + if (option) { + form.setValue("provider", option.value); + } + }} + /> + )} + /> +
+ + + + +
+
+
+ + ); +}; + +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 ( <>
{ /> -
- - -
+ {inDialog ? ( + +
+ +
+
+ ) : ( +
+ +
+ )}
); 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