Files
supabase/apps/studio/components/interfaces/Settings/General/General.utils.ts
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

110 lines
3.7 KiB
TypeScript

import type {
OrganizationRole,
OrganizationRolesResponse,
} from 'data/organization-members/organization-roles-query'
import type { OrganizationMember } from 'data/organizations/organization-members-query'
export interface ProjectAccessMember {
id: string
displayName?: string
email: string
role?: string
}
interface SummarizeProjectAccessParams {
organizationMembers: OrganizationMember[]
roles: OrganizationRolesResponse | undefined
projectRef?: string
hasLimitedVisibility: boolean
currentUserId?: string
maxVisibleMembers?: number
}
export interface ProjectAccessSummary {
projectMembers: ProjectAccessMember[]
visibleMembers: ProjectAccessMember[]
hiddenMembersCount: number
projectMemberCount: number
organizationMemberCount: number
shouldShowOrgComparison: boolean
hasOrganizationWideAccess: boolean
}
const getRoleDisplayName = (roleName: string | undefined) => {
if (!roleName) return undefined
return roleName.split('_')[0]
}
const roleAppliesToProject = (role: OrganizationRole | undefined, projectRef?: string) => {
if (!role) return false
if (role.projects.length === 0) return true
if (!projectRef) return false
return role.projects.some((project) => project.ref === projectRef)
}
export const summarizeProjectAccess = ({
organizationMembers,
roles,
projectRef,
hasLimitedVisibility,
currentUserId,
maxVisibleMembers = 12,
}: SummarizeProjectAccessParams): ProjectAccessSummary => {
const allRoles = [...(roles?.org_scoped_roles ?? []), ...(roles?.project_scoped_roles ?? [])]
const rolesById = new Map(allRoles.map((role) => [role.id, role]))
const normalizedMembers = organizationMembers.filter(
(member) => !member.invited_id && typeof member.gotrue_id === 'string' && !!member.primary_email
)
const membersWithProjectAccess = normalizedMembers
.filter((member) =>
member.role_ids.some((roleId) => roleAppliesToProject(rolesById.get(roleId), projectRef))
)
.sort((a, b) => {
const isCurrentUserA = !!currentUserId && a.gotrue_id === currentUserId
const isCurrentUserB = !!currentUserId && b.gotrue_id === currentUserId
if (isCurrentUserA && !isCurrentUserB) return -1
if (!isCurrentUserA && isCurrentUserB) return 1
return (a.primary_email ?? '').localeCompare(b.primary_email ?? '')
})
const projectMembers = membersWithProjectAccess.map((member) => {
const matchingRoleNames = member.role_ids
.map((roleId) => rolesById.get(roleId))
.filter((role) => roleAppliesToProject(role, projectRef))
.map((role) => getRoleDisplayName(role?.name))
.filter((name): name is string => typeof name === 'string' && name.length > 0)
const uniqueRoleNames = [...new Set(matchingRoleNames)]
const hasDisplayName =
typeof member.username === 'string' &&
typeof member.primary_email === 'string' &&
member.username !== member.primary_email
return {
id: member.gotrue_id as string,
displayName: hasDisplayName ? member.username : undefined,
email: member.primary_email as string,
role: uniqueRoleNames.length > 0 ? uniqueRoleNames.join(', ') : undefined,
}
})
const projectMemberCount = projectMembers.length
const organizationMemberCount = normalizedMembers.length
const shouldShowOrgComparison = !hasLimitedVisibility && organizationMemberCount > 0
const hasOrganizationWideAccess =
shouldShowOrgComparison && projectMemberCount === organizationMemberCount
return {
projectMembers,
visibleMembers: projectMembers.slice(0, maxVisibleMembers),
hiddenMembersCount: Math.max(projectMemberCount - maxVisibleMembers, 0),
projectMemberCount,
organizationMemberCount,
shouldShowOrgComparison,
hasOrganizationWideAccess,
}
}