2
0

first commit

This commit is contained in:
2024-08-09 00:39:27 +02:00
commit 79688abe2e
5698 changed files with 497838 additions and 0 deletions

View File

@@ -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;

View 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;

View File

@@ -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;

View File

@@ -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;

View 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;

View File

@@ -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;

View File

@@ -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;

View 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",
},
];

View 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;

View 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;

View File

@@ -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;

View 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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View 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;