feat(studio): invite multiple team members at once (#42637)

## What kind of change does this PR introduce?

- Feature
- Resolves DEPR-355

## What is the current behavior?

Only one email address can be invited to an organization at a time.

## What is the new behavior?

- Multiple email addresses can be invited (at a single scope) to an
organization at one time
	- List of email addresses detected via comma-separation
	- Pluralization on fields and labels
- Table and copywriting cleanup

| Before | After |
| --- | --- |
| <img width="808" height="691" alt="Supabase"
src="https://github.com/user-attachments/assets/f6450c26-968c-4ee8-bb7c-d6f6a3af1209"
/> | <img width="808" height="691" alt="8298"
src="https://github.com/user-attachments/assets/2d7e3869-b9d4-4a4f-89aa-ae55d67e794b"
/> |
| <img width="1024" height="560" alt="Supabase"
src="https://github.com/user-attachments/assets/9a255167-fcd3-4294-ba3a-9160bd500cff"
/> | <img width="1024" height="560" alt="Supabase"
src="https://github.com/user-attachments/assets/daba5bb8-eb12-43dc-86de-217e9baf0b72"
/> |

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added support for inviting multiple team members simultaneously via
comma-separated emails.

* **Improvements**
* Enhanced member management interface with clearer status indicators
(You, Invited, SSO, MFA enabled/disabled).
* Improved feedback messages for invitation outcomes and member status
changes.
* Updated member table layout with summary footer displaying member
count.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Danny White authored and GitHub committed 2026-02-26 16:27:53 +11:00
1 parent aa1aa73200
commit be7cb44e01
5 files changed
+301 -178

No files matched your search

@@ -8,7 +8,6 @@ import * as z from 'zod'
import { useParams } from 'common'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import InformationBox from 'components/ui/InformationBox'
import { OrganizationProjectSelector } from 'components/ui/OrganizationProjectSelector'
import { UpgradePlanButton } from 'components/ui/UpgradePlanButton'
import { useOrganizationCreateInvitationMutation } from 'data/organization-members/organization-invitation-create-mutation'
@@ -21,6 +20,14 @@ import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization
import { DOCS_URL } from 'lib/constants'
import { useProfile } from 'lib/profile'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Button,
Dialog,
DialogContent,
@@ -29,10 +36,11 @@ import {
DialogSectionSeparator,
DialogTitle,
DialogTrigger,
ExpandingTextArea,
FormControl_Shadcn_,
FormField_Shadcn_,
DialogFooter,
Form_Shadcn_,
Input_Shadcn_,
SelectContent_Shadcn_,
SelectGroup_Shadcn_,
SelectItem_Shadcn_,
@@ -42,6 +50,15 @@ import {
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { useGetRolesManagementPermissions } from './TeamSettings.utils'
import { UserPlus } from 'lucide-react'
import { Admonition } from 'ui-patterns'
function parseEmails(value: string): string[] {
return value
.split(',')
.map((e) => e.trim())
.filter(Boolean)
}
export const InviteMemberButton = () => {
const { slug } = useParams()
@@ -54,6 +71,7 @@ export const InviteMemberButton = () => {
])
const [isOpen, setIsOpen] = useState(false)
const [isDiscardConfirmOpen, setIsDiscardConfirmOpen] = useState(false)
const [projectDropdownOpen, setProjectDropdownOpen] = useState(false)
const { data: members } = useOrganizationMembersQuery({ slug })
@@ -61,6 +79,7 @@ export const InviteMemberButton = () => {
const orgScopedRoles = allRoles?.org_scoped_roles ?? []
const currentPlan = organization?.plan
const isFreeOrProPlan = currentPlan?.id === 'free' || currentPlan?.id === 'pro'
const hasAccessToProjectLevelPermissions = useHasAccessToProjectLevelPermissions(slug as string)
const userMemberData = members?.find((m) => m.gotrue_id === profile?.gotrue_id)
@@ -87,77 +106,183 @@ export const InviteMemberButton = () => {
)
)
const { mutate: inviteMember, isPending: isInviting } = useOrganizationCreateInvitationMutation()
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: z.string().email('Must be a valid email address').min(1, 'Email is required'),
email: emailSchema,
role: z.string().min(1, 'Role is required'),
applyToOrg: z.boolean(),
projectRef: z.string(),
})
const form = useForm<z.infer<typeof FormSchema>>({
mode: 'onBlur',
reValidateMode: 'onBlur',
mode: 'onSubmit',
reValidateMode: 'onChange',
resolver: zodResolver(FormSchema),
defaultValues: { email: '', role: '', applyToOrg: true, projectRef: '' },
})
const { applyToOrg, projectRef } = form.watch()
const { applyToOrg, projectRef, email } = form.watch()
const emailCount = parseEmails(email ?? '').length
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 developerRole = orgScopedRoles.find((role) => role.name === 'Developer')
const existingMember = (members ?? []).find(
(member) => member.primary_email === values.email.toLowerCase()
)
if (existingMember !== undefined) {
if (existingMember.invited_id) {
return toast('User has already been invited to this organization')
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 {
return toast('User is already in this organization')
toInvite.push(emailAddress)
}
}
inviteMember(
{
slug,
email: values.email.toLowerCase(),
roleId: Number(values.role),
...(!values.applyToOrg && values.projectRef ? { projects: [values.projectRef] } : {}),
},
{
onSuccess: () => {
toast.success('Successfully sent invitation to new member')
setIsOpen(!isOpen)
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
}
form.reset({
email: '',
role: developerRole?.id.toString() ?? '',
applyToOrg: true,
projectRef: '',
})
},
}
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`
)
setIsOpen(false)
form.reset({
email: '',
role: developerRole?.id.toString() ?? '',
applyToOrg: true,
projectRef: '',
})
}
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 developerRole = orgScopedRoles.find((role) => role.name === 'Developer')
if (developerRole !== undefined) form.setValue('role', developerRole.id.toString())
if (developerRole !== undefined) {
form.reset({
...form.getValues(),
role: developerRole.id.toString(),
})
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSuccess, isOpen])
const hasUnsavedChanges = form.formState.isDirty
const handleOpenChange = (open: boolean) => {
if (!open && hasUnsavedChanges) {
setIsDiscardConfirmOpen(true)
} else {
setIsOpen(open)
}
}
const handleCancel = () => {
if (hasUnsavedChanges) {
setIsDiscardConfirmOpen(true)
} else {
form.reset({
email: '',
role: '',
applyToOrg: true,
projectRef: '',
})
setIsOpen(false)
}
}
const handleDiscardConfirm = () => {
form.reset({
email: '',
role: '',
applyToOrg: true,
projectRef: '',
})
setIsDiscardConfirmOpen(false)
setIsOpen(false)
}
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<ButtonTooltip
type="primary"
disabled={!canInviteMembers}
icon={<UserPlus size={14} />}
className="pointer-events-auto flex-grow md:flex-grow-0"
onClick={() => setIsOpen(true)}
tooltip={{
@@ -166,19 +291,43 @@ export const InviteMemberButton = () => {
text: !organizationMembersCreationEnabled
? 'Inviting members is currently disabled'
: !canInviteMembers
? 'You need additional permissions to invite a member to this organization'
? 'You need additional permissions to invite members to this organization'
: undefined,
},
}}
>
Invite member
Invite members
</ButtonTooltip>
</DialogTrigger>
<DialogContent size="medium">
<DialogHeader>
<DialogTitle>Invite a member to this organization</DialogTitle>
<DialogTitle>Invite team members</DialogTitle>
</DialogHeader>
<DialogSectionSeparator />
<Admonition
type="note"
showIcon={false}
title="Single Sign-On (SSO) available"
layout={isFreeOrProPlan ? '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={
<>
<Button asChild type="default">
<Link href={`${DOCS_URL}/guides/platform/sso`} target="_blank" rel="noreferrer">
Learn more
</Link>
</Button>
{isFreeOrProPlan ? (
<UpgradePlanButton
plan="Team"
source="inviteMemberSSO"
featureProposition="enable Single Sign-on (SSO)"
/>
) : null}
</>
}
/>
<Form_Shadcn_ {...form}>
<form
id="organization-invitation"
@@ -186,30 +335,11 @@ export const InviteMemberButton = () => {
onSubmit={form.handleSubmit(onInviteMember)}
>
<DialogSection className="flex flex-col gap-y-4 pb-2">
{hasAccessToProjectLevelPermissions && (
<FormField_Shadcn_
name="applyToOrg"
control={form.control}
render={({ field }) => (
<FormItemLayout
layout="flex"
label="Apply role to all projects in the organization"
>
<FormControl_Shadcn_>
<Switch
checked={field.value}
onCheckedChange={(value) => form.setValue('applyToOrg', value)}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
)}
<FormField_Shadcn_
name="role"
control={form.control}
render={({ field }) => (
<FormItemLayout label="Member role">
<FormItemLayout label="Role">
<FormControl_Shadcn_>
<Select_Shadcn_
value={field.value}
@@ -245,6 +375,22 @@ export const InviteMemberButton = () => {
</FormItemLayout>
)}
/>
{hasAccessToProjectLevelPermissions && (
<FormField_Shadcn_
name="applyToOrg"
control={form.control}
render={({ field }) => (
<FormItemLayout layout="flex" label="Grant this role on all projects">
<FormControl_Shadcn_>
<Switch
checked={field.value}
onCheckedChange={(value) => form.setValue('applyToOrg', value)}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
)}
{!applyToOrg && (
<FormField_Shadcn_
name="projectRef"
@@ -252,7 +398,7 @@ export const InviteMemberButton = () => {
render={({ field }) => (
<FormItemLayout
label="Select a project"
description="You can assign roles to multiple projects once the invite is accepted"
description="Project access can be adjusted after the user joins"
>
<FormControl_Shadcn_>
<OrganizationProjectSelector
@@ -275,62 +421,52 @@ export const InviteMemberButton = () => {
name="email"
control={form.control}
render={({ field }) => (
<FormItemLayout label="Email address">
<FormItemLayout label="Email addresses">
<FormControl_Shadcn_>
<Input_Shadcn_
<ExpandingTextArea
autoFocus
{...field}
autoComplete="off"
disabled={isInviting}
placeholder="Enter email address"
placeholder="name@example.com, name2@example.com, ..."
className="max-h-48"
data-1p-ignore
data-lpignore="true"
data-form-type="other"
data-bwignore
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<InformationBox
defaultVisibility={false}
title="Single Sign-on (SSO) login option available"
hideCollapse={false}
description={
<div className="space-y-4 mb-1">
<p>
Supabase offers single sign-on (SSO) as a login option to provide additional
account security for your team. This allows company administrators to enforce
the use of an identity provider when logging into Supabase.
</p>
<p>This is only available for organizations on Team Plan or above.</p>
<div className="flex items-center space-x-2">
<Button asChild type="default">
<Link
href={`${DOCS_URL}/guides/platform/sso`}
target="_blank"
rel="noreferrer"
>
Learn more
</Link>
</Button>
{(currentPlan?.id === 'free' || currentPlan?.id === 'pro') && (
<UpgradePlanButton
plan="Team"
source="inviteMemberSSO"
featureProposition="enable Single Sign-on (SSO)"
/>
)}
</div>
</div>
}
/>
</DialogSection>
<DialogSectionSeparator />
<DialogSection className="pt-0">
<Button block htmlType="submit" loading={isInviting}>
Send invitation
<DialogFooter className="!justify-between">
<Button type="default" onClick={handleCancel}>
Cancel
</Button>
</DialogSection>
<Button type="primary" htmlType="submit" loading={isInviting}>
{emailCount >= 2 ? 'Send invitations' : 'Send invitation'}
</Button>
</DialogFooter>
</form>
</Form_Shadcn_>
</DialogContent>
<AlertDialog open={isDiscardConfirmOpen} onOpenChange={setIsDiscardConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Discard changes?</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to discard your changes? Your invitation will not be sent.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep editing</AlertDialogCancel>
<AlertDialogAction variant="danger" onClick={handleDiscardConfirm}>
Discard changes
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Dialog>
)
}
@@ -147,7 +147,7 @@ export const MemberActions = ({ member }: MemberActionsProps) => {
deleteInvitation(
{ slug, id: invitedId },
{ onSuccess: () => toast.success('Successfully revoked the invitation.') }
{ onSuccess: () => toast.success('Successfully canceled the invitation') }
)
}
@@ -170,7 +170,7 @@ export const MemberActions = ({ member }: MemberActionsProps) => {
content: {
side: 'bottom',
text: isPendingInviteAcceptance
? 'Role can only be changed after the user has accepted the invite'
? 'Access can be managed after the invite is accepted'
: !canRemoveMember
? 'You need additional permissions to manage this team member'
: undefined,
@@ -1,4 +1,4 @@
import { ArrowRight, Check, User, X } from 'lucide-react'
import { ArrowRight, Check, User, X, ChevronRight } from 'lucide-react'
import Link from 'next/link'
import { useMemo } from 'react'
@@ -72,40 +72,44 @@ export const MemberRow = ({ member }: MemberRowProps) => {
</div>
}
/>
<div className="flex item-center gap-x-2">
<div className="flex item-center gap-x-3">
<p className="text-foreground-light truncate">{member.primary_email}</p>
{member.gotrue_id === profile?.gotrue_id && <Badge>You</Badge>}
<div className="flex items-center gap-x-2">
{member.gotrue_id === profile?.gotrue_id && <Badge>You</Badge>}
{isInvitedUser && member.invited_at && (
<Badge variant={isInviteExpired(member.invited_at) ? 'destructive' : 'warning'}>
{isInviteExpired(member.invited_at) ? 'Expired' : 'Invited'}
</Badge>
)}
{member.is_sso_user && <Badge variant="default">SSO</Badge>}
{(member.metadata as any)?.origin && (
<PartnerIcon
organization={{
managed_by:
MEMBER_ORIGIN_TO_MANAGED_BY[
(member.metadata as any).origin as keyof typeof MEMBER_ORIGIN_TO_MANAGED_BY
] ?? 'supabase',
}}
tooltipText="Managed by Vercel Marketplace."
/>
)}
</div>
</div>
{(member.metadata as any)?.origin && (
<PartnerIcon
organization={{
managed_by:
MEMBER_ORIGIN_TO_MANAGED_BY[
(member.metadata as any).origin as keyof typeof MEMBER_ORIGIN_TO_MANAGED_BY
] ?? 'supabase',
}}
tooltipText="This user is managed by Vercel Marketplace."
/>
)}
</div>
</TableCell>
<TableCell>
{isInvitedUser && member.invited_at && (
<Badge variant={isInviteExpired(member.invited_at) ? 'destructive' : 'warning'}>
{isInviteExpired(member.invited_at) ? 'Expired' : 'Invited'}
</Badge>
)}
{member.is_sso_user && <Badge variant="default">SSO</Badge>}
</TableCell>
<TableCell>
<div className="flex items-center justify-center">
<div className="flex items-center gap-x-1.5">
{member.mfa_enabled ? (
<Check className="text-brand" strokeWidth={2} size={20} />
<>
<span className="text-foreground-lighter">Enabled</span>
<Check className="text-brand" strokeWidth={2} size={16} />
</>
) : (
<X className="text-foreground-light" strokeWidth={1.5} size={20} />
<>
<span className="text-foreground-lighter">Disabled</span>
<X className="text-foreground-muted" strokeWidth={1.5} size={16} />
</>
)}
</div>
</TableCell>
@@ -130,18 +134,18 @@ export const MemberRow = ({ member }: MemberRowProps) => {
return (
<div key={`role-${id}`} className="flex items-center gap-x-2">
<p>{roleName}</p>
<p className="text-foreground-light">{roleName}</p>
{hasProjectScopedRoles && (
<>
<span>•</span>
<ChevronRight className="text-foreground-muted/50" size={14} />
{projectsApplied.length === 1 ? (
<span className="text-foreground truncate" title={projectsApplied[0]}>
<span className="text-foreground-light truncate" title={projectsApplied[0]}>
{projectsApplied[0]}
</span>
) : (
<HoverCard_Shadcn_ openDelay={200}>
<HoverCardTrigger_Shadcn_ asChild>
<span className="text-foreground">
<span className="text-foreground-light">
{role?.projects.length === 0
? 'Organization'
: `${projectsApplied.length} project${projectsApplied.length > 1 ? 's' : ''}`}
@@ -15,6 +15,7 @@ import {
Table,
TableBody,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
@@ -62,6 +63,7 @@ const MembersView = ({ searchString }: MembersViewProps) => {
member.username.includes(searchString) || member.primary_email?.includes(searchString)
)
}
return false
})
}, [members, searchString])
@@ -102,30 +104,9 @@ const MembersView = ({ searchString }: MembersViewProps) => {
<Table>
<TableHeader>
<TableRow>
<TableHead key="header-user">User</TableHead>
<TableHead key="header-status" className="w-24" />
<TableHead key="header-mfa" className="text-center w-32">
Enabled MFA
</TableHead>
<TableHead key="header-role" className="flex items-center space-x-1">
<span>Role</span>
<Tooltip>
<TooltipTrigger asChild>
<Button asChild type="text" className="px-1">
<a
target="_blank"
rel="noreferrer"
href={`${DOCS_URL}/guides/platform/access-control`}
>
<HelpCircle size={14} className="text-foreground-light" />
</a>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
How to configure access control?
</TooltipContent>
</Tooltip>
</TableHead>
<TableHead key="header-user">Member</TableHead>
<TableHead key="header-mfa">MFA</TableHead>
<TableHead key="header-role">Role</TableHead>
<TableHead key="header-action" />
</TableRow>
</TableHeader>
@@ -138,9 +119,9 @@ const MembersView = ({ searchString }: MembersViewProps) => {
<TableCell colSpan={12} className="!p-0">
<Admonition
type="note"
title="You are currently assigned with project scoped roles in this organization"
description="All other members within the organization will not be visible to you"
className="bg-alternative border-0 rounded-none"
title="You have limited visibility in this organization"
description="Your access is limited to specific projects, so you can’t see all members or settings."
className="border-0 rounded-none"
/>
</TableCell>
</TableRow>,
@@ -157,23 +138,24 @@ const MembersView = ({ searchString }: MembersViewProps) => {
<div className="flex items-center space-x-3 opacity-75">
<AlertCircle size={16} strokeWidth={2} />
<p className="text-foreground-light">
No users matched the search query "{searchString}"
No members matched the search query "{searchString}"
</p>
</div>
</TableCell>
</TableRow>,
]
: []),
<TableRow key="footer" className="bg-panel-secondary-light">
<TableCell colSpan={12}>
<p className="text-foreground-light">
{searchString ? `${filteredMembers.length} of ` : ''}
{members.length || '0'} {members.length == 1 ? 'user' : 'users'}
</p>
</TableCell>
</TableRow>,
]}
</TableBody>
<TableFooter className="font-normal">
<TableRow className="border-b-0 [&>td]:hover:bg-inherit">
<TableCell colSpan={4} className="text-foreground-muted">
{searchString
? `${filteredMembers.length} of ${members.length} ${members.length === 1 ? 'member' : 'members'}`
: `${members.length || 0} ${members.length === 1 ? 'member' : 'members'}`}
</TableCell>
</TableRow>
</TableFooter>
</Table>
</Loading>
</Card>
@@ -1,6 +1,6 @@
'use client'
import React, { forwardRef, useImperativeHandle, useRef } from 'react'
import React, { forwardRef, useImperativeHandle, useLayoutEffect, useRef } from 'react'
import { cn } from '../../lib/utils'
import { TextArea } from '../shadcn/ui/text-area'
@@ -22,16 +22,17 @@ const ExpandingTextArea = forwardRef<HTMLTextAreaElement, ExpandingTextAreaProps
const updateTextAreaHeight = (element: HTMLTextAreaElement | null) => {
if (!element) return
// Update the height
if (!value) {
element.style.height = 'auto'
element.style.minHeight = '36px'
} else {
element.style.height = 'auto'
element.style.height = element.scrollHeight + 'px'
}
// Match single-line input height (h-10 = 40px) so we don't shrink when typing; grow only when content wraps
const singleLineHeightPx = 40
element.style.height = 'auto'
const contentHeight = element.scrollHeight
element.style.height = Math.max(singleLineHeightPx, contentHeight) + 'px'
}
useLayoutEffect(() => {
updateTextAreaHeight(internalRef.current)
}, [value])
return (
<TextArea
ref={(element) => {