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