mirror of
https://github.com/supabase/supabase.git
synced 2026-10-08 10:55:06 +03:00
## What kind of change does this PR introduce? Feature that resolves DEPR-321. ## What is the current behavior? From @dshukertjr: > Users of free and pro orgs frequently struggle to figure out how to add teammates to a project. ## What is the new behavior? Project Settings → General now has a Project access section showing who can access the project (email + role), with a link to team management (_Manage members_ or _View team_ for limited users). Large member lists are truncated with +N more. | Permutations | | --- | | <img width="1462" height="544" alt="CleanShot 2026-03-06 at 17 00 00@2x" src="https://github.com/user-attachments/assets/68e5519f-3851-4ab8-a364-9fcb222fbcb7" /> | | <img width="1486" height="676" alt="CleanShot 2026-03-06 at 16 59 48@2x" src="https://github.com/user-attachments/assets/e1b85bb5-6fbd-46ec-9b13-15501defd030" /> | | <img width="1464" height="566" alt="CleanShot 2026-03-06 at 16 59 34@2x" src="https://github.com/user-attachments/assets/e9fdc188-cf79-4af9-8b3c-313e98256109" /> | | <img width="1438" height="654" alt="CleanShot 2026-03-06 at 17 11 25@2x-83D06149-E4AE-4AC0-98D9-FBBE10A58C8C" src="https://github.com/user-attachments/assets/8a6e1aa2-76bb-486e-999a-1df4f88c3692" /> | ## Additional context Behaviour is based on user role visibility, not a special plan-only toggle. Team+ orgs are more likely to hit limited/project-scoped cases; free/pro are usually org-wide access.
197 lines
7.2 KiB
TypeScript
197 lines
7.2 KiB
TypeScript
import AlertError from 'components/ui/AlertError'
|
||
import { useOrganizationRolesV2Query } from 'data/organization-members/organization-roles-query'
|
||
import { useOrganizationMembersQuery } from 'data/organizations/organization-members-query'
|
||
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||
import { useProfile } from 'lib/profile'
|
||
import Link from 'next/link'
|
||
import {
|
||
Badge,
|
||
Button,
|
||
Card,
|
||
CardContent,
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from 'ui'
|
||
import {
|
||
PageSection,
|
||
PageSectionContent,
|
||
PageSectionDescription,
|
||
PageSectionMeta,
|
||
PageSectionSummary,
|
||
PageSectionTitle,
|
||
} from 'ui-patterns/PageSection'
|
||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||
|
||
import { summarizeProjectAccess } from './General.utils'
|
||
|
||
export const ProjectAccessSection = () => {
|
||
const { data: project } = useSelectedProjectQuery()
|
||
const { data: organization } = useSelectedOrganizationQuery()
|
||
const { profile } = useProfile()
|
||
|
||
const isBranch = Boolean(project?.parent_project_ref)
|
||
const projectRef = project?.parent_project_ref ?? project?.ref
|
||
|
||
const {
|
||
data: organizationMembers = [],
|
||
error: organizationMembersError,
|
||
isPending: isLoadingOrganizationMembers,
|
||
isError: isErrorOrganizationMembers,
|
||
} = useOrganizationMembersQuery(
|
||
{ slug: organization?.slug },
|
||
{
|
||
enabled: !!organization?.slug,
|
||
}
|
||
)
|
||
const {
|
||
data: organizationRoles,
|
||
error: organizationRolesError,
|
||
isPending: isLoadingOrganizationRoles,
|
||
isError: isErrorOrganizationRoles,
|
||
} = useOrganizationRolesV2Query(
|
||
{ slug: organization?.slug },
|
||
{
|
||
enabled: !!organization?.slug,
|
||
}
|
||
)
|
||
|
||
const userMemberData = organizationMembers.find(
|
||
(member) => member.gotrue_id === profile?.gotrue_id
|
||
)
|
||
const orgScopedRoleIds = new Set(
|
||
(organizationRoles?.org_scoped_roles ?? []).map((role) => role.id)
|
||
)
|
||
const hasProjectScopedRoles = (organizationRoles?.project_scoped_roles ?? []).length > 0
|
||
const isOrgScopedRole = (userMemberData?.role_ids ?? []).some((roleId) =>
|
||
orgScopedRoleIds.has(roleId)
|
||
)
|
||
const hasLimitedVisibility = hasProjectScopedRoles && !isOrgScopedRole
|
||
|
||
const {
|
||
visibleMembers,
|
||
hiddenMembersCount,
|
||
projectMemberCount,
|
||
organizationMemberCount,
|
||
shouldShowOrgComparison,
|
||
hasOrganizationWideAccess,
|
||
} = summarizeProjectAccess({
|
||
organizationMembers,
|
||
roles: organizationRoles,
|
||
projectRef,
|
||
hasLimitedVisibility,
|
||
currentUserId: profile?.gotrue_id,
|
||
})
|
||
|
||
const isLoadingProjectAccess = isLoadingOrganizationMembers || isLoadingOrganizationRoles
|
||
const isErrorProjectAccess = isErrorOrganizationMembers || isErrorOrganizationRoles
|
||
const projectAccessError = organizationMembersError ?? organizationRolesError
|
||
|
||
if (isBranch) return null
|
||
|
||
const projectAccessTitle = hasLimitedVisibility
|
||
? 'You have limited visibility in this organization'
|
||
: shouldShowOrgComparison && hasOrganizationWideAccess
|
||
? 'Organization-wide access'
|
||
: 'Restricted project access'
|
||
|
||
const projectAccessDescription = hasLimitedVisibility
|
||
? 'Your access is limited to specific projects, so you can’t see all members or settings.'
|
||
: shouldShowOrgComparison
|
||
? hasOrganizationWideAccess
|
||
? `All ${organizationMemberCount} organization members can access this project.`
|
||
: `${projectMemberCount} of ${organizationMemberCount} organization members can access this project.`
|
||
: `${projectMemberCount} project member${projectMemberCount === 1 ? '' : 's'} currently ${projectMemberCount === 1 ? 'has' : 'have'} access.`
|
||
|
||
return (
|
||
<PageSection>
|
||
<PageSectionMeta>
|
||
<PageSectionSummary>
|
||
<PageSectionTitle>Project access</PageSectionTitle>
|
||
</PageSectionSummary>
|
||
</PageSectionMeta>
|
||
<PageSectionContent>
|
||
{isErrorProjectAccess ? (
|
||
<AlertError error={projectAccessError} subject="Failed to retrieve project members" />
|
||
) : (
|
||
<Card>
|
||
{isLoadingProjectAccess ? (
|
||
<CardContent>
|
||
<GenericSkeletonLoader />
|
||
</CardContent>
|
||
) : (
|
||
<>
|
||
<CardContent className="flex flex-col gap-4">
|
||
<div className="flex flex-col @lg:flex-row @lg:items-center @lg:justify-between gap-3">
|
||
<div>
|
||
<p className="text-sm">{projectAccessTitle}</p>
|
||
<p className="text-sm text-foreground-light">{projectAccessDescription}</p>
|
||
</div>
|
||
{!!organization?.slug && (
|
||
<Button asChild type="default">
|
||
<Link href={`/org/${organization.slug}/team`}>
|
||
{hasLimitedVisibility ? 'View team' : 'Manage members'}
|
||
</Link>
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
|
||
{visibleMembers.length > 0 ? (
|
||
<CardContent className="p-0">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>Member</TableHead>
|
||
<TableHead className="w-[180px]">Role</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{visibleMembers.map((member) => (
|
||
<TableRow key={member.id}>
|
||
<TableCell className="align-top">
|
||
<div className="flex items-center gap-2">
|
||
<p className="text-sm text-foreground break-all">{member.email}</p>
|
||
{member.id === profile?.gotrue_id && (
|
||
<Badge variant="default">You</Badge>
|
||
)}
|
||
</div>
|
||
</TableCell>
|
||
<TableCell className="align-top text-sm text-foreground-light w-[180px]">
|
||
{member.role ?? ''}
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
{hiddenMembersCount > 0 && (
|
||
<TableRow className="[&>td]:hover:bg-inherit">
|
||
<TableCell colSpan={2}>
|
||
<p className="text-sm text-foreground-lighter">
|
||
+{hiddenMembersCount} more project member
|
||
{hiddenMembersCount === 1 ? '' : 's'}
|
||
</p>
|
||
</TableCell>
|
||
</TableRow>
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
</CardContent>
|
||
) : (
|
||
<CardContent className="pt-0">
|
||
<p className="text-sm text-foreground-lighter">
|
||
No visible project members in your current access scope.
|
||
</p>
|
||
</CardContent>
|
||
)}
|
||
</>
|
||
)}
|
||
</Card>
|
||
)}
|
||
</PageSectionContent>
|
||
</PageSection>
|
||
)
|
||
}
|