mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 01:45:10 +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.
110 lines
3.7 KiB
TypeScript
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,
|
|
}
|
|
}
|