mirror of
https://github.com/supabase/supabase.git
synced 2026-10-08 10:55:06 +03:00
## Problem As the API has allow inviting users into `None / No-access` role for team, enterprise, and platform tier organization, we need to update the documentation and descriptions for this new role on the invitation form. ## Solution 1. Updated `apps/docs/content/guides/platform/access-control.mdx` to include the role 2. Added the role description on `apps/studio/components/interfaces/Organization/TeamSettings/Roles.constants.tsx` 3. Add the roles into the proper sorting order at `apps/studio/data/organization-members/organization-roles-query.ts` 4. Add logic to invitation components to disable the role when inviting user into project(s), as the backend does not allow it. ## Testing and verification steps The UI: https://studio-staging-aa8is1m07-supabase.vercel.app/dashboard/org Documentation: https://docs-kht98bi78-supabase.vercel.app/docs/guides/platform/access-control <!-- ## Preview links If relevant, include links to changed pages for easy review access. Copy the preview base URL from the Vercel bot comment on this PR. Use the following table as an example template. | Site | Live | Preview | Search for | | -------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ----------------------------- | | WWW | [/blog/your-post](https://supabase.com/blog/your-post) | [/blog/your-post](https://zone-www-dot-com-git-branch-name-supabase.vercel.app/blog/your-post) | unique phrase from the change | | Docs | [/docs/guides/your-page](https://supabase.com/docs/guides/your-page) | [/docs/guides/your-page](https://docs-git-branch-name-supabase.vercel.app/docs/guides/your-page) | unique phrase from the change | | Studio | [/dashboard](https://supabase.com/dashboard) | [/dashboard](https://studio-git-branch-name-supabase.vercel.app/dashboard) | unique phrase from the change | | Design system | [/design-system](https://supabase.com/design-system) | [/design-system](https://design-system-git-branch-name-supabase.vercel.app/design-system) | unique phrase from the change | | UI library | [/library](https://supabase.com/library) | [/library](https://ui-library-git-branch-name-supabase.vercel.app/library) | unique phrase from the change | | Knowledge base | [/kb/guides/your-page](https://supabase.com/kb/guides/your-page) | [/kb/guides/your-page](https://kb-git-branch-name-supabase.vercel.app/kb/guides/your-page) | unique phrase from the change | --> <!-- ## Additional context Optionally add any other context or screenshots. --> ## Checklist Check all before review: - [x] I have read [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) - [x] If I wrote a new docs topic or edited an existing topic, I used the `/write-the-docs` or `/edit-the-docs` skill, which references [WORD_LIST](https://github.com/supabase/supabase/blob/master/apps/docs/WORD_LIST.md) and the docs [CONTRIBUTING](https://github.com/supabase/supabase/blob/master/apps/docs/CONTRIBUTING.md) guide <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Summary * **Updates** * The **None** role is labeled **No-access** and describes the lack of organization and project resource access. * **None** is included after **Read-only** in the role list. When inviting a member with project-only access, **None** is disabled with an explanation. * **Documentation** * Clarified plan coverage for **Read-Only** and **No access**, and added guidance on assigning **No access** at the organization level before granting project-specific roles. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
536 lines
20 KiB
TypeScript
536 lines
20 KiB
TypeScript
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<z.infer<typeof FormSchema>>()
|
|
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<z.infer<typeof FormSchema>>({
|
|
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<typeof FormSchema>) => {
|
|
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<typeof FormSchema>) => {
|
|
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 (
|
|
<Sheet open={isOpen} onOpenChange={handleOpenChange}>
|
|
<SheetTrigger asChild>
|
|
<Shortcut
|
|
id={SHORTCUT_IDS.ORG_TEAM_INVITE}
|
|
onTrigger={() => setIsOpen(true)}
|
|
options={{ enabled: !isInviteDisabled }}
|
|
side="bottom"
|
|
tooltipOpen={isOpen || isInviteDisabled ? false : undefined}
|
|
>
|
|
<ButtonTooltip
|
|
variant="primary"
|
|
disabled={isInviteDisabled}
|
|
icon={<UserPlus size={14} />}
|
|
className="pointer-events-auto grow md:grow-0"
|
|
onClick={() => setIsOpen(true)}
|
|
tooltip={{
|
|
content: {
|
|
side: 'bottom',
|
|
text: inviteDisabledReason,
|
|
},
|
|
}}
|
|
>
|
|
Invite members
|
|
</ButtonTooltip>
|
|
</Shortcut>
|
|
</SheetTrigger>
|
|
<SheetContent size="lg" className="flex flex-col gap-0">
|
|
<SheetHeader>
|
|
<SheetTitle>Invite team members</SheetTitle>
|
|
<SheetDescription>
|
|
Send invitations and choose the access each new team member receives.
|
|
</SheetDescription>
|
|
</SheetHeader>
|
|
<Admonition
|
|
type="note"
|
|
showIcon={false}
|
|
title="Single Sign-On (SSO) available"
|
|
layout={!hasAccessToSso ? 'vertical' : 'horizontal'}
|
|
className="rounded-none border-t-0 border-x-0 px-5"
|
|
description="Enforce login via your company identity provider for added security and access control. Available on Team plan and above."
|
|
actions={
|
|
<>
|
|
<DocsButton href={`${DOCS_URL}/guides/platform/sso`} />
|
|
{!hasAccessToSso && (
|
|
<UpgradePlanButton
|
|
plan="Team"
|
|
source="inviteMemberSSO"
|
|
featureProposition="enable Single Sign-on (SSO)"
|
|
/>
|
|
)}
|
|
</>
|
|
}
|
|
/>
|
|
<SheetSection className="grow overflow-auto">
|
|
<Form {...form}>
|
|
<form
|
|
id="organization-invitation"
|
|
className="flex flex-col gap-y-4"
|
|
onSubmit={form.handleSubmit(handleInviteSubmit)}
|
|
>
|
|
<FormField
|
|
name="role"
|
|
control={form.control}
|
|
render={({ field }) => (
|
|
<FormItemLayout
|
|
layout="horizontal"
|
|
label="Role"
|
|
description={
|
|
<>
|
|
Learn more about{' '}
|
|
<InlineLink href={`${DOCS_URL}/guides/platform/access-control`}>
|
|
roles and permissions
|
|
</InlineLink>
|
|
</>
|
|
}
|
|
>
|
|
<FormControl className="col-span-6">
|
|
<RadioGroupStacked value={field.value} onValueChange={field.onChange}>
|
|
{orgScopedRoles.map((role) => {
|
|
const canAssignRole = rolesAddable.includes(role.id)
|
|
const isOwnerRole = role.name === 'Owner'
|
|
const disabledForStripe = isStripeProjectsOrg && isOwnerRole
|
|
|
|
const isNoAccessRole = role.name === 'No-access'
|
|
const isApplyingProjectRole = !applyToOrg
|
|
const disabledForProjectRole = isApplyingProjectRole && isNoAccessRole
|
|
|
|
const disabled =
|
|
!canAssignRole || disabledForStripe || disabledForProjectRole
|
|
const disabledReason = disabledForStripe
|
|
? 'Cannot be assigned in Stripe Projects organizations'
|
|
: disabledForProjectRole
|
|
? 'Cannot assign No-access role to project-scoped members'
|
|
: !canAssignRole
|
|
? 'Additional permissions required to assign role'
|
|
: undefined
|
|
|
|
return (
|
|
<FormItem asChild key={role.id}>
|
|
<FormControl>
|
|
<RadioGroupStackedItem
|
|
value={role.id.toString()}
|
|
disabled={disabled}
|
|
label={role.name}
|
|
description={
|
|
<>
|
|
{ROLE_DESCRIPTIONS[role.name] ??
|
|
'Permissions are based on the configured organization role.'}
|
|
{disabledReason && ` ${disabledReason}`}
|
|
</>
|
|
}
|
|
/>
|
|
</FormControl>
|
|
</FormItem>
|
|
)
|
|
})}
|
|
</RadioGroupStacked>
|
|
</FormControl>
|
|
</FormItemLayout>
|
|
)}
|
|
/>
|
|
{hasSsoProvider && (
|
|
<FormField
|
|
name="requireSso"
|
|
control={form.control}
|
|
render={({ field }) => (
|
|
<FormItemLayout
|
|
layout="horizontal"
|
|
label="Invitation type"
|
|
description="Choose how the invitee should authenticate"
|
|
>
|
|
<FormControl className="col-span-6">
|
|
<Select value={field.value} onValueChange={field.onChange}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Automatic (based on your account)" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectGroup>
|
|
<SelectItem value="auto">
|
|
Automatic (based on your account)
|
|
</SelectItem>
|
|
<SelectItem value="sso">Require SSO authentication</SelectItem>
|
|
<SelectItem value="non-sso">Email/password authentication</SelectItem>
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
</FormControl>
|
|
</FormItemLayout>
|
|
)}
|
|
/>
|
|
)}
|
|
{hasAccessToProjectLevelPermissions && (
|
|
<FormField
|
|
name="applyToOrg"
|
|
control={form.control}
|
|
render={({ field }) => (
|
|
<FormItemLayout
|
|
layout="horizontal"
|
|
label="Grant this role on all projects"
|
|
description="Apply this role to all current and future projects in the organization"
|
|
>
|
|
<FormControl className="col-span-6">
|
|
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
|
</FormControl>
|
|
</FormItemLayout>
|
|
)}
|
|
/>
|
|
)}
|
|
{!applyToOrg && (
|
|
<FormField
|
|
name="projectRef"
|
|
control={form.control}
|
|
render={({ field }) => (
|
|
<FormItemLayout
|
|
layout="horizontal"
|
|
label="Select a project"
|
|
description="Project access can be adjusted after the user joins"
|
|
>
|
|
<FormControl className="col-span-6">
|
|
<OrganizationProjectSelector
|
|
fetchOnMount
|
|
sameWidthAsTrigger
|
|
checkPosition="left"
|
|
selectedRef={projectRef}
|
|
open={projectDropdownOpen}
|
|
setOpen={setProjectDropdownOpen}
|
|
searchPlaceholder="Search project..."
|
|
onSelect={(project) => field.onChange(project.ref)}
|
|
onInitialLoad={(projects) => field.onChange(projects[0]?.ref ?? '')}
|
|
/>
|
|
</FormControl>
|
|
</FormItemLayout>
|
|
)}
|
|
/>
|
|
)}
|
|
<FormField
|
|
name="email"
|
|
control={form.control}
|
|
render={({ field }) => (
|
|
<FormItemLayout layout="horizontal" label="Email addresses">
|
|
<FormControl className="col-span-6">
|
|
<ExpandingTextArea
|
|
autoFocus
|
|
{...field}
|
|
autoComplete="off"
|
|
disabled={isInviting}
|
|
placeholder="name@example.com, name2@example.com, ..."
|
|
className="max-h-48"
|
|
data-1p-ignore
|
|
data-lpignore="true"
|
|
data-form-type="other"
|
|
data-bwignore
|
|
/>
|
|
</FormControl>
|
|
</FormItemLayout>
|
|
)}
|
|
/>
|
|
</form>
|
|
</Form>
|
|
</SheetSection>
|
|
<SheetFooter>
|
|
<Button onClick={confirmOnClose}>Cancel</Button>
|
|
<Shortcut
|
|
id={SHORTCUT_IDS.ORG_TEAM_INVITE_SUBMIT}
|
|
onTrigger={() => form.handleSubmit(handleInviteSubmit)()}
|
|
options={{ enabled: isOpen && !isInviting }}
|
|
side="top"
|
|
>
|
|
<Button
|
|
variant="primary"
|
|
form="organization-invitation"
|
|
type="submit"
|
|
loading={isInviting}
|
|
>
|
|
{emailCount >= 2 ? 'Send invitations' : 'Send invitation'}
|
|
</Button>
|
|
</Shortcut>
|
|
</SheetFooter>
|
|
</SheetContent>
|
|
<DiscardChangesConfirmationDialog
|
|
{...discardChangesModalProps}
|
|
description="Are you sure you want to discard your changes? Your invitation will not be sent."
|
|
/>
|
|
<ConfirmationModal
|
|
variant="warning"
|
|
visible={pendingInvite !== undefined}
|
|
loading={isInviting}
|
|
title={`Invite as ${pendingRoleName}?`}
|
|
description={pendingRoleName && ROLE_DESCRIPTIONS[pendingRoleName]}
|
|
confirmLabel="Send invitation"
|
|
confirmLabelLoading="Sending invitation..."
|
|
onCancel={() => setPendingInvite(undefined)}
|
|
onConfirm={handleConfirmInvite}
|
|
/>
|
|
</Sheet>
|
|
)
|
|
}
|