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.

<img width="1182" height="435" alt="image"
src="https://github.com/user-attachments/assets/e3da7036-c1c8-4d73-a363-85ecbdb79179"
/>


## 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
<img width="275" height="126" alt="image"
src="https://github.com/user-attachments/assets/17eae3b1-c527-4b8f-afcd-c5151bdaf869"
/>
- 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
<img width="332" height="144" alt="image"
src="https://github.com/user-attachments/assets/a479af31-7fec-454d-b64e-e6314fd6d55e"
/>
- Added a filter for MFA status  
<img width="375" height="177" alt="image"
src="https://github.com/user-attachments/assets/fd87105e-524d-4ded-b471-769592be96c7"
/>


## 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
This commit is contained in:
Joshen Lim authored and GitHub committed 2026-10-06 06:41:39 -06:00
1 parent 6ecf98671a
commit 23e7bbcdc6
6 files changed
+334 -152

No files matched your search

@@ -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<OrganizationMember>) {
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 (
<TableRow>
<TableCell>
<div className="flex items-center gap-x-4">
<div
role="row"
aria-rowindex={index + 2}
style={style}
className={cn(MEMBERS_GRID_CLASS, 'border-b hover:bg-surface-200 transition-colors')}
>
<div role="cell" className="min-w-0">
<div className="flex items-center gap-x-4 min-w-0">
<ProfileImage
alt={member.primary_email ?? member.username ?? ''}
src={profileImageUrl}
className="border rounded-full w-[32px] h-[32px] md:w-[40px] md:h-[40px]"
className="border rounded-full w-6 h-6 md:w-8 md:h-8 shrink-0"
placeholder={
<div
className={cn(
'w-[32px] h-[32px] md:w-[40px] md:h-[40px]',
'w-6 h-6 md:w-8 md:h-8 text-xs shrink-0',
'bg-surface-100 border border-overlay rounded-full text-foreground-lighter flex items-center justify-center'
)}
>
<User size={20} strokeWidth={1.5} />
{member.primary_email?.[0]?.toUpperCase()}
</div>
}
/>
<div className="flex items-center gap-x-3">
<p className="text-foreground-light truncate">{member.primary_email}</p>
<div className="flex items-center gap-x-2">
<div className="flex items-center gap-x-3 min-w-0">
<p
title={member.primary_email ?? ''}
className="text-foreground-light truncate text-sm"
>
{member.primary_email}
</p>
<div className="flex items-center gap-x-2 shrink-0 text-sm">
{member.gotrue_id === profile?.gotrue_id && <Badge>You</Badge>}
{isInvitedUser && member.invited_at && (
<Badge variant={isInviteExpired(member.invited_at) ? 'destructive' : 'warning'}>
@@ -111,34 +127,43 @@ export const MemberRow = memo(function MemberRow({ member }: MemberRowProps) {
</div>
</div>
</div>
</TableCell>
</div>
<TableCell>
<div className="flex items-center gap-x-1.5">
{member.mfa_enabled ? (
<>
<span className="text-foreground-lighter">Enabled</span>
<Check className="text-primary" strokeWidth={2} size={16} />
</>
) : (
<>
<span className="text-foreground-lighter">Disabled</span>
<X className="text-foreground-muted" strokeWidth={1.5} size={16} />
</>
)}
<div role="cell">
<div className="flex items-center ml-1">
<Tooltip>
<TooltipTrigger>
{member.mfa_enabled ? (
<Check className="text-primary" strokeWidth={2} size={16} />
) : (
<X className="text-foreground-muted" strokeWidth={1.5} size={16} />
)}
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-70 text-center">
{member.mfa_enabled ? (
'Member has MFA enabled'
) : (
<span className="text-balance">
Member does not have MFA enabled. You can enforce MFA from your organization's{' '}
<InlineLink href={`/org/${slug}/security`}>security settings</InlineLink>,
restricting access until members enable it.
</span>
)}
</TooltipContent>
</Tooltip>
</div>
</TableCell>
</div>
<TableCell className="max-w-64">
<div role="cell" className="min-w-0">
{isLoadingRoles ? (
<ShimmeringLoader className="w-32" />
) : (
roleRows.map(({ id, roleName, appliesToAllProjects, projectsApplied }) => (
<div key={`role-${id}`} className="flex items-center gap-x-2">
<p className="text-foreground-light">{roleName}</p>
<div key={`role-${id}`} className="flex items-center gap-x-2 min-w-0 text-sm">
<p className="text-foreground-light whitespace-nowrap">{roleName}</p>
{hasProjectScopedRoles && (
<>
<ChevronRight className="text-foreground-muted/50" size={14} />
<ChevronRight className="text-foreground-muted/50 shrink-0" size={14} />
{projectsApplied.length === 1 ? (
<span
className="text-foreground-light truncate"
@@ -149,7 +174,7 @@ export const MemberRow = memo(function MemberRow({ member }: MemberRowProps) {
) : (
<HoverCard openDelay={200}>
<HoverCardTrigger asChild>
<span className="text-foreground-light">
<span className="text-foreground-light truncate">
{appliesToAllProjects
? 'Organization'
: `${projectsApplied.length} project${projectsApplied.length > 1 ? 's' : ''}`}
@@ -185,11 +210,11 @@ export const MemberRow = memo(function MemberRow({ member }: MemberRowProps) {
</div>
))
)}
</TableCell>
</div>
<TableCell>
<div role="cell">
<MemberActions member={member} />
</TableCell>
</TableRow>
</div>
</div>
)
})
@@ -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']
@@ -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 (
<TeamSettingsDataProvider
members={members}
@@ -134,66 +156,66 @@ export const MembersView = ({ searchString }: MembersViewProps) => {
)}
{isSuccessMembers && (
<div className="rounded-sm w-full overflow-hidden overflow-x-scroll">
<Card>
<Loading active={!filteredMembers}>
<Table>
<TableHeader>
<TableRow>
<TableHead key="header-user">Member</TableHead>
<TableHead key="header-mfa">MFA</TableHead>
<TableHead key="header-role">Role</TableHead>
<TableHead key="header-action" />
</TableRow>
</TableHeader>
<div className="rounded-sm w-full overflow-hidden overflow-x-auto">
<Card className="min-w-[760px]">
{hasLimitedVisibility && (
<Admonition
type="note"
title="You have limited visibility in this organization"
description="Your access is limited to specific projects, so you can’t see all members or settings."
className="border-0 border-b rounded-none"
/>
)}
<TableBody>
{[
...(isSuccessRoles && isSuccessMembers && !isOrgScopedRole
? [
<TableRow key="project-scope-notice">
<TableCell colSpan={12} className="p-0!">
<Admonition
type="note"
title="You have limited visibility in this organization"
description="Your access is limited to specific projects, so you can’t see all members or settings."
className="border-0 rounded-none"
/>
</TableCell>
</TableRow>,
]
: []),
...(!!user ? [<MemberRow key={user.gotrue_id} member={user} />] : []),
...sortedMembers.map((member) => (
<MemberRow key={member.gotrue_id} member={member} />
)),
...(searchString.length > 0 && filteredMembers.length === 0
? [
<TableRow key="no-results" className="bg-panel-secondary-light">
<TableCell colSpan={12}>
<div className="flex items-center space-x-3 opacity-75">
<AlertCircle size={16} strokeWidth={2} />
<p className="text-foreground-light">
No members matched the search query "{searchString}"
</p>
</div>
</TableCell>
</TableRow>,
]
: []),
]}
</TableBody>
<TableFooter className="font-normal">
<TableRow className="border-b-0 [&>td]:hover:bg-inherit">
<TableCell colSpan={4} className="text-foreground-muted">
{searchString
? `${filteredMembers.length} of ${members.length} ${members.length === 1 ? 'member' : 'members'}`
: `${members.length || 0} ${members.length === 1 ? 'member' : 'members'}`}
</TableCell>
</TableRow>
</TableFooter>
</Table>
</Loading>
<div role="table" aria-label="Members" aria-rowcount={listedMembers.length + 1}>
<InfiniteListScrollWrapper
className="max-h-[70vh]"
items={listedMembers}
getItemKey={getItemKey}
getItemSize={getItemSize}
>
<div
role="row"
aria-rowindex={1}
className={cn(
MEMBERS_GRID_CLASS,
'sticky top-0 z-10 h-10 border-b bg-surface-100 heading-meta text-foreground-lighter'
)}
>
<div role="columnheader">Member</div>
<div role="columnheader">MFA</div>
<div role="columnheader">Role</div>
<div role="columnheader">
<span className="sr-only">Actions</span>
</div>
</div>
<InfiniteListSizer>
<InfiniteListItems
items={listedMembers}
ItemComponent={MemberRow}
LoaderComponent={NoLoader}
/>
</InfiniteListSizer>
</InfiniteListScrollWrapper>
</div>
{isFiltering && filteredMembers.length === 0 && (
<div className="flex items-center gap-x-3 p-4">
<AlertCircle size={16} strokeWidth={2} className="text-foreground-lighter" />
<p className="text-foreground-lighter text-sm">
{searchString.length > 0
? `No members matched the search query "${searchString}"`
: 'No members match the selected MFA filter'}
</p>
</div>
)}
<div className="px-4 py-3 text-sm text-foreground-muted border-t">
{isFiltering
? `${filteredMembers.length} of ${members.length} ${members.length === 1 ? 'member' : 'members'}`
: `${members.length || 0} ${members.length === 1 ? 'member' : 'members'}`}
</div>
</Card>
</div>
)}
@@ -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<MfaFilter>(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 (
<ScaffoldContainer>
<ScaffoldSection isFullWidth className="py-8! gap-y-8">
<ScaffoldTitle>Team</ScaffoldTitle>
<ScaffoldFilterAndContent>
<ScaffoldActionsContainer className="w-full flex-col md:flex-row gap-2 justify-between">
<Input
size="tiny"
autoComplete="off"
icon={<Search />}
value={searchString}
onChange={(e) => setSearchString(e.target.value)}
name="email"
id="email"
placeholder="Filter members"
/>
<ScaffoldActionsGroup className="w-full md:w-auto">
<DocsButton href={`${DOCS_URL}/guides/platform/access-control`} />
<InviteMemberButton />
</ScaffoldActionsGroup>
</ScaffoldActionsContainer>
<>
<PageHeader size="default">
<PageHeaderMeta>
<PageHeaderSummary>
<PageHeaderTitle>Team</PageHeaderTitle>
<PageHeaderDescription>Manage team members and invitations</PageHeaderDescription>
</PageHeaderSummary>
</PageHeaderMeta>
</PageHeader>
{hasProjectScopedRoles && totalCount > threshold && (
<Admonition
type="warning"
title="This page may not render properly due to the number of projects your account has access to"
description="We're actively looking into optimizing this page and will make things available as soon as we can!"
/>
)}
<PageContainer size="default">
<PageSection className="pt-4 last:pb-0">
<ScaffoldFilterAndContent>
<ScaffoldActionsContainer className="w-full flex-col md:flex-row gap-2 justify-between">
<div className="flex items-center gap-x-2">
<Input
size="tiny"
autoComplete="off"
icon={<Search />}
value={searchString}
onChange={(e) => setSearchString(e.target.value)}
name="email"
id="email"
placeholder="Filter members"
/>
<Select
value={mfaFilter}
onValueChange={(value) => setMfaFilter(value as MfaFilter)}
>
<SelectTrigger
size="tiny"
aria-label="Filter by MFA status"
className={cn('w-32 bg-transparent!', mfaFilter === 'all' && 'border-dashed')}
>
<SelectValue>{mfaFilterLabel}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{MFA_FILTER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value} className="text-xs">
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<ScaffoldActionsGroup className="w-full md:w-auto">
<DocsButton href={`${DOCS_URL}/guides/platform/access-control`} />
<InviteMemberButton />
</ScaffoldActionsGroup>
</ScaffoldActionsContainer>
<ScaffoldSectionContent className="w-full">
<MembersView
searchString={searchString.length === 0 ? searchString : debouncedSearch}
/>
</ScaffoldSectionContent>
</ScaffoldFilterAndContent>
</ScaffoldSection>
</ScaffoldContainer>
{hasProjectScopedRoles && totalCount > threshold && (
<Admonition
type="warning"
title="This page may not render properly due to the number of projects your account has access to"
description="We're actively looking into optimizing this page and will make things available as soon as we can!"
/>
)}
<ScaffoldSectionContent className="w-full">
<MembersView
searchString={searchString.length === 0 ? searchString : debouncedSearch}
mfaFilter={mfaFilter}
/>
</ScaffoldSectionContent>
</ScaffoldFilterAndContent>
</PageSection>
</PageContainer>
</>
)
}
@@ -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)
})
})
@@ -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<OrganizationMember, 'role_ids'>) =>
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<OrganizationMember, 'invited_id' | 'mfa_enabled'>,
filter: MfaFilter
) => {
if (filter === 'all') return true
if (member.invited_id) return false
return filter === 'enabled' ? member.mfa_enabled : !member.mfa_enabled
}