import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' import { DiscardChangesConfirmationDialog } from 'components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { OrganizationProjectSelector } from 'components/ui/OrganizationProjectSelector' import { UpgradePlanButton } from 'components/ui/UpgradePlanButton' import { useOrganizationCreateInvitationMutation } from 'data/organization-members/organization-invitation-create-mutation' import { useOrganizationRolesV2Query } from 'data/organization-members/organization-roles-query' import { useOrganizationMembersQuery } from 'data/organizations/organization-members-query' import { useHasAccessToProjectLevelPermissions } from 'data/subscriptions/org-subscription-query' import { useCheckEntitlements } from 'hooks/misc/useCheckEntitlements' import { doPermissionsCheck, useGetPermissions } from 'hooks/misc/useCheckPermissions' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useConfirmOnClose } from 'hooks/ui/useConfirmOnClose' import { DOCS_URL } from 'lib/constants' import { useProfile } from 'lib/profile' import { UserPlus } from 'lucide-react' import { useEffect, useState } from 'react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' import { Button, Dialog, DialogContent, DialogFooter, DialogHeader, DialogSection, DialogSectionSeparator, DialogTitle, DialogTrigger, ExpandingTextArea, Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, Select_Shadcn_, SelectContent_Shadcn_, SelectGroup_Shadcn_, SelectItem_Shadcn_, SelectTrigger_Shadcn_, Switch, } from 'ui' import { Admonition } from 'ui-patterns' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import * as z from 'zod' import { useGetRolesManagementPermissions } from './TeamSettings.utils' import { DocsButton } from '@/components/ui/DocsButton' function parseEmails(value: string): string[] { return value .split(',') .map((e) => e.trim()) .filter(Boolean) } export const InviteMemberButton = () => { const { slug } = useParams() const { profile } = useProfile() const { data: organization } = useSelectedOrganizationQuery() const { permissions: permissions } = useGetPermissions() const { organizationMembersCreate: organizationMembersCreationEnabled } = useIsFeatureEnabled([ 'organization_members:create', ]) const [isOpen, setIsOpen] = useState(false) const [projectDropdownOpen, setProjectDropdownOpen] = useState(false) const { data: members } = useOrganizationMembersQuery({ slug }) const { data: allRoles, isSuccess } = useOrganizationRolesV2Query({ slug }) const orgScopedRoles = allRoles?.org_scoped_roles ?? [] const defaultValues = { email: '', role: orgScopedRoles.find((role) => role.name === 'Developer')?.id.toString() ?? '', applyToOrg: true, projectRef: '', } const { hasAccess: hasAccessToSso } = useCheckEntitlements('auth.platform.sso') const hasAccessToProjectLevelPermissions = useHasAccessToProjectLevelPermissions(slug as string) const userMemberData = members?.find((m) => m.gotrue_id === profile?.gotrue_id) const hasOrgRole = (userMemberData?.role_ids ?? []).length === 1 && orgScopedRoles.some((r) => r.id === userMemberData?.role_ids[0]) const { rolesAddable } = useGetRolesManagementPermissions( organization?.slug, orgScopedRoles, permissions ?? [] ) const canInviteMembers = hasOrgRole && rolesAddable.length > 0 && orgScopedRoles.some(({ id: role_id }) => doPermissionsCheck( permissions, PermissionAction.CREATE, 'user_invites', { resource: { role_id } }, organization?.slug ) ) const { mutateAsync: inviteMemberAsync, isPending: isInviting } = useOrganizationCreateInvitationMutation() const emailSchema = z .string() .min(1, 'At least one email address is required') .refine( (val) => { const emails = parseEmails(val) if (emails.length === 0) return false return emails.every((e) => z.string().email().safeParse(e).success) }, (val) => { const emails = parseEmails(val) const invalid = emails.find((e) => !z.string().email().safeParse(e).success) return { message: invalid ? `Invalid email address: ${invalid}` : 'At least one email address is required', } } ) const FormSchema = z.object({ email: emailSchema, role: z.string().min(1, 'Role is required'), applyToOrg: z.boolean(), projectRef: z.string(), }) const form = useForm>({ mode: 'onSubmit', reValidateMode: 'onChange', resolver: zodResolver(FormSchema), defaultValues, }) const { applyToOrg, projectRef, email } = form.watch() const emailCount = parseEmails(email ?? '').length const onInviteMember = async (values: z.infer) => { if (!slug) return console.error('Slug is required') if (profile?.id === undefined) return console.error('Profile ID required') const emails = parseEmails(values.email).map((e) => e.toLowerCase()) const alreadyInvited: string[] = [] const alreadyMembers: string[] = [] const toInvite: string[] = [] for (const emailAddress of emails) { const existingMember = (members ?? []).find((member) => member.primary_email === emailAddress) if (existingMember !== undefined) { if (existingMember.invited_id) { alreadyInvited.push(emailAddress) } else { alreadyMembers.push(emailAddress) } } else { toInvite.push(emailAddress) } } if (alreadyInvited.length > 0) { toast.error( alreadyInvited.length === 1 ? `${alreadyInvited[0]} has already been invited to this organization` : `${alreadyInvited.length} emails have already been invited to this organization` ) } if (alreadyMembers.length > 0) { toast.error( alreadyMembers.length === 1 ? `${alreadyMembers[0]} is already in this organization` : `${alreadyMembers.length} emails are already in this organization` ) } if (alreadyInvited.length > 0 || alreadyMembers.length > 0) { if (toInvite.length === 0) return } const projectPayload = !values.applyToOrg && values.projectRef ? { projects: [values.projectRef] } : {} const results = await Promise.allSettled( toInvite.map((emailAddress) => inviteMemberAsync({ slug, email: emailAddress, roleId: Number(values.role), ...projectPayload, }) ) ) const successCount = results.filter((r) => r.status === 'fulfilled').length const failedEmails = toInvite.filter((_, i) => results[i].status === 'rejected') if (successCount > 0) { toast.success( successCount === 1 ? 'Successfully sent invitation to new member' : `Successfully sent invitations to ${successCount} new members` ) closeInviteDialog() } if (failedEmails.length > 0) { toast.error( failedEmails.length === 1 ? `Failed to send invitation to ${failedEmails[0]}` : `Failed to send invitations to ${failedEmails.length} emails` ) } } useEffect(() => { if (isSuccess && isOpen) { const developerRoleId = orgScopedRoles .find((role) => role.name === 'Developer') ?.id.toString() if (developerRoleId !== undefined && form.getValues('role') === '') { form.setValue('role', developerRoleId, { shouldDirty: false }) } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isSuccess, isOpen]) const hasUnsavedChanges = form.formState.isDirty const closeInviteDialog = () => { setProjectDropdownOpen(false) setIsOpen(false) form.reset(defaultValues) } const { confirmOnClose, handleOpenChange, modalProps: discardChangesModalProps, } = useConfirmOnClose({ checkIsDirty: () => hasUnsavedChanges, onClose: closeInviteDialog, }) return ( } className="pointer-events-auto flex-grow md:flex-grow-0" onClick={() => setIsOpen(true)} tooltip={{ content: { side: 'bottom', text: !organizationMembersCreationEnabled ? 'Inviting members is currently disabled' : !canInviteMembers ? 'You need additional permissions to invite members to this organization' : undefined, }, }} > Invite members Invite team members {!hasAccessToSso && ( )} } />
( {orgScopedRoles.find((role) => role.id === Number(field.value))?.name ?? 'Unknown'} {orgScopedRoles.map((role) => { const canAssignRole = rolesAddable.includes(role.id) return ( {role.name} {!canAssignRole && ( Additional permissions required to assign role )} ) })} )} /> {hasAccessToProjectLevelPermissions && ( ( )} /> )} {!applyToOrg && ( ( field.onChange(project.ref)} onInitialLoad={(projects) => field.onChange(projects[0]?.ref ?? '')} /> )} /> )} ( )} />
) }