Files
supabase/apps/studio/components/interfaces/Settings/General/ProjectAccessSection.tsx
T
Danny White dca4087bb4 feat(studio): expose project members in settings (#43477)
## 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.
2026-03-12 06:39:55 +00:00

197 lines
7.2 KiB
TypeScript
Raw 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 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>
)
}