mirror of
https://github.com/supabase/supabase.git
synced 2026-10-12 12:55:06 +03:00
## 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 -->
198 lines
8.1 KiB
TypeScript
198 lines
8.1 KiB
TypeScript
import { ArrowRight, Check, User, X, ChevronRight } from 'lucide-react'
|
|
import Link from 'next/link'
|
|
import { useMemo } from 'react'
|
|
|
|
import { useParams } from 'common'
|
|
import PartnerIcon from 'components/ui/PartnerIcon'
|
|
import { ProfileImage } from 'components/ui/ProfileImage'
|
|
import { useOrganizationRolesV2Query } from 'data/organization-members/organization-roles-query'
|
|
import { OrganizationMember } from 'data/organizations/organization-members-query'
|
|
import { useOrgProjectsInfiniteQuery } from 'data/projects/org-projects-infinite-query'
|
|
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
|
import { useProfile } from 'lib/profile'
|
|
import {
|
|
Badge,
|
|
HoverCardContent_Shadcn_,
|
|
HoverCardTrigger_Shadcn_,
|
|
HoverCard_Shadcn_,
|
|
ScrollArea,
|
|
TableCell,
|
|
TableRow,
|
|
cn,
|
|
} from 'ui'
|
|
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
|
|
import { isInviteExpired } from '../Organization.utils'
|
|
import { MemberActions } from './MemberActions'
|
|
|
|
interface MemberRowProps {
|
|
member: OrganizationMember
|
|
}
|
|
|
|
const MEMBER_ORIGIN_TO_MANAGED_BY = {
|
|
vercel: 'vercel-marketplace',
|
|
} as const
|
|
|
|
export const MemberRow = ({ member }: MemberRowProps) => {
|
|
const { slug } = useParams()
|
|
const { profile } = useProfile()
|
|
const { data: selectedOrganization } = useSelectedOrganizationQuery()
|
|
|
|
const { data: roles, isPending: isLoadingRoles } = useOrganizationRolesV2Query({
|
|
slug: selectedOrganization?.slug,
|
|
})
|
|
const hasProjectScopedRoles = (roles?.project_scoped_roles ?? []).length > 0
|
|
|
|
const { data: projectsData } = useOrgProjectsInfiniteQuery({ slug })
|
|
const orgProjects =
|
|
useMemo(() => projectsData?.pages.flatMap((page) => page.projects), [projectsData?.pages]) || []
|
|
|
|
const isInvitedUser = Boolean(member.invited_id)
|
|
const isEmailUser = member.username === member.primary_email
|
|
const isFlyUser = Boolean(member.primary_email?.endsWith('customer.fly.io'))
|
|
|
|
// Use generic avatar for all team members instead of attempting to fetch from GitHub
|
|
const profileImageUrl = undefined
|
|
|
|
return (
|
|
<TableRow>
|
|
<TableCell>
|
|
<div className="flex items-center gap-x-4">
|
|
<ProfileImage
|
|
alt={member.primary_email ?? member.username ?? ''}
|
|
src={profileImageUrl}
|
|
className="border rounded-full w-[32px] h-[32px] md:w-[40px] md:h-[40px]"
|
|
placeholder={
|
|
<div
|
|
className={cn(
|
|
'w-[32px] h-[32px] md:w-[40px] md:h-[40px]',
|
|
'bg-surface-100 border border-overlay rounded-full text-foreground-lighter flex items-center justify-center'
|
|
)}
|
|
>
|
|
<User size={20} strokeWidth={1.5} />
|
|
</div>
|
|
}
|
|
/>
|
|
<div className="flex item-center gap-x-3">
|
|
<p className="text-foreground-light truncate">{member.primary_email}</p>
|
|
<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>
|
|
</div>
|
|
</TableCell>
|
|
|
|
<TableCell>
|
|
<div className="flex items-center gap-x-1.5">
|
|
{member.mfa_enabled ? (
|
|
<>
|
|
<span className="text-foreground-lighter">Enabled</span>
|
|
<Check className="text-brand" strokeWidth={2} size={16} />
|
|
</>
|
|
) : (
|
|
<>
|
|
<span className="text-foreground-lighter">Disabled</span>
|
|
<X className="text-foreground-muted" strokeWidth={1.5} size={16} />
|
|
</>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
|
|
<TableCell className="max-w-64">
|
|
{isLoadingRoles ? (
|
|
<ShimmeringLoader className="w-32" />
|
|
) : (
|
|
member.role_ids.map((id) => {
|
|
const orgScopedRole = (roles?.org_scoped_roles ?? []).find((role) => role.id === id)
|
|
const projectScopedRole = (roles?.project_scoped_roles ?? []).find(
|
|
(role) => role.id === id
|
|
)
|
|
const role = orgScopedRole || projectScopedRole
|
|
const roleName = (role?.name ?? '').split('_')[0]
|
|
const projectsApplied =
|
|
role?.projects.length === 0
|
|
? orgProjects?.map((p) => p.name) ?? []
|
|
: (role?.projects ?? [])
|
|
.map(({ ref }) => orgProjects?.find((p) => p.ref === ref)?.name ?? '')
|
|
.filter((x) => x.length > 0)
|
|
|
|
return (
|
|
<div key={`role-${id}`} className="flex items-center gap-x-2">
|
|
<p className="text-foreground-light">{roleName}</p>
|
|
{hasProjectScopedRoles && (
|
|
<>
|
|
<ChevronRight className="text-foreground-muted/50" size={14} />
|
|
{projectsApplied.length === 1 ? (
|
|
<span className="text-foreground-light truncate" title={projectsApplied[0]}>
|
|
{projectsApplied[0]}
|
|
</span>
|
|
) : (
|
|
<HoverCard_Shadcn_ openDelay={200}>
|
|
<HoverCardTrigger_Shadcn_ asChild>
|
|
<span className="text-foreground-light">
|
|
{role?.projects.length === 0
|
|
? 'Organization'
|
|
: `${projectsApplied.length} project${projectsApplied.length > 1 ? 's' : ''}`}
|
|
</span>
|
|
</HoverCardTrigger_Shadcn_>
|
|
<HoverCardContent_Shadcn_ className="p-0">
|
|
<p className="p-2 text-xs">
|
|
{roleName} role applies to {projectsApplied.length} project
|
|
{projectsApplied.length > 1 ? 's' : ''}
|
|
</p>
|
|
<div className="border-t flex flex-col py-1">
|
|
<ScrollArea
|
|
className={cn(projectsApplied.length > 5 ? 'h-[130px]' : '')}
|
|
>
|
|
{projectsApplied.map((name) => {
|
|
const ref = orgProjects?.find((p) => p.name === name)?.ref
|
|
return (
|
|
<Link
|
|
key={name}
|
|
href={`/project/${ref}`}
|
|
className="px-2 py-1 group hover:bg-surface-300 hover:text-foreground transition flex items-center justify-between"
|
|
>
|
|
<span className="text-xs truncate max-w-[60%]">{name}</span>
|
|
<span className="text-xs text-foreground flex items-center gap-x-1 opacity-0 group-hover:opacity-100 transition">
|
|
Go to project
|
|
<ArrowRight size={14} />
|
|
</span>
|
|
</Link>
|
|
)
|
|
})}
|
|
</ScrollArea>
|
|
</div>
|
|
</HoverCardContent_Shadcn_>
|
|
</HoverCard_Shadcn_>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
})
|
|
)}
|
|
</TableCell>
|
|
|
|
<TableCell>
|
|
<MemberActions member={member} />
|
|
</TableCell>
|
|
</TableRow>
|
|
)
|
|
}
|