fix(studio): eliminate per-row query duplication on org team page (#50238)

## Summary

* Fixes extreme slowness (browser-crashing on filter) on
`/org/[slug]/team` for orgs with 200+ members.
* Root cause: `MemberRow`/`MemberActions` each independently subscribed
to org-wide React Query data (roles, projects, permissions, feature
flags) and rendered a hidden `UpdateRolesPanel` per row. Filtering
caused hundreds of duplicate query observers to mount/unmount on every
keystroke, each scheduling its own stale-timeout bookkeeping and
blocking the main thread for multiple seconds.
* Hoisted all org-wide data fetching (`useOrganizationRolesV2Query`,
`useOrgProjectsInfiniteQuery`, `usePermissionsQuery`,
`useSelectedOrganizationQuery`, `useIsFeatureEnabled`) to `MembersView`
and passed the results down as props.
* Replaced the per-row `useAsyncCheckPermissions` hook calls in
`MemberActions` with the underlying pure `doPermissionsCheck` function
memoized locally, removing their internal query subscriptions.
* Simplified `useGetRolesManagementPermissions` to stop calling a
query-fetching fallback hook that was unreachable given all current call
sites already pass `permissions`/`orgSlug` directly.
* Replaced 200 hidden per-row `UpdateRolesPanel` instances with a single
shared instance owned by `MembersView`, opened via an `onManageAccess`
callback.
* Cached the regex built by `doPermissionsCheck`'s `toRegexpString`
instead of rebuilding it on every permission check.

Diagnosed from two Chrome performance traces of the team page while
typing in the filter box (multi-second main-thread blocking tasks traced
to React Query `QueryObserver` mount/unmount storms).

## Test plan

- [X] `tsc --noEmit` clean (only one pre-existing, unrelated error in
`packages/ui-patterns`)
- [X] `eslint` clean on all changed files (only pre-existing warnings)
- [X] `vitest run tests/components/Organization/TeamSettings` — 51 tests
pass
- [X] Manually verify filtering is smooth on an org with 200+ members

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Team Settings provides centralized member access and role management.
  * Members can update roles through the access-management panel.

* **Improvements**
* Permission checks now more accurately handle organization and project
scopes, including wildcard patterns.
  * Member search is debounced for smoother filtering while typing.
* Access-management actions use current organization members, roles,
permissions, and feature settings.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
CharisandJoshen Lim authored and GitHub committed 2026-09-11 14:02:28 +08:00
1 parent 9b1dddde11
commit 82d7d347c4
7 files changed
+276 -76

No files matched your search

@@ -1,7 +1,7 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import { useIsLoggedIn, useParams } from 'common'
import { MoreVertical, Redo2, Trash } from 'lucide-react'
import { useState } from 'react'
import { useMemo, useState } from 'react'
import { toast } from 'sonner'
import {
Button,
@@ -14,38 +14,42 @@ import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { LeaveTeamButton } from './LeaveTeamButton'
import { useGetRolesManagementPermissions } from './TeamSettings.utils'
import { UpdateRolesPanel } from './UpdateRolesPanel/UpdateRolesPanel'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { DropdownMenuItemTooltip } from '@/components/ui/DropdownMenuItemTooltip'
import { useOrganizationCreateInvitationMutation } from '@/data/organization-members/organization-invitation-create-mutation'
import { useOrganizationDeleteInvitationMutation } from '@/data/organization-members/organization-invitation-delete-mutation'
import { useOrganizationRolesV2Query } from '@/data/organization-members/organization-roles-query'
import type { OrganizationRolesResponse } from '@/data/organization-members/organization-roles-query'
import { useOrganizationMemberDeleteMutation } from '@/data/organizations/organization-member-delete-mutation'
import {
useOrganizationMembersQuery,
type OrganizationMember,
} from '@/data/organizations/organization-members-query'
import { usePermissionsQuery } from '@/data/permissions/permissions-query'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import type { OrganizationMember } from '@/data/organizations/organization-members-query'
import type { OrganizationBase } from '@/data/organizations/organizations-query'
import { doPermissionsCheck } from '@/hooks/misc/useCheckPermissions'
import { IS_PLATFORM } from '@/lib/constants'
import { useProfile } from '@/lib/profile'
import type { Permission } from '@/types'
interface MemberActionsProps {
member: OrganizationMember
members: OrganizationMember[]
allRoles: OrganizationRolesResponse | undefined
permissions: Permission[] | undefined
selectedOrganization: OrganizationBase | undefined
organizationMembersDeletionEnabled: boolean
onManageAccess: (member: OrganizationMember) => void
}
export const MemberActions = ({ member }: MemberActionsProps) => {
export const MemberActions = ({
member,
members,
allRoles,
permissions,
selectedOrganization,
organizationMembersDeletionEnabled,
onManageAccess,
}: MemberActionsProps) => {
const { slug } = useParams()
const { profile } = useProfile()
const [showAccessModal, setShowAccessModal] = useState(false)
const isLoggedIn = useIsLoggedIn()
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
const organizationMembersDeletionEnabled = useIsFeatureEnabled('organization_members:delete')
const { data: selectedOrganization } = useSelectedOrganizationQuery()
const { data: permissions } = usePermissionsQuery()
const { data: allRoles } = useOrganizationRolesV2Query({ slug })
const { data: members } = useOrganizationMembersQuery({ slug })
const memberIsUser = member.gotrue_id == profile?.gotrue_id
const orgScopedRoles = allRoles?.org_scoped_roles ?? []
@@ -66,18 +70,32 @@ export const MemberActions = ({ member }: MemberActionsProps) => {
const roleId = member.role_ids?.[0] ?? -1
const canRemoveMember = member.role_ids.every((id) => rolesRemovable.includes(id))
const { can: canCreateUserInvites } = useAsyncCheckPermissions(
PermissionAction.CREATE,
'user_invites',
{ resource: { role_id: roleId } }
)
const canCreateUserInvites = useMemo(() => {
if (!IS_PLATFORM) return true
if (!isLoggedIn) return false
if (!permissions) return false
return doPermissionsCheck(
permissions,
PermissionAction.CREATE,
'user_invites',
{ resource: { role_id: roleId } },
selectedOrganization?.slug
)
}, [isLoggedIn, permissions, roleId, selectedOrganization?.slug])
const canResendInvite = canCreateUserInvites && hasOrgRole
const { can: canDeleteUserInvites } = useAsyncCheckPermissions(
PermissionAction.DELETE,
'user_invites',
{ resource: { role_id: roleId } }
)
const canDeleteUserInvites = useMemo(() => {
if (!IS_PLATFORM) return true
if (!isLoggedIn) return false
if (!permissions) return false
return doPermissionsCheck(
permissions,
PermissionAction.DELETE,
'user_invites',
{ resource: { role_id: roleId } },
selectedOrganization?.slug
)
}, [isLoggedIn, permissions, roleId, selectedOrganization?.slug])
const canRevokeInvite = canDeleteUserInvites && hasOrgRole
const { mutate: deleteOrganizationMember, isPending: isDeletingMember } =
@@ -165,7 +183,7 @@ export const MemberActions = ({ member }: MemberActionsProps) => {
<ButtonTooltip
variant="default"
disabled={isPendingInviteAcceptance || !canRemoveMember}
onClick={() => setShowAccessModal(true)}
onClick={() => onManageAccess(member)}
tooltip={{
content: {
side: 'bottom',
@@ -291,12 +309,6 @@ export const MemberActions = ({ member }: MemberActionsProps) => {
<span className="text-foreground">{selectedOrganization?.name}</span>?
</p>
</ConfirmationModal>
<UpdateRolesPanel
visible={showAccessModal}
member={member}
onClose={() => setShowAccessModal(false)}
/>
</>
)
}
@@ -1,7 +1,5 @@
import { useParams } from 'common'
import { ArrowRight, Check, ChevronRight, User, X } from 'lucide-react'
import Link from 'next/link'
import { useMemo } from 'react'
import {
Badge,
cn,
@@ -18,34 +16,44 @@ import { isInviteExpired } from '../Organization.utils'
import { MemberActions } from './MemberActions'
import PartnerIcon from '@/components/ui/PartnerIcon'
import { ProfileImage } from '@/components/ui/ProfileImage'
import { useOrganizationRolesV2Query } from '@/data/organization-members/organization-roles-query'
import { OrganizationRolesResponse } from '@/data/organization-members/organization-roles-query'
import { OrganizationMember } from '@/data/organizations/organization-members-query'
import { useOrgProjectsInfiniteQuery } from '@/data/projects/org-projects-infinite-query'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { OrganizationBase } from '@/data/organizations/organizations-query'
import { OrgProject } from '@/data/projects/org-projects-infinite-query'
import { useProfile } from '@/lib/profile'
import type { Permission } from '@/types'
interface MemberRowProps {
member: OrganizationMember
members: OrganizationMember[]
roles: OrganizationRolesResponse | undefined
isLoadingRoles: boolean
orgProjects: OrgProject[]
permissions: Permission[] | undefined
selectedOrganization: OrganizationBase | undefined
organizationMembersDeletionEnabled: boolean
onManageAccess: (member: OrganizationMember) => void
}
const MEMBER_ORIGIN_TO_MANAGED_BY = {
vercel: 'vercel-marketplace',
} as const
export const MemberRow = ({ member }: MemberRowProps) => {
const { slug } = useParams()
export const MemberRow = ({
member,
members,
roles,
isLoadingRoles,
orgProjects,
permissions,
selectedOrganization,
organizationMembersDeletionEnabled,
onManageAccess,
}: MemberRowProps) => {
const { profile } = useProfile()
const { data: selectedOrganization } = useSelectedOrganizationQuery()
const { data: roles, isPending: isLoadingRoles } = useOrganizationRolesV2Query({
slug: selectedOrganization?.slug,
})
const hasProjectScopedRoles = (roles?.project_scoped_roles ?? []).length > 0
const { data: projectsData } = useOrgProjectsInfiniteQuery({ slug })
const orgProjects =
useMemo(() => projectsData?.pages.flatMap((page) => page.projects), [projectsData?.pages]) || []
const isInvitedUser = Boolean(member.invited_id)
// Use generic avatar for all team members instead of attempting to fetch from GitHub
@@ -188,7 +196,15 @@ export const MemberRow = ({ member }: MemberRowProps) => {
</TableCell>
<TableCell>
<MemberActions member={member} />
<MemberActions
member={member}
members={members}
allRoles={roles}
permissions={permissions}
selectedOrganization={selectedOrganization}
organizationMembersDeletionEnabled={organizationMembersDeletionEnabled}
onManageAccess={onManageAccess}
/>
</TableCell>
</TableRow>
)
@@ -1,7 +1,7 @@
import { useParams } from 'common'
import { partition } from 'lodash'
import { AlertCircle } from 'lucide-react'
import { useMemo } from 'react'
import { useMemo, useState } from 'react'
import {
Card,
Loading,
@@ -17,9 +17,17 @@ import { Admonition } from 'ui-patterns/Admonition'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { MemberRow } from './MemberRow'
import { UpdateRolesPanel } from './UpdateRolesPanel/UpdateRolesPanel'
import { AlertError } from '@/components/ui/AlertError'
import { useOrganizationRolesV2Query } from '@/data/organization-members/organization-roles-query'
import { useOrganizationMembersQuery } from '@/data/organizations/organization-members-query'
import {
useOrganizationMembersQuery,
type OrganizationMember,
} from '@/data/organizations/organization-members-query'
import { usePermissionsQuery } from '@/data/permissions/permissions-query'
import { useOrgProjectsInfiniteQuery } from '@/data/projects/org-projects-infinite-query'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { useProfile } from '@/lib/profile'
export interface MembersViewProps {
@@ -40,12 +48,23 @@ const MembersView = ({ searchString }: MembersViewProps) => {
const {
data: roles,
error: rolesError,
isPending: isLoadingRoles,
isSuccess: isSuccessRoles,
isError: isErrorRoles,
} = useOrganizationRolesV2Query({
slug,
})
const { data: selectedOrganization } = useSelectedOrganizationQuery()
const { data: permissions } = usePermissionsQuery()
const organizationMembersDeletionEnabled = useIsFeatureEnabled('organization_members:delete')
const { data: projectsData } = useOrgProjectsInfiniteQuery({ slug })
const orgProjects = useMemo(
() => projectsData?.pages.flatMap((page) => page.projects) ?? [],
[projectsData?.pages]
)
const filteredMembers = useMemo(() => {
return !searchString
? members
@@ -67,6 +86,13 @@ const MembersView = ({ searchString }: MembersViewProps) => {
(m) => m.gotrue_id === profile?.gotrue_id
)
const [memberForRoleUpdate, setMemberForRoleUpdate] = useState<OrganizationMember>()
const [showRoleUpdatePanel, setShowRoleUpdatePanel] = useState(false)
const handleManageAccess = (member: OrganizationMember) => {
setMemberForRoleUpdate(member)
setShowRoleUpdatePanel(true)
}
const userMember = members.find((m) => m.gotrue_id === profile?.gotrue_id)
const orgScopedRoleIds = (roles?.org_scoped_roles ?? []).map((r) => r.id)
const isOrgScopedRole = orgScopedRoleIds.includes(userMember?.role_ids?.[0] ?? -1)
@@ -122,9 +148,35 @@ const MembersView = ({ searchString }: MembersViewProps) => {
</TableRow>,
]
: []),
...(!!user ? [<MemberRow key={user.gotrue_id} member={user} />] : []),
...(!!user
? [
<MemberRow
key={user.gotrue_id}
member={user}
members={members}
roles={roles}
isLoadingRoles={isLoadingRoles}
orgProjects={orgProjects}
permissions={permissions}
selectedOrganization={selectedOrganization}
organizationMembersDeletionEnabled={organizationMembersDeletionEnabled}
onManageAccess={handleManageAccess}
/>,
]
: []),
...sortedMembers.map((member) => (
<MemberRow key={member.gotrue_id} member={member} />
<MemberRow
key={member.gotrue_id}
member={member}
members={members}
roles={roles}
isLoadingRoles={isLoadingRoles}
orgProjects={orgProjects}
permissions={permissions}
selectedOrganization={selectedOrganization}
organizationMembersDeletionEnabled={organizationMembersDeletionEnabled}
onManageAccess={handleManageAccess}
/>
)),
...(searchString.length > 0 && filteredMembers.length === 0
? [
@@ -156,6 +208,14 @@ const MembersView = ({ searchString }: MembersViewProps) => {
</Card>
</div>
)}
{memberForRoleUpdate && (
<UpdateRolesPanel
visible={showRoleUpdatePanel}
member={memberForRoleUpdate}
onClose={() => setShowRoleUpdatePanel(false)}
/>
)}
</>
)
}
@@ -1,3 +1,4 @@
import { useDebounce } from '@uidotdev/usehooks'
import { useParams } from 'common'
import { Search } from 'lucide-react'
import { useState } from 'react'
@@ -24,6 +25,8 @@ export const TeamSettings = () => {
const { slug } = useParams()
const [searchString, setSearchString] = useState('')
const debouncedSearch = useDebounce(searchString, 500)
const { data: roles } = useOrganizationRolesV2Query({ slug })
const hasProjectScopedRoles = (roles?.project_scoped_roles ?? []).length > 0
@@ -42,7 +45,7 @@ export const TeamSettings = () => {
autoComplete="off"
icon={<Search />}
value={searchString}
onChange={(e: any) => setSearchString(e.target.value)}
onChange={(e) => setSearchString(e.target.value)}
name="email"
id="email"
placeholder="Filter members"
@@ -62,7 +65,9 @@ export const TeamSettings = () => {
)}
<ScaffoldSectionContent className="w-full">
<MembersView searchString={searchString} />
<MembersView
searchString={searchString.length === 0 ? searchString : debouncedSearch}
/>
</ScaffoldSectionContent>
</ScaffoldFilterAndContent>
</ScaffoldSection>
@@ -1,7 +1,7 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import type { OrganizationMember } from '@/data/organizations/organization-members-query'
import { doPermissionsCheck, useGetPermissions } from '@/hooks/misc/useCheckPermissions'
import { doPermissionsCheck } from '@/hooks/misc/useCheckPermissions'
import type { Permission, Role } from '@/types'
export const useGetRolesManagementPermissions = (
@@ -9,36 +9,30 @@ export const useGetRolesManagementPermissions = (
roles?: Role[],
permissions?: Permission[]
): { rolesAddable: Number[]; rolesRemovable: Number[] } => {
const { permissions: allPermissions, organizationSlug } = useGetPermissions(
permissions,
orgSlug,
permissions !== undefined && orgSlug !== undefined
)
const rolesAddable: Number[] = []
const rolesRemovable: Number[] = []
if (!roles || !orgSlug) return { rolesAddable, rolesRemovable }
roles.forEach((role: Role) => {
const canAdd = doPermissionsCheck(
allPermissions,
permissions,
PermissionAction.CREATE,
'auth.subject_roles',
{
resource: { role_id: role.id },
},
organizationSlug
orgSlug
)
if (canAdd) rolesAddable.push(role.id)
const canRemove = doPermissionsCheck(
allPermissions,
permissions,
PermissionAction.DELETE,
'auth.subject_roles',
{
resource: { role_id: role.id },
},
organizationSlug
orgSlug
)
if (canRemove) rolesRemovable.push(role.id)
})
@@ -0,0 +1,99 @@
import { describe, expect, it } from 'vitest'
import { doPermissionsCheck } from './useCheckPermissions'
import type { Permission } from '@/types'
function permission(overrides: Partial<Permission>): Permission {
return {
actions: ['read'] as any,
condition: null as unknown as Permission['condition'],
organization_slug: 'org-slug',
resources: ['tables'],
restrictive: false,
project_refs: null,
...overrides,
}
}
describe('doPermissionsCheck', () => {
it('returns false when permissions are missing', () => {
expect(doPermissionsCheck(undefined, 'read', 'tables', undefined, 'org-slug')).toBe(false)
})
it('matches a literal action and resource', () => {
const permissions = [permission({ actions: ['read'] as any, resources: ['tables'] })]
expect(doPermissionsCheck(permissions, 'read', 'tables', undefined, 'org-slug')).toBe(true)
expect(doPermissionsCheck(permissions, 'read', 'columns', undefined, 'org-slug')).toBe(false)
})
it('treats every "." in a resource as literal, not "any character"', () => {
// Regression for the incomplete-escaping bug: only the first "." used to get escaped,
// so a resource with two dots would let any single character stand in for the second one.
const permissions = [permission({ resources: ['queue_job.projects.update_jwt'] })]
expect(
doPermissionsCheck(
permissions,
'read',
'queue_job.projects.update_jwt',
undefined,
'org-slug'
)
).toBe(true)
expect(
doPermissionsCheck(
permissions,
'read',
'queue_jobXprojectsXupdate_jwt',
undefined,
'org-slug'
)
).toBe(false)
})
it('expands every "%" wildcard in a resource, not just the first one', () => {
const permissions = [permission({ resources: ['queue_job.%.%'] })]
expect(
doPermissionsCheck(permissions, 'read', 'queue_job.restore.prepare', undefined, 'org-slug')
).toBe(true)
expect(
doPermissionsCheck(
permissions,
'read',
'queue_job.walg.prepare_restore',
undefined,
'org-slug'
)
).toBe(true)
})
it('treats a literal backslash in a resource as a literal character, not a regex escape', () => {
const permissions = [permission({ resources: ['a\\d'] })]
// If the backslash weren't escaped, "\d" would be interpreted as the regex digit class
// and incorrectly match "a1".
expect(doPermissionsCheck(permissions, 'read', 'a1', undefined, 'org-slug')).toBe(false)
expect(doPermissionsCheck(permissions, 'read', 'a\\d', undefined, 'org-slug')).toBe(true)
})
it('denies when a restrictive permission matches, even if a non-restrictive one also matches', () => {
const permissions = [
permission({ restrictive: false, resources: ['tables'] }),
permission({ restrictive: true, resources: ['tables'] }),
]
expect(doPermissionsCheck(permissions, 'read', 'tables', undefined, 'org-slug')).toBe(false)
})
it('only matches permissions for the given organization', () => {
const permissions = [permission({ organization_slug: 'other-org' })]
expect(doPermissionsCheck(permissions, 'read', 'tables', undefined, 'org-slug')).toBe(false)
})
it('prefers a project-scoped permission over an org-level one when a projectRef is given', () => {
const permissions = [
permission({ resources: ['tables'], project_refs: [] }),
permission({ resources: ['tables'], project_refs: ['project-ref'], restrictive: true }),
]
expect(
doPermissionsCheck(permissions, 'read', 'tables', undefined, 'org-slug', 'project-ref')
).toBe(false)
})
})
+20 -6
View File
@@ -8,8 +8,18 @@ import { usePermissionsQuery } from '@/data/permissions/permissions-query'
import { IS_PLATFORM } from '@/lib/constants'
import type { Permission } from '@/types'
const toRegexpString = (actionOrResource: string) =>
`^${actionOrResource.replace('.', '\\.').replace('%', '.*')}$`
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const regexpCache = new Map<string, RegExp>()
const getActionResourceRegexp = (actionOrResource: string) => {
let regexp = regexpCache.get(actionOrResource)
if (!regexp) {
const pattern = actionOrResource.split('%').map(escapeRegExp).join('.*')
regexp = new RegExp(`^${pattern}$`)
regexpCache.set(actionOrResource, regexp)
}
return regexp
}
function doPermissionConditionCheck(permissions: Permission[], data?: object) {
const isRestricted = permissions
@@ -44,8 +54,10 @@ export function doPermissionsCheck(
const projectPermissions = permissions.filter(
(permission) =>
permission.organization_slug === organizationSlug &&
permission.actions.some((act) => (action ? action.match(toRegexpString(act)) : null)) &&
permission.resources.some((res) => resource.match(toRegexpString(res))) &&
permission.actions.some((act) =>
action ? getActionResourceRegexp(act).test(action) : null
) &&
permission.resources.some((res) => getActionResourceRegexp(res).test(resource)) &&
permission.project_refs?.includes(projectRef)
)
if (projectPermissions.length > 0) {
@@ -59,8 +71,10 @@ export function doPermissionsCheck(
.filter(
(permission) =>
permission.organization_slug === organizationSlug &&
permission.actions.some((act) => (action ? action.match(toRegexpString(act)) : null)) &&
permission.resources.some((res) => resource.match(toRegexpString(res)))
permission.actions.some((act) =>
action ? getActionResourceRegexp(act).test(action) : null
) &&
permission.resources.some((res) => getActionResourceRegexp(res).test(resource))
)
return doPermissionConditionCheck(orgPermissions, { resource_name: resource, ...data })
}