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.
This commit is contained in:
Danny White authored and GitHub committed 2026-03-12 06:39:55 +00:00
1 parent ff0edaafdb
commit dca4087bb4
5 files changed
+613 -96

No files matched your search

@@ -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 (
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>General settings</PageSectionTitle>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
{isBranch && (
<Alert_Shadcn_ variant="default">
<WarningIcon />
<AlertTitle_Shadcn_>
You are currently on a preview branch of your project
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
Certain settings are not available while you're on a preview branch. To adjust your
project settings, you may return to your{' '}
<Link
href={`/project/${project?.parent_project_ref}/settings/general`}
className="text-brand"
>
main branch
</Link>
.
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)}
<>
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>General settings</PageSectionTitle>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
{isBranch && (
<Alert_Shadcn_ variant="default">
<WarningIcon />
<AlertTitle_Shadcn_>
You are currently on a preview branch of your project
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
Certain settings are not available while you're on a preview branch. To adjust your
project settings, you may return to your{' '}
<Link
href={`/project/${project?.parent_project_ref}/settings/general`}
className="text-brand"
>
main branch
</Link>
.
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)}
{project === undefined ? (
<Card>
<CardContent>
<GenericSkeletonLoader />
</CardContent>
</Card>
) : (
<Form_Shadcn_ {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<Card>
<CardContent>
<FormField_Shadcn_
control={form.control}
name="name"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Project name"
description="Displayed throughout the dashboard."
className="[&>div]:md:w-1/2"
>
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
disabled={isBranch || !canUpdateProject}
autoComplete="off"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</CardContent>
<CardContent>
<FormItemLayout
layout="flex-row-reverse"
label="Project ID"
description="Reference used in APIs and URLs."
className="[&>div]:md:w-1/2 [&>div>div]:md:w-full"
>
<FormControl_Shadcn_>
<Input copy readOnly size="small" value={project?.ref ?? ''} />
</FormControl_Shadcn_>
</FormItemLayout>
</CardContent>
<CardFooter className="justify-end space-x-2">
{form.formState.isDirty && (
<Button
type="default"
htmlType="button"
disabled={isUpdating}
onClick={() => form.reset({ name: project?.name ?? '' })}
{project === undefined ? (
<Card>
<CardContent>
<GenericSkeletonLoader />
</CardContent>
</Card>
) : (
<Form_Shadcn_ {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<Card>
<CardContent>
<FormField_Shadcn_
control={form.control}
name="name"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Project name"
description="Displayed throughout the dashboard."
className="[&>div]:md:w-1/2"
>
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
disabled={isBranch || !canUpdateProject}
autoComplete="off"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</CardContent>
<CardContent>
<FormItemLayout
layout="flex-row-reverse"
label="Project ID"
description="Reference used in APIs and URLs."
className="[&>div]:md:w-1/2 [&>div>div]:md:w-full"
>
Cancel
<FormControl_Shadcn_>
<Input copy readOnly size="small" value={project?.ref ?? ''} />
</FormControl_Shadcn_>
</FormItemLayout>
</CardContent>
<CardFooter className="justify-end space-x-2">
{form.formState.isDirty && (
<Button
type="default"
htmlType="button"
disabled={isUpdating}
onClick={() => form.reset({ name: project?.name ?? '' })}
>
Cancel
</Button>
)}
<Button
type="primary"
htmlType="submit"
disabled={
!form.formState.isDirty || isUpdating || !canUpdateProject || isBranch
}
loading={isUpdating}
>
Save changes
</Button>
)}
<Button
type="primary"
htmlType="submit"
disabled={
!form.formState.isDirty || isUpdating || !canUpdateProject || isBranch
}
loading={isUpdating}
>
Save changes
</Button>
</CardFooter>
</Card>
</form>
</Form_Shadcn_>
)}
</PageSectionContent>
</PageSection>
</CardFooter>
</Card>
</form>
</Form_Shadcn_>
)}
</PageSectionContent>
</PageSection>
<ProjectAccessSection />
</>
)
}
@@ -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)
})
})
@@ -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,
}
}
@@ -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 (
<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>
)
}
@@ -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)