From dca4087bb4e447066720e697ea7cccddcb52ad21 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Thu, 12 Mar 2026 17:39:55 +1100 Subject: [PATCH] feat(studio): expose project members in settings (#43477) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 | | --- | | CleanShot 2026-03-06 at 17 00
00@2x | | CleanShot 2026-03-06 at 16 59
48@2x | | CleanShot 2026-03-06 at 16 59
34@2x | | CleanShot 2026-03-06 at 17 11
25@2x-83D06149-E4AE-4AC0-98D9-FBBE10A58C8C | ## 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. --- .../interfaces/Settings/General/General.tsx | 198 +++++++++--------- .../Settings/General/General.utils.test.ts | 198 ++++++++++++++++++ .../Settings/General/General.utils.ts | 109 ++++++++++ .../Settings/General/ProjectAccessSection.tsx | 196 +++++++++++++++++ .../SettingsMenu.utils.test.tsx | 8 + 5 files changed, 613 insertions(+), 96 deletions(-) create mode 100644 apps/studio/components/interfaces/Settings/General/General.utils.test.ts create mode 100644 apps/studio/components/interfaces/Settings/General/General.utils.ts create mode 100644 apps/studio/components/interfaces/Settings/General/ProjectAccessSection.tsx diff --git a/apps/studio/components/interfaces/Settings/General/General.tsx b/apps/studio/components/interfaces/Settings/General/General.tsx index 03c373f2a3b..6b03ea3d33c 100644 --- a/apps/studio/components/interfaces/Settings/General/General.tsx +++ b/apps/studio/components/interfaces/Settings/General/General.tsx @@ -32,6 +32,8 @@ import { import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import * as z from 'zod' +import { ProjectAccessSection } from './ProjectAccessSection' + export const General = () => { const { data: project } = useSelectedProjectQuery() const isBranch = Boolean(project?.parent_project_ref) @@ -72,104 +74,108 @@ export const General = () => { } return ( - - - - General settings - - - - {isBranch && ( - - - - You are currently on a preview branch of your project - - - Certain settings are not available while you're on a preview branch. To adjust your - project settings, you may return to your{' '} - - main branch - - . - - - )} + <> + + + + General settings + + + + {isBranch && ( + + + + You are currently on a preview branch of your project + + + Certain settings are not available while you're on a preview branch. To adjust your + project settings, you may return to your{' '} + + main branch + + . + + + )} - {project === undefined ? ( - - - - - - ) : ( - -
- - - ( - - - - - - )} - /> - - - - - - - - - - {form.formState.isDirty && ( - + )} + - )} - - - -
-
- )} -
-
+ + + + + )} +
+
+ + + ) } diff --git a/apps/studio/components/interfaces/Settings/General/General.utils.test.ts b/apps/studio/components/interfaces/Settings/General/General.utils.test.ts new file mode 100644 index 00000000000..56c59ceb857 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/General/General.utils.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from 'vitest' + +import { summarizeProjectAccess } from './General.utils' + +const roles = { + org_scoped_roles: [ + { + id: 1, + name: 'Owner', + description: null, + base_role_id: 1, + projects: [], + }, + ], + project_scoped_roles: [ + { + id: 2, + name: 'Developer', + description: null, + base_role_id: 3, + projects: [{ name: 'Project A', ref: 'ref-a' }], + }, + ], +} as any + +describe('summarizeProjectAccess', () => { + it('includes org-scoped members for every project', () => { + const summary = summarizeProjectAccess({ + organizationMembers: [ + { + gotrue_id: 'owner-id', + username: 'Owner User', + primary_email: 'owner@example.com', + role_ids: [1], + } as any, + ], + roles, + projectRef: 'ref-a', + hasLimitedVisibility: false, + }) + + expect(summary.projectMemberCount).toBe(1) + expect(summary.projectMembers[0].email).toBe('owner@example.com') + expect(summary.projectMembers[0].role).toBe('Owner') + expect(summary.hasOrganizationWideAccess).toBe(true) + }) + + it('filters project-scoped members to the selected project', () => { + const summary = summarizeProjectAccess({ + organizationMembers: [ + { + gotrue_id: 'dev-visible', + username: 'Dev Visible', + primary_email: 'visible@example.com', + role_ids: [2], + } as any, + { + gotrue_id: 'dev-hidden', + username: 'Dev Hidden', + primary_email: 'hidden@example.com', + role_ids: [3], + } as any, + ], + roles: { + ...roles, + project_scoped_roles: [ + { + ...roles.project_scoped_roles[0], + projects: [{ name: 'Project A', ref: 'ref-a' }], + }, + { + ...roles.project_scoped_roles[0], + id: 3, + projects: [{ name: 'Project B', ref: 'ref-b' }], + }, + ], + } as any, + projectRef: 'ref-a', + hasLimitedVisibility: false, + }) + + expect(summary.projectMemberCount).toBe(1) + expect(summary.projectMembers[0].email).toBe('visible@example.com') + }) + + it('excludes invited members', () => { + const summary = summarizeProjectAccess({ + organizationMembers: [ + { + gotrue_id: 'member-id', + username: 'Member', + primary_email: 'member@example.com', + role_ids: [1], + } as any, + { + gotrue_id: 'invite-id', + username: 'invite', + primary_email: 'invite@example.com', + role_ids: [1], + invited_id: 123, + } as any, + ], + roles, + projectRef: 'ref-a', + hasLimitedVisibility: false, + }) + + expect(summary.organizationMemberCount).toBe(1) + expect(summary.projectMemberCount).toBe(1) + }) + + it('does not show org comparison in limited-visibility mode', () => { + const summary = summarizeProjectAccess({ + organizationMembers: [ + { + gotrue_id: 'member-id', + username: 'Member', + primary_email: 'member@example.com', + role_ids: [1], + } as any, + ], + roles, + projectRef: 'ref-a', + hasLimitedVisibility: true, + }) + + expect(summary.shouldShowOrgComparison).toBe(false) + expect(summary.hasOrganizationWideAccess).toBe(false) + }) + + it('caps visible members and tracks hidden count', () => { + const summary = summarizeProjectAccess({ + organizationMembers: [ + { + gotrue_id: '1', + username: 'A', + primary_email: 'a@example.com', + role_ids: [1], + } as any, + { + gotrue_id: '2', + username: 'B', + primary_email: 'b@example.com', + role_ids: [1], + } as any, + ], + roles, + projectRef: 'ref-a', + hasLimitedVisibility: false, + maxVisibleMembers: 1, + }) + + expect(summary.projectMemberCount).toBe(2) + expect(summary.visibleMembers).toHaveLength(1) + expect(summary.hiddenMembersCount).toBe(1) + }) + + it('places current user at the top of project members', () => { + const summary = summarizeProjectAccess({ + organizationMembers: [ + { + gotrue_id: 'alpha-id', + username: 'Alpha', + primary_email: 'alpha@example.com', + role_ids: [1], + } as any, + { + gotrue_id: 'current-user-id', + username: 'Current User', + primary_email: 'zeta@example.com', + role_ids: [1], + } as any, + { + gotrue_id: 'beta-id', + username: 'Beta', + primary_email: 'beta@example.com', + role_ids: [1], + } as any, + ], + roles, + projectRef: 'ref-a', + hasLimitedVisibility: false, + currentUserId: 'current-user-id', + maxVisibleMembers: 2, + }) + + expect(summary.projectMembers.map((member) => member.id)).toEqual([ + 'current-user-id', + 'alpha-id', + 'beta-id', + ]) + expect(summary.visibleMembers.map((member) => member.id)).toEqual([ + 'current-user-id', + 'alpha-id', + ]) + expect(summary.hiddenMembersCount).toBe(1) + }) +}) diff --git a/apps/studio/components/interfaces/Settings/General/General.utils.ts b/apps/studio/components/interfaces/Settings/General/General.utils.ts new file mode 100644 index 00000000000..0bef1118758 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/General/General.utils.ts @@ -0,0 +1,109 @@ +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, + } +} diff --git a/apps/studio/components/interfaces/Settings/General/ProjectAccessSection.tsx b/apps/studio/components/interfaces/Settings/General/ProjectAccessSection.tsx new file mode 100644 index 00000000000..dd6c29330af --- /dev/null +++ b/apps/studio/components/interfaces/Settings/General/ProjectAccessSection.tsx @@ -0,0 +1,196 @@ +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 ( + + + + Project access + + + + {isErrorProjectAccess ? ( + + ) : ( + + {isLoadingProjectAccess ? ( + + + + ) : ( + <> + +
+
+

{projectAccessTitle}

+

{projectAccessDescription}

+
+ {!!organization?.slug && ( + + )} +
+
+ + {visibleMembers.length > 0 ? ( + + + + + Member + Role + + + + {visibleMembers.map((member) => ( + + +
+

{member.email}

+ {member.id === profile?.gotrue_id && ( + You + )} +
+
+ + {member.role ?? ''} + +
+ ))} + {hiddenMembersCount > 0 && ( + + +

+ +{hiddenMembersCount} more project member + {hiddenMembersCount === 1 ? '' : 's'} +

+
+
+ )} +
+
+
+ ) : ( + +

+ No visible project members in your current access scope. +

+
+ )} + + )} +
+ )} +
+
+ ) +} diff --git a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.test.tsx b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.test.tsx index 001e5ff6012..c5c4cfd3786 100644 --- a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.test.tsx +++ b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.test.tsx @@ -59,6 +59,14 @@ describe('useGenerateSettingsMenu', () => { expect(hasWebhooks).toBe(false) }) + it('does not include members link in project settings navigation', () => { + const { result } = renderHook(() => useGenerateSettingsMenu()) + const configurationGroup = result.current.find((group) => group.title === 'Configuration') + const hasMembers = configurationGroup?.items.some((item) => item.name === 'Members') + + expect(hasMembers).toBe(false) + }) + it('includes dashboard preferences when flag is enabled', () => { vi.mocked(useFlag).mockReturnValue(true)