first commit
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import {
|
||||
AppSkeletonLoader as SkeletonLoader,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogTrigger,
|
||||
Label,
|
||||
showToast,
|
||||
EmptyScreen,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import CreateDirectory from "./CreateDirectory";
|
||||
import DirectoryInfo from "./DirectoryInfo";
|
||||
import GroupTeamMappingTable from "./GroupTeamMappingTable";
|
||||
|
||||
const ConfigureDirectorySync = ({ organizationId }: { organizationId: number | null }) => {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
const [deleteDirectoryOpen, setDeleteDirectoryOpen] = useState(false);
|
||||
|
||||
const { data, isLoading, isError, error } = trpc.viewer.dsync.get.useQuery({ organizationId });
|
||||
|
||||
const deleteMutation = trpc.viewer.dsync.delete.useMutation({
|
||||
async onSuccess() {
|
||||
showToast(t("directory_sync_deleted"), "success");
|
||||
await utils.viewer.dsync.invalidate();
|
||||
setDeleteDirectoryOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <SkeletonLoader />;
|
||||
}
|
||||
|
||||
const directory = data ?? null;
|
||||
|
||||
const onDeleteConfirmation = (e: Event | React.MouseEvent<HTMLElement, MouseEvent>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!directory) {
|
||||
return;
|
||||
}
|
||||
|
||||
deleteMutation.mutate({ organizationId, directoryId: directory.id });
|
||||
};
|
||||
|
||||
if (error || isError) {
|
||||
return (
|
||||
<div>
|
||||
<EmptyScreen
|
||||
headline="Error"
|
||||
description={error.message || "Error getting dsync data"}
|
||||
Icon="triangle-alert"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!directory ? (
|
||||
<CreateDirectory orgId={organizationId} />
|
||||
) : (
|
||||
<>
|
||||
<DirectoryInfo directory={directory} />
|
||||
<div className="mt-4">
|
||||
<GroupTeamMappingTable />
|
||||
</div>
|
||||
|
||||
<hr className="border-subtle my-6" />
|
||||
<Label>{t("danger_zone")}</Label>
|
||||
{/* Delete directory sync connection */}
|
||||
<Dialog open={deleteDirectoryOpen} onOpenChange={setDeleteDirectoryOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button color="destructive" className="mt-1" StartIcon="trash">
|
||||
{t("directory_sync_delete_connection")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent
|
||||
title={t("directory_sync_delete_title")}
|
||||
description={t("directory_sync_delete_description")}
|
||||
type="creation"
|
||||
Icon="triangle-alert">
|
||||
<>
|
||||
<div className="mb-10">
|
||||
<p className="text-default mb-4">{t("directory_sync_delete_confirmation")}</p>
|
||||
</div>
|
||||
<DialogFooter showDivider>
|
||||
<DialogClose />
|
||||
<Button
|
||||
color="primary"
|
||||
data-testid="delete-account-confirm"
|
||||
onClick={onDeleteConfirmation}
|
||||
loading={deleteMutation.isPending}>
|
||||
{t("directory_sync_delete_connection")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfigureDirectorySync;
|
||||
126
calcom/packages/features/ee/dsync/components/CreateDirectory.tsx
Normal file
126
calcom/packages/features/ee/dsync/components/CreateDirectory.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
SelectField,
|
||||
Form,
|
||||
TextField,
|
||||
DialogFooter,
|
||||
showToast,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import { directoryProviders } from "../lib/directoryProviders";
|
||||
|
||||
const defaultValues = {
|
||||
name: "",
|
||||
provider: directoryProviders[0].value,
|
||||
};
|
||||
|
||||
const CreateDirectory = ({ orgId }: { orgId: number | null }) => {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
const form = useForm({ defaultValues });
|
||||
const [openModal, setOpenModal] = useState(false);
|
||||
|
||||
const mutation = trpc.viewer.dsync.create.useMutation({
|
||||
async onSuccess() {
|
||||
showToast(t("directory_sync_created"), "success");
|
||||
await utils.viewer.dsync.invalidate();
|
||||
setOpenModal(false);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col sm:flex-row">
|
||||
<div>
|
||||
<p className="text-default text-sm font-normal leading-6 dark:text-gray-300">
|
||||
{t("directory_sync_title")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0 pt-3 sm:ml-auto sm:pl-3 sm:pt-0">
|
||||
<Button color="primary" onClick={() => setOpenModal(true)}>
|
||||
{t("configure")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={openModal} onOpenChange={setOpenModal}>
|
||||
<DialogContent type="creation">
|
||||
<Form
|
||||
form={form}
|
||||
handleSubmit={(values) => {
|
||||
mutation.mutate({
|
||||
...values,
|
||||
organizationId: orgId,
|
||||
});
|
||||
}}>
|
||||
<div className="mb-5 mt-1">
|
||||
<h2 className="font-semi-bold font-cal text-emphasis text-xl tracking-wide">
|
||||
{t("directory_sync_configure")}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">{t("directory_sync_configure_description")}</p>
|
||||
</div>
|
||||
<fieldset className="space-y-6 py-2">
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field: { value } }) => (
|
||||
<TextField
|
||||
name="title"
|
||||
label={t("directory_name")}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
form.setValue("name", e?.target.value);
|
||||
}}
|
||||
type="text"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="provider"
|
||||
render={() => (
|
||||
<SelectField
|
||||
name="provider"
|
||||
label={t("directory_provider")}
|
||||
options={directoryProviders}
|
||||
placeholder={t("choose_directory_provider")}
|
||||
defaultValue={directoryProviders[0]}
|
||||
onChange={(option) => {
|
||||
if (option) {
|
||||
form.setValue("provider", option.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</fieldset>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
setOpenModal(false);
|
||||
}}
|
||||
tabIndex={-1}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={form.formState.isSubmitting || mutation.isPending}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateDirectory;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { CreateANewTeamForm } from "@calcom/features/ee/teams/components";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Dialog, DialogContent } from "@calcom/ui";
|
||||
|
||||
interface CreateTeamDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const CreateTeamDialog = (props: CreateTeamDialogProps) => {
|
||||
const { open, onOpenChange } = props;
|
||||
const { t } = useLocale();
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent type="creation" title={t("create_new_team")} description={t("team_will_be_under_org")}>
|
||||
<CreateANewTeamForm
|
||||
inDialog
|
||||
submitLabel="Create"
|
||||
onCancel={() => onOpenChange(false)}
|
||||
onSuccess={async () => {
|
||||
await utils.viewer.dsync.teamGroupMapping.get.invalidate();
|
||||
await utils.viewer.teams.list.invalidate();
|
||||
onOpenChange(false);
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateTeamDialog;
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Directory } from "@boxyhq/saml-jackson";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, showToast, Label, Tooltip } from "@calcom/ui";
|
||||
|
||||
const DirectoryInfo = ({ directory }: { directory: Directory }) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<p className="text-default text-sm font-normal leading-6 dark:text-gray-300">
|
||||
{t("directory_sync_info_description")}
|
||||
</p>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex">
|
||||
<Label>{t("directory_scim_url")}</Label>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<code className="bg-subtle text-default w-full truncate rounded-md rounded-r-none py-[6px] pl-2 pr-2 align-middle font-mono">
|
||||
{directory.scim.endpoint}
|
||||
</code>
|
||||
<Tooltip side="top" content={t("copy_to_clipboard")}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(`${directory.scim.endpoint}`);
|
||||
showToast(t("directory_scim_url_copied"), "success");
|
||||
}}
|
||||
type="button"
|
||||
className="rounded-l-none text-base"
|
||||
StartIcon="clipboard">
|
||||
{t("copy")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex">
|
||||
<Label>{t("directory_scim_token")}</Label>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<code className="bg-subtle text-default w-full truncate rounded-md rounded-r-none py-[6px] pl-2 pr-2 align-middle font-mono">
|
||||
{directory.scim.secret}
|
||||
</code>
|
||||
<Tooltip side="top" content={t("copy_to_clipboard")}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(`${directory.scim.secret}`);
|
||||
showToast(t("directory_scim_token_copied"), "success");
|
||||
}}
|
||||
type="button"
|
||||
className="rounded-l-none text-base"
|
||||
StartIcon="clipboard">
|
||||
{t("copy")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DirectoryInfo;
|
||||
116
calcom/packages/features/ee/dsync/components/GroupNameCell.tsx
Normal file
116
calcom/packages/features/ee/dsync/components/GroupNameCell.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Badge, Icon, showToast, TextField } from "@calcom/ui";
|
||||
|
||||
interface GroupNameCellProps {
|
||||
groupNames: string[];
|
||||
teamId: number;
|
||||
directoryId: string;
|
||||
}
|
||||
|
||||
const GroupNameCell = ({ groupNames, teamId, directoryId }: GroupNameCellProps) => {
|
||||
const [showTextInput, setShowTextInput] = useState(false);
|
||||
const [newGroupName, setNewGroupName] = useState("");
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const createMutation = trpc.viewer.dsync.teamGroupMapping.create.useMutation({
|
||||
onSuccess: (data) => {
|
||||
utils.viewer.dsync.teamGroupMapping.get.setData(undefined, (prev) => {
|
||||
if (prev) {
|
||||
const teamIndex = prev.teamGroupMapping.findIndex((team) => team.id === teamId);
|
||||
prev.teamGroupMapping[teamIndex].groupNames.push(data.newGroupName);
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
setShowTextInput(false);
|
||||
setNewGroupName("");
|
||||
showToast(`Group added`, "success");
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast(`Error adding group name${error.message}`, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = trpc.viewer.dsync.teamGroupMapping.delete.useMutation({
|
||||
onSuccess: (data) => {
|
||||
utils.viewer.dsync.teamGroupMapping.get.setData(undefined, (prev) => {
|
||||
if (prev) {
|
||||
const teamIndex = prev.teamGroupMapping.findIndex((team) => team.id === teamId);
|
||||
const indexToRemove = prev.teamGroupMapping[teamIndex].groupNames.indexOf(data.deletedGroupName);
|
||||
prev.teamGroupMapping[teamIndex].groupNames.splice(indexToRemove, 1);
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
showToast(`Group removed`, "success");
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast(`Error removing group name${error.message}`, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const addGroupName = (groupName: string) => {
|
||||
if (groupNames.some((name: string) => name === groupName)) {
|
||||
showToast(`Group name already added`, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
createMutation.mutate({ teamId: teamId, name: groupName, directoryId: directoryId });
|
||||
};
|
||||
|
||||
const removeGroupName = (groupName: string) => {
|
||||
deleteMutation.mutate({
|
||||
teamId: teamId,
|
||||
groupName: groupName,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-4">
|
||||
{groupNames.map((name) => (
|
||||
<Badge variant="gray" size="lg" key={name} className="h-8 py-4">
|
||||
<div className="flex items-center space-x-2 ">
|
||||
<p>{name}</p>
|
||||
<div className="hover:bg-emphasis rounded p-1">
|
||||
<Icon name="x" className="h-4 w-4 stroke-[3px]" onClick={() => removeGroupName(name)} />
|
||||
</div>
|
||||
</div>
|
||||
</Badge>
|
||||
))}
|
||||
<Badge variant="gray" size="lg" className={classNames(!showTextInput && "hover:bg-emphasis")}>
|
||||
<div
|
||||
className="flex items-center space-x-1"
|
||||
onClick={() => {
|
||||
if (!showTextInput) setShowTextInput(true);
|
||||
}}>
|
||||
{showTextInput ? (
|
||||
<TextField
|
||||
autoFocus
|
||||
className="mb-0 h-6"
|
||||
onBlur={() => {
|
||||
if (!newGroupName) setShowTextInput(false);
|
||||
}}
|
||||
onChange={(e) => setNewGroupName(e.target.value)}
|
||||
value={newGroupName}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
addGroupName(newGroupName);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<p>{t("add_group_name")}</p>
|
||||
)}
|
||||
<div className={classNames("rounded p-1", showTextInput && "hover:bg-emphasis ml-2")}>
|
||||
<Icon name="plus" className="h-4 w-4 stroke-[3px]" onClick={() => addGroupName(newGroupName)} />
|
||||
</div>
|
||||
</div>
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupNameCell;
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { DataTable, Button } from "@calcom/ui";
|
||||
|
||||
import CreateTeamDialog from "./CreateTeamDialog";
|
||||
import GroupNameCell from "./GroupNameCell";
|
||||
|
||||
interface TeamGroupMapping {
|
||||
name: string;
|
||||
id: number;
|
||||
groupNames: string[];
|
||||
directoryId: string;
|
||||
}
|
||||
|
||||
const GroupTeamMappingTable = () => {
|
||||
const { t } = useLocale();
|
||||
const [createTeamDialogOpen, setCreateTeamDialogOpen] = useState(false);
|
||||
|
||||
const { data } = trpc.viewer.dsync.teamGroupMapping.get.useQuery();
|
||||
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const columns: ColumnDef<TeamGroupMapping>[] = [
|
||||
{
|
||||
id: "name",
|
||||
header: t("team"),
|
||||
cell: ({ row }) => {
|
||||
const { name } = row.original;
|
||||
|
||||
return <p>{name}</p>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "group",
|
||||
header: t("group_name"),
|
||||
cell: ({ row }) => {
|
||||
const { id, groupNames, directoryId } = row.original;
|
||||
|
||||
return <GroupNameCell groupNames={groupNames} teamId={id} directoryId={directoryId} />;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
data={data ? data.teamGroupMapping : []}
|
||||
tableContainerRef={tableContainerRef}
|
||||
columns={columns}
|
||||
tableCTA={<Button onClick={() => setCreateTeamDialogOpen(true)}>Create team</Button>}
|
||||
/>
|
||||
<CreateTeamDialog open={createTeamDialogOpen} onOpenChange={setCreateTeamDialogOpen} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupTeamMappingTable;
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { TFunction } from "next-i18next";
|
||||
|
||||
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import type { getTeamOrThrow } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/utils";
|
||||
import { sendSignupToOrganizationEmail } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/utils";
|
||||
|
||||
const createUserAndInviteToOrg = async ({
|
||||
userEmail,
|
||||
org,
|
||||
translation,
|
||||
}: {
|
||||
userEmail: string;
|
||||
org: Awaited<ReturnType<typeof getTeamOrThrow>>;
|
||||
translation: TFunction;
|
||||
}) => {
|
||||
const orgId = org.id;
|
||||
const [emailUser, emailDomain] = userEmail.split("@");
|
||||
const username = slugify(`${emailUser}-${emailDomain.split(".")[0]}`);
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
email: userEmail,
|
||||
// name: event.data?.givenName,
|
||||
// Assume verified since coming from directory
|
||||
verified: true,
|
||||
invitedTo: orgId,
|
||||
organizationId: orgId,
|
||||
teams: {
|
||||
create: {
|
||||
teamId: orgId,
|
||||
role: MembershipRole.MEMBER,
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
profiles: {
|
||||
createMany: {
|
||||
data: [
|
||||
{
|
||||
uid: ProfileRepository.generateProfileUid(),
|
||||
username,
|
||||
organizationId: orgId,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await sendSignupToOrganizationEmail({
|
||||
usernameOrEmail: userEmail,
|
||||
team: org,
|
||||
translation,
|
||||
inviterName: org.name,
|
||||
input: {
|
||||
teamId: orgId,
|
||||
role: MembershipRole.MEMBER,
|
||||
usernameOrEmail: userEmail,
|
||||
language: "en",
|
||||
isOrg: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default createUserAndInviteToOrg;
|
||||
22
calcom/packages/features/ee/dsync/lib/directoryProviders.ts
Normal file
22
calcom/packages/features/ee/dsync/lib/directoryProviders.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export const directoryProviders = [
|
||||
{
|
||||
label: "Azure SCIM v2.0",
|
||||
value: "azure-scim-v2",
|
||||
},
|
||||
{
|
||||
label: "Okta SCIM v2.0",
|
||||
value: "okta-scim-v2",
|
||||
},
|
||||
{
|
||||
label: "JumpCloud v2.0",
|
||||
value: "jumpcloud-scim-v2",
|
||||
},
|
||||
{
|
||||
label: "OneLogin SCIM v2.0",
|
||||
value: "onelogin-scim-v2",
|
||||
},
|
||||
{
|
||||
label: "SCIM Generic v2.0",
|
||||
value: "generic-scim-v2",
|
||||
},
|
||||
];
|
||||
191
calcom/packages/features/ee/dsync/lib/handleGroupEvents.ts
Normal file
191
calcom/packages/features/ee/dsync/lib/handleGroupEvents.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import type { DirectorySyncEvent, Group } from "@boxyhq/saml-jackson";
|
||||
|
||||
import jackson from "@calcom/features/ee/sso/lib/jackson";
|
||||
import { createAProfileForAnExistingUser } from "@calcom/lib/createAProfileForAnExistingUser";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { IdentityProvider, MembershipRole } from "@calcom/prisma/enums";
|
||||
import {
|
||||
getTeamOrThrow,
|
||||
sendSignupToOrganizationEmail,
|
||||
sendExistingUserTeamInviteEmails,
|
||||
} from "@calcom/trpc/server/routers/viewer/teams/inviteMember/utils";
|
||||
|
||||
import createUsersAndConnectToOrg from "./users/createUsersAndConnectToOrg";
|
||||
|
||||
const handleGroupEvents = async (event: DirectorySyncEvent, organizationId: number) => {
|
||||
const { dsyncController } = await jackson();
|
||||
// Find the group name associated with the event
|
||||
const eventData = event.data as Group;
|
||||
|
||||
// If the group doesn't have any members assigned then return early
|
||||
if (!eventData.raw.members.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const groupNames = await prisma.dSyncTeamGroupMapping.findMany({
|
||||
where: {
|
||||
directoryId: event.directory_id,
|
||||
groupName: eventData.name,
|
||||
organizationId,
|
||||
},
|
||||
select: {
|
||||
teamId: true,
|
||||
team: {
|
||||
include: {
|
||||
parent: {
|
||||
include: {
|
||||
organizationSettings: true,
|
||||
},
|
||||
},
|
||||
organizationSettings: true,
|
||||
},
|
||||
},
|
||||
groupName: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!groupNames.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const org = await getTeamOrThrow(organizationId);
|
||||
|
||||
// Check if the group member display property is an email
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const isEmail = emailRegex.test(eventData.raw.members[0].display);
|
||||
|
||||
let userEmails: string[] = [];
|
||||
|
||||
// TODO: Handle the case where display property is not an email
|
||||
|
||||
if (isEmail) {
|
||||
userEmails = eventData.raw.members.map((member: { display: string }) => member.display);
|
||||
}
|
||||
|
||||
// Find existing users
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
email: {
|
||||
in: userEmails,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
organizationId: true,
|
||||
completedOnboarding: true,
|
||||
identityProvider: true,
|
||||
profiles: true,
|
||||
locale: true,
|
||||
teams: true,
|
||||
password: {
|
||||
select: {
|
||||
hash: true,
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const translation = await getTranslation("en", "common");
|
||||
|
||||
const newUserEmails = userEmails.filter((email) => !users.find((user) => user.email === email));
|
||||
// For each team linked to the dsync group name provision members
|
||||
for (const group of groupNames) {
|
||||
if (newUserEmails.length) {
|
||||
const createUsersAndConnectToOrgProps = {
|
||||
emailsToCreate: newUserEmails,
|
||||
organizationId: org.id,
|
||||
identityProvider: IdentityProvider.CAL,
|
||||
identityProviderId: null,
|
||||
};
|
||||
const newUsers = await createUsersAndConnectToOrg(createUsersAndConnectToOrgProps);
|
||||
await prisma.membership.createMany({
|
||||
data: newUsers.map((user) => ({
|
||||
userId: user.id,
|
||||
teamId: group.teamId,
|
||||
role: MembershipRole.MEMBER,
|
||||
accepted: true,
|
||||
})),
|
||||
});
|
||||
await Promise.all(
|
||||
newUserEmails.map((email) => {
|
||||
return sendSignupToOrganizationEmail({
|
||||
usernameOrEmail: email,
|
||||
team: group.team,
|
||||
translation,
|
||||
inviterName: org.name,
|
||||
teamId: group.teamId,
|
||||
isOrg: false,
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// For existing users create membership for team and org if needed
|
||||
await prisma.membership.createMany({
|
||||
data: [
|
||||
...users
|
||||
.map((user) => {
|
||||
return [
|
||||
{
|
||||
userId: user.id,
|
||||
teamId: group.teamId,
|
||||
role: MembershipRole.MEMBER,
|
||||
accepted: true,
|
||||
},
|
||||
{
|
||||
userId: user.id,
|
||||
teamId: organizationId,
|
||||
role: MembershipRole.MEMBER,
|
||||
accepted: true,
|
||||
},
|
||||
];
|
||||
})
|
||||
.flat(),
|
||||
],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
|
||||
// Send emails to new members
|
||||
const newMembers = users.filter((user) => !user.teams.find((team) => team.id === group.teamId));
|
||||
const newOrgMembers = users.filter(
|
||||
(user) => !user.profiles.find((profile) => profile.organizationId === organizationId)
|
||||
);
|
||||
|
||||
await Promise.all([
|
||||
...newMembers.map(async (user) => {
|
||||
const translation = await getTranslation(user.locale || "en", "common");
|
||||
return sendExistingUserTeamInviteEmails({
|
||||
currentUserTeamName: group.team.name,
|
||||
existingUsersWithMemberships: [
|
||||
{
|
||||
...user,
|
||||
profile: null,
|
||||
},
|
||||
],
|
||||
language: translation,
|
||||
isOrg: false,
|
||||
teamId: group.teamId,
|
||||
isAutoJoin: true,
|
||||
currentUserParentTeamName: org.name,
|
||||
orgSlug: null,
|
||||
});
|
||||
}),
|
||||
...newOrgMembers.map((user) => {
|
||||
return createAProfileForAnExistingUser({
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
currentUsername: user.username,
|
||||
},
|
||||
organizationId,
|
||||
});
|
||||
}),
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
export default handleGroupEvents;
|
||||
93
calcom/packages/features/ee/dsync/lib/handleUserEvents.ts
Normal file
93
calcom/packages/features/ee/dsync/lib/handleUserEvents.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { DirectorySyncEvent, User } from "@boxyhq/saml-jackson";
|
||||
|
||||
import removeUserFromOrg from "@calcom/features/ee/dsync/lib/removeUserFromOrg";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { IdentityProvider } from "@calcom/prisma/enums";
|
||||
import { getTeamOrThrow } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/utils";
|
||||
import type { UserWithMembership } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/utils";
|
||||
import { sendExistingUserTeamInviteEmails } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/utils";
|
||||
import { sendSignupToOrganizationEmail } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/utils";
|
||||
|
||||
import createUsersAndConnectToOrg from "./users/createUsersAndConnectToOrg";
|
||||
import dSyncUserSelect from "./users/dSyncUserSelect";
|
||||
import inviteExistingUserToOrg from "./users/inviteExistingUserToOrg";
|
||||
|
||||
const handleUserEvents = async (event: DirectorySyncEvent, organizationId: number) => {
|
||||
const eventData = event.data as User;
|
||||
const userEmail = eventData.email;
|
||||
// Check if user exists in DB
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: userEmail,
|
||||
},
|
||||
select: dSyncUserSelect,
|
||||
});
|
||||
|
||||
// User is already a part of that org
|
||||
if (user?.organizationId && eventData.active) {
|
||||
return;
|
||||
}
|
||||
|
||||
const translation = await getTranslation(user?.locale || "en", "common");
|
||||
|
||||
const org = await getTeamOrThrow(organizationId);
|
||||
|
||||
if (!org) {
|
||||
throw new Error("Org not found");
|
||||
}
|
||||
|
||||
if (user) {
|
||||
if (eventData.active) {
|
||||
// If data.active is true then provision the user into the org
|
||||
const addedUser = await inviteExistingUserToOrg({
|
||||
user: user as UserWithMembership,
|
||||
org,
|
||||
translation,
|
||||
});
|
||||
|
||||
await sendExistingUserTeamInviteEmails({
|
||||
currentUserName: user.username,
|
||||
currentUserTeamName: org.name,
|
||||
existingUsersWithMemberships: [
|
||||
{
|
||||
...addedUser,
|
||||
profile: null,
|
||||
},
|
||||
],
|
||||
language: translation,
|
||||
isOrg: true,
|
||||
teamId: org.id,
|
||||
isAutoJoin: true,
|
||||
currentUserParentTeamName: org?.parent?.name,
|
||||
orgSlug: org.slug,
|
||||
});
|
||||
} else {
|
||||
// If data.active is false then remove the user from the org
|
||||
await removeUserFromOrg({
|
||||
userId: user.id,
|
||||
orgId: organizationId,
|
||||
});
|
||||
}
|
||||
// If user is not in DB, create user and add to the org
|
||||
} else {
|
||||
const createUsersAndConnectToOrgProps = {
|
||||
emailsToCreate: [userEmail],
|
||||
organizationId: org.id,
|
||||
identityProvider: IdentityProvider.CAL,
|
||||
identityProviderId: null,
|
||||
};
|
||||
await createUsersAndConnectToOrg(createUsersAndConnectToOrgProps);
|
||||
|
||||
await sendSignupToOrganizationEmail({
|
||||
usernameOrEmail: userEmail,
|
||||
team: org,
|
||||
translation,
|
||||
inviterName: org.name,
|
||||
teamId: organizationId,
|
||||
isOrg: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default handleUserEvents;
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { TFunction } from "next-i18next";
|
||||
|
||||
import { createAProfileForAnExistingUser } from "@calcom/lib/createAProfileForAnExistingUser";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { sendExistingUserTeamInviteEmails } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/utils";
|
||||
import type { UserWithMembership } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/utils";
|
||||
|
||||
/**
|
||||
* This should only be used in a dsync context
|
||||
*/
|
||||
const inviteExistingUserToOrg = async ({
|
||||
user,
|
||||
org,
|
||||
translation,
|
||||
}: {
|
||||
user: UserWithMembership;
|
||||
org: { id: number; name: string; parent: { name: string } | null };
|
||||
translation: TFunction;
|
||||
}) => {
|
||||
await createAProfileForAnExistingUser({
|
||||
user,
|
||||
organizationId: org.id,
|
||||
});
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
organizationId: org.id,
|
||||
teams: {
|
||||
create: {
|
||||
teamId: org.id,
|
||||
role: "MEMBER",
|
||||
// Since coming from directory assume it'll be verified
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await sendExistingUserTeamInviteEmails({
|
||||
currentUserName: user.username,
|
||||
currentUserTeamName: org.name,
|
||||
existingUsersWithMemberships: [user],
|
||||
language: translation,
|
||||
isOrg: true,
|
||||
teamId: org.id,
|
||||
isAutoJoin: true,
|
||||
currentUserParentTeamName: org?.parent?.name,
|
||||
});
|
||||
};
|
||||
|
||||
export default inviteExistingUserToOrg;
|
||||
11
calcom/packages/features/ee/dsync/lib/removeUserFromOrg.ts
Normal file
11
calcom/packages/features/ee/dsync/lib/removeUserFromOrg.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import removeMember from "@calcom/features/ee/teams/lib/removeMember";
|
||||
|
||||
const removeUserFromOrg = async ({ userId, orgId }: { userId: number; orgId: number }) => {
|
||||
return removeMember({
|
||||
memberId: userId,
|
||||
teamId: orgId,
|
||||
isOrg: true,
|
||||
});
|
||||
};
|
||||
|
||||
export default removeUserFromOrg;
|
||||
@@ -0,0 +1,49 @@
|
||||
import { canAccess } from "@calcom/features/ee/sso/lib/saml";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
const userCanCreateTeamGroupMapping = async (
|
||||
user: NonNullable<TrpcSessionUser>,
|
||||
organizationId: number | null,
|
||||
teamId?: number
|
||||
) => {
|
||||
if (!organizationId) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Could not find organization id",
|
||||
});
|
||||
}
|
||||
|
||||
const { message, access } = await canAccess(user, organizationId);
|
||||
if (!access) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
if (teamId) {
|
||||
const orgTeam = await prisma.team.findFirst({
|
||||
where: {
|
||||
id: teamId,
|
||||
parentId: organizationId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!orgTeam) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Could not find team",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { organizationId };
|
||||
};
|
||||
|
||||
export default userCanCreateTeamGroupMapping;
|
||||
@@ -0,0 +1,76 @@
|
||||
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { IdentityProvider } from "@calcom/prisma/enums";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import dSyncUserSelect from "./dSyncUserSelect";
|
||||
|
||||
type createUsersAndConnectToOrgPropsType = {
|
||||
emailsToCreate: string[];
|
||||
organizationId: number;
|
||||
identityProvider: IdentityProvider;
|
||||
identityProviderId: string | null;
|
||||
};
|
||||
|
||||
const createUsersAndConnectToOrg = async (
|
||||
createUsersAndConnectToOrgProps: createUsersAndConnectToOrgPropsType
|
||||
) => {
|
||||
const { emailsToCreate, organizationId, identityProvider, identityProviderId } =
|
||||
createUsersAndConnectToOrgProps;
|
||||
// As of Mar 2024 Prisma createMany does not support nested creates and returning created records
|
||||
await prisma.user.createMany({
|
||||
data: emailsToCreate.map((email) => {
|
||||
const [emailUser, emailDomain] = email.split("@");
|
||||
const username = slugify(`${emailUser}-${emailDomain.split(".")[0]}`);
|
||||
const name = username
|
||||
.split("-")
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
return {
|
||||
username,
|
||||
email,
|
||||
name,
|
||||
// Assume verified since coming from directory
|
||||
verified: true,
|
||||
emailVerified: new Date(),
|
||||
invitedTo: organizationId,
|
||||
organizationId,
|
||||
identityProvider,
|
||||
identityProviderId,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
email: {
|
||||
in: emailsToCreate,
|
||||
},
|
||||
},
|
||||
select: dSyncUserSelect,
|
||||
});
|
||||
|
||||
await prisma.membership.createMany({
|
||||
data: users.map((user) => ({
|
||||
accepted: true,
|
||||
userId: user.id,
|
||||
teamId: organizationId,
|
||||
role: MembershipRole.MEMBER,
|
||||
})),
|
||||
});
|
||||
|
||||
await prisma.profile.createMany({
|
||||
data: users.map((user) => ({
|
||||
uid: ProfileRepository.generateProfileUid(),
|
||||
userId: user.id,
|
||||
// The username is already set when creating the user
|
||||
username: user.username!,
|
||||
organizationId,
|
||||
})),
|
||||
});
|
||||
|
||||
return users;
|
||||
};
|
||||
|
||||
export default createUsersAndConnectToOrg;
|
||||
@@ -0,0 +1,17 @@
|
||||
const dSyncUserSelect = {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
organizationId: true,
|
||||
completedOnboarding: true,
|
||||
identityProvider: true,
|
||||
profiles: true,
|
||||
locale: true,
|
||||
password: {
|
||||
select: {
|
||||
hash: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default dSyncUserSelect;
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { TFunction } from "next-i18next";
|
||||
|
||||
import { createAProfileForAnExistingUser } from "@calcom/lib/createAProfileForAnExistingUser";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { UserWithMembership } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/utils";
|
||||
|
||||
/**
|
||||
* This should only be used in a dsync context
|
||||
*/
|
||||
const inviteExistingUserToOrg = async ({
|
||||
user,
|
||||
org,
|
||||
translation,
|
||||
}: {
|
||||
user: UserWithMembership;
|
||||
org: { id: number; name: string; parent: { name: string } | null };
|
||||
translation: TFunction;
|
||||
}) => {
|
||||
await createAProfileForAnExistingUser({
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
currentUsername: user.username,
|
||||
},
|
||||
organizationId: org.id,
|
||||
});
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
organizationId: org.id,
|
||||
teams: {
|
||||
create: {
|
||||
teamId: org.id,
|
||||
role: "MEMBER",
|
||||
// Since coming from directory assume it'll be verified
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return user;
|
||||
};
|
||||
|
||||
export default inviteExistingUserToOrg;
|
||||
61
calcom/packages/features/ee/dsync/page/team-dsync-view.tsx
Normal file
61
calcom/packages/features/ee/dsync/page/team-dsync-view.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Meta, SkeletonLoader, showToast } from "@calcom/ui";
|
||||
|
||||
import { getLayout } from "../../../settings/layouts/SettingsLayout";
|
||||
import ConfigureDirectorySync from "../components/ConfigureDirectorySync";
|
||||
|
||||
// For Hosted Cal - Team view
|
||||
const DirectorySync = () => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
const { data: currentOrg, isLoading, error } = trpc.viewer.organizations.listCurrent.useQuery();
|
||||
|
||||
useEffect(() => {
|
||||
if (!HOSTED_CAL_FEATURES) {
|
||||
router.push("/404");
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
if (isLoading) {
|
||||
return <SkeletonLoader />;
|
||||
}
|
||||
|
||||
if (!currentOrg?.id) {
|
||||
router.push("/404");
|
||||
}
|
||||
|
||||
if (error) {
|
||||
showToast(error.message, "error");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-default w-full sm:mx-0 xl:mt-0">
|
||||
<Meta title={t("directory_sync")} description={t("directory_sync_description")} />
|
||||
{HOSTED_CAL_FEATURES && <ConfigureDirectorySync organizationId={currentOrg?.id || null} />}
|
||||
{/* TODO add additional settings for dsync */}
|
||||
{/* <SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
title="Map groups to teams 1:1"
|
||||
description="Members will be auto assigned to teams with the same name as their group."
|
||||
switchContainerClassName="mt-6"
|
||||
/>
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
title="Create new teams based on groups"
|
||||
description="Automatically create new teams if a new group is pushed"
|
||||
switchContainerClassName="mt-6"
|
||||
/>
|
||||
<Button>Default team config</Button> */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
DirectorySync.getLayout = getLayout;
|
||||
|
||||
export default DirectorySync;
|
||||
Reference in New Issue
Block a user