mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 01:45:10 +03:00
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:
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)
|
||||
|
||||
|
||||
Reference in new issue
Block a user