Files
Danny White be7cb44e01 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 -->
2026-02-26 16:27:53 +11:00

169 lines
6.0 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { AlertCircle, HelpCircle } from 'lucide-react'
import { useParams } from 'common'
import AlertError from 'components/ui/AlertError'
import { useOrganizationRolesV2Query } from 'data/organization-members/organization-roles-query'
import { useOrganizationMembersQuery } from 'data/organizations/organization-members-query'
import { DOCS_URL } from 'lib/constants'
import { useProfile } from 'lib/profile'
import { partition } from 'lodash'
import { useMemo } from 'react'
import {
Button,
Card,
Loading,
Table,
TableBody,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { Admonition } from 'ui-patterns'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { MemberRow } from './MemberRow'
export interface MembersViewProps {
searchString: string
}
const MembersView = ({ searchString }: MembersViewProps) => {
const { slug } = useParams()
const { profile } = useProfile()
const {
data: members = [],
error: membersError,
isPending: isLoadingMembers,
isError: isErrorMembers,
isSuccess: isSuccessMembers,
} = useOrganizationMembersQuery({ slug })
const {
data: roles,
error: rolesError,
isSuccess: isSuccessRoles,
isError: isErrorRoles,
} = useOrganizationRolesV2Query({
slug,
})
const filteredMembers = useMemo(() => {
return !searchString
? members
: members.filter((member) => {
if (member.invited_at) {
return member.primary_email?.includes(searchString)
}
if (member.gotrue_id) {
return (
member.username.includes(searchString) || member.primary_email?.includes(searchString)
)
}
return false
})
}, [members, searchString])
const [[user], _otherMembers] = partition(
filteredMembers,
(m) => m.gotrue_id === profile?.gotrue_id
)
const userMember = members.find((m) => m.gotrue_id === profile?.gotrue_id)
const orgScopedRoleIds = (roles?.org_scoped_roles ?? []).map((r) => r.id)
const isOrgScopedRole = orgScopedRoleIds.includes(userMember?.role_ids?.[0] ?? -1)
// [Joshen] Temp wait on API level changes but I think it makes sense to hide invites for
// project scoped users since they can't see other members to begin with. Not a security issue nonetheless
const otherMembers = isOrgScopedRole
? _otherMembers
: _otherMembers.filter((x) => !('invited_id' in x))
const sortedMembers = otherMembers.sort((a, b) =>
(a.primary_email ?? '').localeCompare(b.primary_email ?? '')
)
return (
<>
{isLoadingMembers && <GenericSkeletonLoader />}
{isErrorMembers && (
<AlertError error={membersError} subject="Failed to retrieve organization members" />
)}
{isErrorRoles && (
<AlertError error={rolesError} subject="Failed to retrieve organization roles" />
)}
{isSuccessMembers && (
<div className="rounded w-full overflow-hidden overflow-x-scroll">
<Card>
<Loading active={!filteredMembers}>
<Table>
<TableHeader>
<TableRow>
<TableHead key="header-user">Member</TableHead>
<TableHead key="header-mfa">MFA</TableHead>
<TableHead key="header-role">Role</TableHead>
<TableHead key="header-action" />
</TableRow>
</TableHeader>
<TableBody>
{[
...(isSuccessRoles && isSuccessMembers && !isOrgScopedRole
? [
<TableRow key="project-scope-notice">
<TableCell colSpan={12} className="!p-0">
<Admonition
type="note"
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>,
]
: []),
...(!!user ? [<MemberRow key={user.gotrue_id} member={user} />] : []),
...sortedMembers.map((member) => (
<MemberRow key={member.gotrue_id} member={member} />
)),
...(searchString.length > 0 && filteredMembers.length === 0
? [
<TableRow key="no-results" className="bg-panel-secondary-light">
<TableCell colSpan={12}>
<div className="flex items-center space-x-3 opacity-75">
<AlertCircle size={16} strokeWidth={2} />
<p className="text-foreground-light">
No members matched the search query "{searchString}"
</p>
</div>
</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>
</div>
)}
</>
)
}
export default MembersView