From 23e7bbcdc6fb9cb496640d3e5f48e67292e6dbfc Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Tue, 6 Oct 2026 20:41:39 +0800 Subject: [PATCH] Joshenlim/fe 3359 fix user permission UI for orgs with thousands of projects (#51329) ## Context Adds virtualization to the organization team members page - browser performance was facing issues for organizations with a large amount of members (e.g 1000+), primarily due to some computation within `MemberActions.tsx`, so virtualization addresses this by controlling the number of member rows being rendered in the DOM at any one time. image ## Unrelated changes - Updated `TeamSettings` to use the `PageContainer` components for UI consistency - Updated user `ProfileImage` to render the first alphabet of the email, rather than a generic user icon image - Updated row heights of member rows to be more smaller - Updated MFA column to use tooltips with a clearer CTA for members that don't have MFA enabled image - Added a filter for MFA status image ## To test - Can override the content for the `members` network request with the following sample JSON, main thing is just to test that initial load + searching should not run into any significant browser performance issues. [members-response-1000.json](https://github.com/user-attachments/files/33100317/members-response-1000.json) - Can also test on production that this mock response does indeed cause browser performance issues as well --- .../Organization/TeamSettings/MemberRow.tsx | 107 ++++++----- .../TeamSettings/MembersList.constants.ts | 15 ++ .../Organization/TeamSettings/MembersView.tsx | 168 ++++++++++-------- .../TeamSettings/TeamSettings.tsx | 130 ++++++++++---- .../TeamSettings/TeamSettings.utils.test.ts | 47 +++++ .../TeamSettings/TeamSettings.utils.ts | 19 ++ 6 files changed, 334 insertions(+), 152 deletions(-) create mode 100644 apps/studio/components/interfaces/Organization/TeamSettings/MembersList.constants.ts create mode 100644 apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.utils.test.ts diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx index b2b5fd61338..8f1d7688cb9 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx @@ -1,4 +1,5 @@ -import { ArrowRight, Check, ChevronRight, User, X } from 'lucide-react' +import { useParams } from 'common' +import { ArrowRight, Check, ChevronRight, X } from 'lucide-react' import Link from 'next/link' import { memo, useMemo } from 'react' import { @@ -8,29 +9,34 @@ import { HoverCardContent, HoverCardTrigger, ScrollArea, - TableCell, - TableRow, + Tooltip, + TooltipContent, + TooltipTrigger, } from 'ui' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { isInviteExpired } from '../Organization.utils' import { MemberActions } from './MemberActions' +import { MEMBERS_GRID_CLASS } from './MembersList.constants' import { useTeamSettingsData } from './TeamSettingsDataContext' +import { type RowComponentBaseProps } from '@/components/ui/InfiniteList' +import { InlineLink } from '@/components/ui/InlineLink' import PartnerIcon from '@/components/ui/PartnerIcon' import { ProfileImage } from '@/components/ui/ProfileImage' import { OrganizationRole } from '@/data/organization-members/organization-roles-query' import { OrganizationMember } from '@/data/organizations/organization-members-query' import { useProfile } from '@/lib/profile' -interface MemberRowProps { - member: OrganizationMember -} - const MEMBER_ORIGIN_TO_MANAGED_BY = { vercel: 'vercel-marketplace', } as const -export const MemberRow = memo(function MemberRow({ member }: MemberRowProps) { +export const MemberRow = memo(function MemberRow({ + item: member, + index, + style, +}: RowComponentBaseProps) { + const { slug } = useParams() const { profile } = useProfile() const { roles, isLoadingRoles, orgProjects } = useTeamSettingsData() @@ -69,27 +75,37 @@ export const MemberRow = memo(function MemberRow({ member }: MemberRowProps) { }, [member.role_ids, roleById, orgProjects, projectNameByRef]) return ( - - -
+
+
+
- + {member.primary_email?.[0]?.toUpperCase()}
} /> -
-

{member.primary_email}

-
+
+

+ {member.primary_email} +

+
{member.gotrue_id === profile?.gotrue_id && You} {isInvitedUser && member.invited_at && ( @@ -111,34 +127,43 @@ export const MemberRow = memo(function MemberRow({ member }: MemberRowProps) {
- +
- -
- {member.mfa_enabled ? ( - <> - Enabled - - - ) : ( - <> - Disabled - - - )} +
+
+ + + {member.mfa_enabled ? ( + + ) : ( + + )} + + + {member.mfa_enabled ? ( + 'Member has MFA enabled' + ) : ( + + Member does not have MFA enabled. You can enforce MFA from your organization's{' '} + security settings, + restricting access until members enable it. + + )} + +
- +
- +
{isLoadingRoles ? ( ) : ( roleRows.map(({ id, roleName, appliesToAllProjects, projectsApplied }) => ( -
-

{roleName}

+
+

{roleName}

{hasProjectScopedRoles && ( <> - + {projectsApplied.length === 1 ? ( - + {appliesToAllProjects ? 'Organization' : `${projectsApplied.length} project${projectsApplied.length > 1 ? 's' : ''}`} @@ -185,11 +210,11 @@ export const MemberRow = memo(function MemberRow({ member }: MemberRowProps) {
)) )} - +
- +
- - +
+
) }) diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/MembersList.constants.ts b/apps/studio/components/interfaces/Organization/TeamSettings/MembersList.constants.ts new file mode 100644 index 00000000000..a17804cbca6 --- /dev/null +++ b/apps/studio/components/interfaces/Organization/TeamSettings/MembersList.constants.ts @@ -0,0 +1,15 @@ +// Shared by the header and every row so the columns line up without table layout +export const MEMBERS_GRID_CLASS = + 'grid grid-cols-[minmax(0,400px)_120px_minmax(0,1fr)_200px] items-center gap-x-4 px-4' + +// Row heights are fixed because the virtualized list doesn't measure rows +export const MEMBER_ROW_MIN_HEIGHT = 60 +export const MEMBER_ROLE_LINE_HEIGHT = 20 + +export const MFA_FILTER_OPTIONS = [ + { value: 'all', label: 'Everyone' }, + { value: 'enabled', label: 'MFA enabled' }, + { value: 'disabled', label: 'MFA disabled' }, +] as const + +export type MfaFilter = (typeof MFA_FILTER_OPTIONS)[number]['value'] diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx index a9b0c3edc60..6ad5c2f811b 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx @@ -2,24 +2,21 @@ import { useParams } from 'common' import { partition } from 'lodash' import { AlertCircle } from 'lucide-react' import { useCallback, useMemo, useState } from 'react' -import { - Card, - Loading, - Table, - TableBody, - TableCell, - TableFooter, - TableHead, - TableHeader, - TableRow, -} from 'ui' +import { Card, cn } from 'ui' import { Admonition } from 'ui-patterns/Admonition' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { MemberRow } from './MemberRow' +import { MEMBERS_GRID_CLASS, type MfaFilter } from './MembersList.constants' +import { getMemberRowHeight, matchesMfaFilter } from './TeamSettings.utils' import { TeamSettingsDataProvider } from './TeamSettingsDataContext' import { UpdateRolesPanel } from './UpdateRolesPanel/UpdateRolesPanel' import { AlertError } from '@/components/ui/AlertError' +import { + InfiniteListItems, + InfiniteListScrollWrapper, + InfiniteListSizer, +} from '@/components/ui/InfiniteList' import { useOrganizationRolesV2Query } from '@/data/organization-members/organization-roles-query' import { useOrganizationMembersQuery, @@ -31,11 +28,18 @@ import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { useProfile } from '@/lib/profile' +// All members are loaded up front, so there is never a loader row to render +const NoLoader = () => null + +const getMemberKey = (member: OrganizationMember) => + member.gotrue_id ?? `invite-${member.invited_id}` + export interface MembersViewProps { searchString: string + mfaFilter: MfaFilter } -export const MembersView = ({ searchString }: MembersViewProps) => { +export const MembersView = ({ searchString, mfaFilter }: MembersViewProps) => { const { slug } = useParams() const { profile } = useProfile() @@ -70,7 +74,7 @@ export const MembersView = ({ searchString }: MembersViewProps) => { ) const filteredMembers = useMemo(() => { - return !searchString + const searchedMembers = !searchString ? members : members.filter((member) => { if (member.invited_at) { @@ -83,7 +87,9 @@ export const MembersView = ({ searchString }: MembersViewProps) => { } return false }) - }, [members, searchString]) + + return searchedMembers.filter((member) => matchesMfaFilter(member, mfaFilter)) + }, [members, searchString, mfaFilter]) const handleManageAccess = useCallback((member: OrganizationMember) => { setMemberForRoleUpdate(member) @@ -112,6 +118,22 @@ export const MembersView = ({ searchString }: MembersViewProps) => { return { user: currentUser, sortedMembers: sorted } }, [filteredMembers, profile?.gotrue_id, isOrgScopedRole]) + const listedMembers = useMemo( + () => (user ? [user, ...sortedMembers] : sortedMembers), + [user, sortedMembers] + ) + const getItemKey = useCallback( + (index: number) => getMemberKey(listedMembers[index]), + [listedMembers] + ) + const getItemSize = useCallback( + (index: number) => getMemberRowHeight(listedMembers[index]), + [listedMembers] + ) + + const isFiltering = searchString.length > 0 || mfaFilter !== 'all' + const hasLimitedVisibility = isSuccessRoles && isSuccessMembers && !isOrgScopedRole + return ( { )} {isSuccessMembers && ( -
- - - - - - Member - MFA - Role - - - +
+ + {hasLimitedVisibility && ( + + )} - - {[ - ...(isSuccessRoles && isSuccessMembers && !isOrgScopedRole - ? [ - - - - - , - ] - : []), - ...(!!user ? [] : []), - ...sortedMembers.map((member) => ( - - )), - ...(searchString.length > 0 && filteredMembers.length === 0 - ? [ - - -
- -

- No members matched the search query "{searchString}" -

-
-
-
, - ] - : []), - ]} -
- - - - {searchString - ? `${filteredMembers.length} of ${members.length} ${members.length === 1 ? 'member' : 'members'}` - : `${members.length || 0} ${members.length === 1 ? 'member' : 'members'}`} - - - -
-
+
+ +
+
Member
+
MFA
+
Role
+
+ Actions +
+
+ + + + +
+
+ + {isFiltering && filteredMembers.length === 0 && ( +
+ +

+ {searchString.length > 0 + ? `No members matched the search query "${searchString}"` + : 'No members match the selected MFA filter'} +

+
+ )} + +
+ {isFiltering + ? `${filteredMembers.length} of ${members.length} ${members.length === 1 ? 'member' : 'members'}` + : `${members.length || 0} ${members.length === 1 ? 'member' : 'members'}`} +
)} diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx index 8cc37829c44..f655b8e4249 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx @@ -1,20 +1,29 @@ import { useDebounce } from '@uidotdev/usehooks' import { useParams } from 'common' import { Search } from 'lucide-react' +import { parseAsStringEnum, useQueryState } from 'nuqs' import { useState } from 'react' +import { cn, Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from 'ui' import { Admonition } from 'ui-patterns/Admonition' import { Input } from 'ui-patterns/DataInputs/Input' +import { PageContainer } from 'ui-patterns/PageContainer' +import { + PageHeader, + PageHeaderDescription, + PageHeaderMeta, + PageHeaderSummary, + PageHeaderTitle, +} from 'ui-patterns/PageHeader' +import { PageSection } from 'ui-patterns/PageSection' import { InviteMemberButton } from './InviteMemberButton' +import { MFA_FILTER_OPTIONS, type MfaFilter } from './MembersList.constants' import { MembersView } from './MembersView' import { ScaffoldActionsContainer, ScaffoldActionsGroup, - ScaffoldContainer, ScaffoldFilterAndContent, - ScaffoldSection, ScaffoldSectionContent, - ScaffoldTitle, } from '@/components/layouts/Scaffold' import { DocsButton } from '@/components/ui/DocsButton' import { useOrganizationRolesV2Query } from '@/data/organization-members/organization-roles-query' @@ -24,6 +33,17 @@ import { DOCS_URL } from '@/lib/constants' export const TeamSettings = () => { const { slug } = useParams() const [searchString, setSearchString] = useState('') + const [mfaFilter, setMfaFilter] = useQueryState( + 'mfa', + parseAsStringEnum(MFA_FILTER_OPTIONS.map((option) => option.value)).withDefault( + 'all' + ) + ) + + const mfaFilterLabel = + mfaFilter === 'all' + ? 'MFA status' + : MFA_FILTER_OPTIONS.find((option) => option.value === mfaFilter)?.label const debouncedSearch = useDebounce(searchString, 500) @@ -35,42 +55,76 @@ export const TeamSettings = () => { const threshold = 1000 return ( - - - Team - - - } - value={searchString} - onChange={(e) => setSearchString(e.target.value)} - name="email" - id="email" - placeholder="Filter members" - /> - - - - - + <> + + + + Team + Manage team members and invitations + + + - {hasProjectScopedRoles && totalCount > threshold && ( - - )} + + + + +
+ } + value={searchString} + onChange={(e) => setSearchString(e.target.value)} + name="email" + id="email" + placeholder="Filter members" + /> + +
+ + + + +
- - - -
-
-
+ {hasProjectScopedRoles && totalCount > threshold && ( + + )} + + + + + + + + ) } diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.utils.test.ts b/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.utils.test.ts new file mode 100644 index 00000000000..549068365cd --- /dev/null +++ b/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.utils.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' + +import { getMemberRowHeight, matchesMfaFilter } from './TeamSettings.utils' + +describe('getMemberRowHeight', () => { + it('uses the minimum height for members with one role', () => { + expect(getMemberRowHeight({ role_ids: [1] })).toBe(60) + }) + + it('uses the minimum height when the role list is empty or missing', () => { + expect(getMemberRowHeight({ role_ids: [] })).toBe(60) + expect(getMemberRowHeight({} as { role_ids: number[] })).toBe(60) + }) + + it('grows by one line per role once the roles no longer fit in the minimum height', () => { + expect(getMemberRowHeight({ role_ids: [1, 2, 3] })).toBe(60) + expect(getMemberRowHeight({ role_ids: [1, 2, 3, 4] })).toBe(80) + expect(getMemberRowHeight({ role_ids: [1, 2, 3, 4, 5] })).toBe(100) + }) +}) + +describe('matchesMfaFilter', () => { + const withMfa = { mfa_enabled: true } + const withoutMfa = { mfa_enabled: false } + const invite = { mfa_enabled: false, invited_id: 1 } + + it('keeps everyone, including pending invites, when the filter is "all"', () => { + expect(matchesMfaFilter(withMfa, 'all')).toBe(true) + expect(matchesMfaFilter(withoutMfa, 'all')).toBe(true) + expect(matchesMfaFilter(invite, 'all')).toBe(true) + }) + + it('keeps only members with MFA when the filter is "enabled"', () => { + expect(matchesMfaFilter(withMfa, 'enabled')).toBe(true) + expect(matchesMfaFilter(withoutMfa, 'enabled')).toBe(false) + }) + + it('keeps only members without MFA when the filter is "disabled"', () => { + expect(matchesMfaFilter(withoutMfa, 'disabled')).toBe(true) + expect(matchesMfaFilter(withMfa, 'disabled')).toBe(false) + }) + + it('leaves pending invites out of the "enabled" and "disabled" filters', () => { + expect(matchesMfaFilter(invite, 'enabled')).toBe(false) + expect(matchesMfaFilter(invite, 'disabled')).toBe(false) + }) +}) diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.utils.ts b/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.utils.ts index 343d5285316..621c39ac655 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.utils.ts +++ b/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.utils.ts @@ -1,5 +1,10 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' +import { + MEMBER_ROLE_LINE_HEIGHT, + MEMBER_ROW_MIN_HEIGHT, + type MfaFilter, +} from './MembersList.constants' import type { OrganizationMember } from '@/data/organizations/organization-members-query' import { doPermissionsCheck } from '@/hooks/misc/useCheckPermissions' import type { Permission, Role } from '@/types' @@ -48,3 +53,17 @@ export const hasMultipleOwners = (members: OrganizationMember[] = [], roles: Rol }) return membersWhoAreOwners.length > 1 } + +/** Each role renders on its own line, so members with several roles need taller rows */ +export const getMemberRowHeight = (member: Pick) => + Math.max(MEMBER_ROW_MIN_HEIGHT, (member.role_ids?.length ?? 0) * MEMBER_ROLE_LINE_HEIGHT) + +/** Pending invites have no MFA status, so they only show when the filter is off */ +export const matchesMfaFilter = ( + member: Pick, + filter: MfaFilter +) => { + if (filter === 'all') return true + if (member.invited_id) return false + return filter === 'enabled' ? member.mfa_enabled : !member.mfa_enabled +}