import { useSession } from "next-auth/react"; import { Trans } from "next-i18next"; import type { FormEvent } from "react"; import { useMemo, useRef, useState } from "react"; import { Controller, useForm } from "react-hook-form"; import TeamInviteFromOrg from "@calcom/ee/organizations/components/TeamInviteFromOrg"; import { classNames } from "@calcom/lib"; import { IS_TEAM_BILLING_ENABLED, MAX_NB_INVITES } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { MembershipRole } from "@calcom/prisma/enums"; import type { RouterOutputs } from "@calcom/trpc"; import { trpc } from "@calcom/trpc"; import { isEmail } from "@calcom/trpc/server/routers/viewer/teams/util"; import { Button, Dialog, DialogContent, DialogFooter, Form, Icon, Label, Select, showToast, TextAreaField, TextField, ToggleGroup, } from "@calcom/ui"; import type { PendingMember } from "../lib/types"; import { GoogleWorkspaceInviteButton } from "./GoogleWorkspaceInviteButton"; type MemberInvitationModalProps = { isOpen: boolean; onExit: () => void; orgMembers?: RouterOutputs["viewer"]["organizations"]["getMembers"]; onSubmit: (values: NewMemberForm, resetFields: () => void) => void; onSettingsOpen?: () => void; teamId: number; members?: PendingMember[]; token?: string; isPending?: boolean; disableCopyLink?: boolean; isOrg?: boolean; }; type MembershipRoleOption = { value: MembershipRole; label: string; }; export interface NewMemberForm { emailOrUsername: string | string[]; role: MembershipRole; } type ModalMode = "INDIVIDUAL" | "BULK" | "ORGANIZATION"; interface FileEvent extends FormEvent { target: EventTarget & T; } function toggleElementInArray(value: string[] | string | undefined, element: string): string[] { const array = value ? (Array.isArray(value) ? value : [value]) : []; return array.includes(element) ? array.filter((item) => item !== element) : [...array, element]; } export default function MemberInvitationModal(props: MemberInvitationModalProps) { const { t } = useLocale(); const { disableCopyLink = false, isOrg = false } = props; const trpcContext = trpc.useUtils(); const session = useSession(); const { data: currentOrg } = trpc.viewer.organizations.listCurrent.useQuery(undefined, { enabled: !!session.data?.user?.org, }); // Check current org role and not team role const isOrgAdminOrOwner = currentOrg && (currentOrg.user.role === MembershipRole.OWNER || currentOrg.user.role === MembershipRole.ADMIN); const canSeeOrganization = !!( props?.orgMembers && props.orgMembers?.length > 0 && currentOrg?.isPrivate && isOrgAdminOrOwner ); const [modalImportMode, setModalInputMode] = useState( canSeeOrganization ? "ORGANIZATION" : "INDIVIDUAL" ); const createInviteMutation = trpc.viewer.teams.createInvite.useMutation({ async onSuccess({ inviteLink }) { trpcContext.viewer.teams.get.invalidate(); trpcContext.viewer.teams.list.invalidate(); }, onError: (error) => { showToast(error.message, "error"); }, }); const options: MembershipRoleOption[] = useMemo(() => { const options: MembershipRoleOption[] = [ { value: MembershipRole.MEMBER, label: t("member") }, { value: MembershipRole.ADMIN, label: t("admin") }, { value: MembershipRole.OWNER, label: t("owner") }, ]; // Adjust options for organizations where the user isn't the owner if (isOrg && !isOrgAdminOrOwner) { return options.filter((option) => option.value !== MembershipRole.OWNER); } return options; }, [t, isOrgAdminOrOwner, isOrg]); const toggleGroupOptions = useMemo(() => { const array = [ { value: "INDIVIDUAL", label: t("invite_team_individual_segment"), iconLeft: , }, { value: "BULK", label: t("invite_team_bulk_segment"), iconLeft: }, ]; if (canSeeOrganization) { array.unshift({ value: "ORGANIZATION", label: t("organization"), iconLeft: , }); } return array; }, [t, canSeeOrganization]); const newMemberFormMethods = useForm(); const validateUniqueInvite = (value: string) => { if (!props?.members?.length) return true; return !( props?.members.some((member) => member?.username === value) || props?.members.some((member) => member?.email === value) ); }; const handleFileUpload = (e: FileEvent) => { if (!e.target.files?.length) { return; } const file = e.target.files[0]; if (file) { const reader = new FileReader(); const emailRegex = /^([A-Z0-9_+-]+\.?)*[A-Z0-9_+-]@([A-Z0-9][A-Z0-9-]*\.)+[A-Z]{2,}$/i; reader.onload = (e) => { const contents = e?.target?.result as string; const lines = contents.split("\n"); const validEmails = []; for (const line of lines) { const columns = line.split(/,|;|\|| /); for (const column of columns) { const email = column.trim().toLowerCase(); if (emailRegex.test(email)) { validEmails.push(email); break; // Stop checking columns if a valid email is found in this line } } } newMemberFormMethods.setValue("emailOrUsername", validEmails); }; reader.readAsText(file); } }; const resetFields = () => { newMemberFormMethods.reset(); newMemberFormMethods.setValue("emailOrUsername", ""); newMemberFormMethods.setValue("role", options[0].value); setModalInputMode("INDIVIDUAL"); }; const importRef = useRef(null); return ( { props.onExit(); newMemberFormMethods.reset(); }}> Note: This will cost an extra seat ($15/m){" "} on your subscription. ) : null }>
{ setModalInputMode(val as ModalMode); newMemberFormMethods.clearErrors(); }} defaultValue={modalImportMode} options={toggleGroupOptions} />
props.onSubmit(values, resetFields)}>
{/* Indivdual Invite */} {modalImportMode === "INDIVIDUAL" && ( { // orgs can only invite members by email if (typeof value === "string" && !isEmail(value)) return t("enter_email"); if (typeof value === "string") return validateUniqueInvite(value) || t("member_already_invited"); }, }} render={({ field: { onChange }, fieldState: { error } }) => ( <> onChange(e.target.value.trim().toLowerCase())} /> {error && {error.message}} )} /> )} {/* Bulk Invite */} {modalImportMode === "BULK" && (
{ if (Array.isArray(value) && value.some((email) => !isEmail(email))) return t("enter_emails"); if (Array.isArray(value) && value.length > MAX_NB_INVITES) return t("too_many_invites", { nbUsers: MAX_NB_INVITES }); if (typeof value === "string" && !isEmail(value)) return t("enter_email"); }, }} render={({ field: { onChange, value }, fieldState: { error } }) => ( <> {/* TODO: Make this a fancy email input that styles on a successful email. */} { const targetValues = e.target.value.split(/[\n,]/); const emails = targetValues.length === 1 ? targetValues[0].trim().toLocaleLowerCase() : targetValues.map((email) => email.trim().toLocaleLowerCase()); return onChange(emails); }} /> {error && {error.message}} )} /> { newMemberFormMethods.setValue("emailOrUsername", data); }} />
)} {modalImportMode === "ORGANIZATION" && ( ( <> { // If 'value' is not an array, create a new array with 'userEmail' to allow future updates to the array. // If 'value' is an array, update the array by either adding or removing 'userEmail'. const newValue = toggleElementInArray(value, userEmail); onChange(newValue); }} orgMembers={props.orgMembers} /> )} /> )} (