import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' import { UserPlus } from 'lucide-react' import { useEffect, useState } from 'react' import { useForm, useWatch } from 'react-hook-form' import { toast } from 'sonner' import { Button, ExpandingTextArea, Form, FormControl, FormField, FormItem, RadioGroupStacked, RadioGroupStackedItem, Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetSection, SheetTitle, SheetTrigger, Switch, } from 'ui' import { Admonition } from 'ui-patterns/Admonition' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import * as z from 'zod' import { BatchInvitationResult, buildProjectPayload, buildSsoPayload, categorizeInviteEmails, emailSchema, parseEmails, } from './InviteMemberButton.utils' import { ROLE_DESCRIPTIONS } from './Roles.constants' import { useGetRolesManagementPermissions } from './TeamSettings.utils' import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { DocsButton } from '@/components/ui/DocsButton' import { InlineLink } from '@/components/ui/InlineLink' import { OrganizationProjectSelector } from '@/components/ui/OrganizationProjectSelector' import { Shortcut } from '@/components/ui/Shortcut' 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 { useOrgSSOConfigQuery } from '@/data/sso/sso-config-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 { MANAGED_BY } from '@/lib/constants/infrastructure' import { useProfile } from '@/lib/profile' import { SHORTCUT_IDS } from '@/state/shortcuts/registry' 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 [pendingInvite, setPendingInvite] = useState>() const [projectDropdownOpen, setProjectDropdownOpen] = useState(false) const { data: members } = useOrganizationMembersQuery({ slug }) const { data: allRoles, isSuccess } = useOrganizationRolesV2Query({ slug }) const orgScopedRoles = allRoles?.org_scoped_roles ?? [] const { data: ssoConfig } = useOrgSSOConfigQuery({ orgSlug: slug }) const hasSsoProvider = !!ssoConfig && ssoConfig !== null const defaultValues = { email: '', role: orgScopedRoles.find((role) => role.name === 'Developer')?.id.toString() ?? '', applyToOrg: true, projectRef: '', requireSso: 'auto' as const, } 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 isStripeProjectsOrg = organization?.managed_by === MANAGED_BY.STRIPE_PROJECTS 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 inviteDisabledReason = !organizationMembersCreationEnabled ? 'Inviting members is currently disabled' : !canInviteMembers ? 'You need additional permissions to invite members to this organization' : undefined const isInviteDisabled = inviteDisabledReason !== undefined const { mutateAsync: inviteMemberAsync, isPending: isInviting } = useOrganizationCreateInvitationMutation() const FormSchema = z .object({ email: emailSchema, role: z.string().min(1, 'Role is required'), applyToOrg: z.boolean(), projectRef: z.string(), requireSso: z.enum(['auto', 'sso', 'non-sso']), }) .superRefine((data, ctx) => { if (!data.applyToOrg && !data.projectRef) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'A project must be selected', path: ['projectRef'], }) } }) const form = useForm>({ mode: 'onSubmit', reValidateMode: 'onChange', resolver: zodResolver(FormSchema), defaultValues, }) const [applyToOrg, projectRef, email] = useWatch({ control: form.control, name: ['applyToOrg', 'projectRef', 'email'], }) const emailCount = parseEmails(email ?? '').length const getRoleName = (roleId?: string) => orgScopedRoles.find((role) => role.id.toString() === roleId)?.name const pendingRoleName = getRoleName(pendingInvite?.role) 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, alreadyMembers, toInvite } = categorizeInviteEmails( emails, members ?? [] ) 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 = buildProjectPayload(values.applyToOrg, values.projectRef) const ssoPayload = buildSsoPayload(values.requireSso) let result: BatchInvitationResult try { result = (await inviteMemberAsync({ slug, emails: toInvite, roleId: Number(values.role), ...projectPayload, ...ssoPayload, })) as BatchInvitationResult } catch { return // onError callback already showed the toast } const { succeeded, failed } = result if (succeeded.length > 0) { toast.success( succeeded.length === 1 ? 'Successfully sent invitation to new member' : `Successfully sent invitations to ${succeeded.length} new members` ) } for (const { email, error } of failed) { toast.error(`Failed to invite ${email}: ${error}`) } if (succeeded.length > 0) { closeInviteSheet() } } const handleInviteSubmit = (values: z.infer) => { const roleName = getRoleName(values.role) const needsConfirmation = roleName === 'Owner' || roleName === 'Administrator' if (needsConfirmation) setPendingInvite(values) else onInviteMember(values) } const handleConfirmInvite = async () => { if (pendingInvite) await onInviteMember(pendingInvite) setPendingInvite(undefined) } 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 closeInviteSheet = () => { setProjectDropdownOpen(false) setIsOpen(false) form.reset(defaultValues) } const { confirmOnClose, handleOpenChange, modalProps: discardChangesModalProps, } = useConfirmOnClose({ checkIsDirty: () => hasUnsavedChanges, onClose: closeInviteSheet, }) return ( setIsOpen(true)} options={{ enabled: !isInviteDisabled }} side="bottom" tooltipOpen={isOpen || isInviteDisabled ? false : undefined} > } className="pointer-events-auto grow md:grow-0" onClick={() => setIsOpen(true)} tooltip={{ content: { side: 'bottom', text: inviteDisabledReason, }, }} > Invite members Invite team members Send invitations and choose the access each new team member receives. {!hasAccessToSso && ( )} } />
( Learn more about{' '} roles and permissions } > {orgScopedRoles.map((role) => { const canAssignRole = rolesAddable.includes(role.id) const isOwnerRole = role.name === 'Owner' const disabledForStripe = isStripeProjectsOrg && isOwnerRole const disabled = !canAssignRole || disabledForStripe const disabledReason = disabledForStripe ? 'Cannot be assigned in Stripe Projects organizations' : !canAssignRole ? 'Additional permissions required to assign role' : undefined return ( {ROLE_DESCRIPTIONS[role.name] ?? 'Permissions are based on the configured organization role.'} {disabledReason && ` ${disabledReason}`} } /> ) })} )} /> {hasSsoProvider && ( ( )} /> )} {hasAccessToProjectLevelPermissions && ( ( )} /> )} {!applyToOrg && ( ( field.onChange(project.ref)} onInitialLoad={(projects) => field.onChange(projects[0]?.ref ?? '')} /> )} /> )} ( )} />
form.handleSubmit(handleInviteSubmit)()} options={{ enabled: isOpen && !isInviting }} side="top" >
setPendingInvite(undefined)} onConfirm={handleConfirmInvite} />
) }