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

199 lines
5.1 KiB
TypeScript

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)
})
})