Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ interface MemberRowProps {
*/
export function MemberRow({ name, email, image, status, roleControl, menu }: MemberRowProps) {
return (
<div className={ROW_CLASSES}>
<div role='group' aria-label={email} className={ROW_CLASSES}>
<MemberAvatar name={name} image={image} />
<span className={ROW_EMAIL_CLASSES}>{email}</span>
<span className={ROW_STATUS_CLASSES}>{status}</span>
Expand All @@ -68,6 +68,8 @@ export function MemberRow({ name, email, image, status, roleControl, menu }: Mem
interface MemberSectionProps {
/** Section label, e.g. "Teammates (3)" or a workspace name with a count. */
label: string
/** Stable accessible name when `label` includes a mutable count. */
ariaLabel?: string
/** Renders the empty state instead of the row group. */
isEmpty?: boolean
/** Copy shown when {@link isEmpty} is true. */
Expand All @@ -82,12 +84,13 @@ interface MemberSectionProps {
*/
export function MemberSection({
label,
ariaLabel,
isEmpty = false,
emptyText = 'No members yet',
children,
}: MemberSectionProps) {
return (
<SettingsSection label={label}>
<SettingsSection label={label} ariaLabel={ariaLabel}>
{isEmpty ? (
<SettingsEmptyState variant='inline'>{emptyText}</SettingsEmptyState>
) : (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import type { ReactNode } from 'react'

interface SettingsSectionProps {
label: string
/** Stable accessible name when the visible label contains mutable metadata such as a count. */
ariaLabel?: string
/** Optional node rendered immediately to the right of the label (e.g. an info tooltip). */
headerAccessory?: ReactNode
/** Optional control pinned to the far right of the header row (e.g. a Select All chip). */
Expand All @@ -15,12 +17,13 @@ interface SettingsSectionProps {
*/
export function SettingsSection({
label,
ariaLabel,
headerAccessory,
action,
children,
}: SettingsSectionProps) {
return (
<section aria-label={label} className='flex flex-col'>
<section aria-label={ariaLabel ?? label} className='flex flex-col'>
<div className='flex items-center gap-1.5 pl-0.5'>
<span className='text-[var(--text-muted)] text-small'>{label}</span>
{headerAccessory}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,7 @@ export function OrganizationMemberLists({
{showMembersSection && (
<MemberSection
label={`Members (${orgRowCount})`}
ariaLabel='Members'
isEmpty={!isLoadingRoster && filteredOrgMembers.length + filteredOrgPending.length === 0}
emptyText={isActiveSearch ? `No members matching “${query}”` : 'No members yet'}
>
Expand All @@ -449,6 +450,7 @@ export function OrganizationMemberLists({
<MemberSection
key={`workspace-${workspace.id}`}
label={`${workspace.name} (${totalCount})`}
ariaLabel={workspace.name}
isEmpty={visibleMembers.length + visibleInvites.length === 0}
emptyText={
isActiveSearch ? `No members matching “${query}”` : 'No members in this workspace'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useSession } from '@/lib/auth/auth-client'
import { getSubscriptionAccessState } from '@/lib/billing/client/utils'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { generateSlug, isAdminOrOwner, type Member } from '@/lib/workspaces/organization'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
import {
NoOrganizationView,
Expand Down Expand Up @@ -38,23 +39,52 @@ export function TeamManagement({
organizationId,
billingHref = `/organization/${organizationId}/settings/billing`,
}: TeamManagementProps) {
const { data: session, isPending: isSessionPending } = useSession()
const { isInvitationsDisabled } = usePermissionConfig()
const { data: session, isPending: isSessionPending, error: sessionError } = useSession()
const {
isInvitationsDisabled,
isPermissionLoading: isPermissionConfigLoading,
isPermissionError: isPermissionConfigError,
} = usePermissionConfig()

const { data: userSubscriptionData } = useSubscriptionData()
const subscriptionAccess = getSubscriptionAccessState(userSubscriptionData?.data)
const hasTeamPlan = subscriptionAccess.hasUsableTeamAccess
const hasEnterprisePlan = subscriptionAccess.hasUsableEnterpriseAccess

const { data: organization, isLoading, error: orgError } = useOrganization(organizationId)
const {
data: organization,
isLoading,
isError: isOrganizationError,
error: orgError,
} = useOrganization(organizationId)
const adminOrOwner = isAdminOrOwner(organization, session?.user?.email)

const { data: organizationBillingData, isLoading: isOrgBillingLoading } = useOrganizationBilling(
organizationId,
{ enabled: adminOrOwner }
)

const { data: roster, isLoading: isLoadingRoster } = useOrganizationRoster(organizationId)
const {
data: organizationBillingData,
isLoading: isOrgBillingLoading,
isError: isOrganizationBillingError,
} = useOrganizationBilling(organizationId, { enabled: adminOrOwner })

const {
data: roster,
isLoading: isLoadingRoster,
isError: isRosterError,
} = useOrganizationRoster(organizationId)
const hasLoadError =
Boolean(sessionError) ||
isOrganizationError ||
(adminOrOwner && isOrganizationBillingError) ||
isRosterError ||
isPermissionConfigError
Comment thread
cursor[bot] marked this conversation as resolved.
const isMembersLoading =
!hasLoadError &&
(isSessionPending ||
isLoading ||
(adminOrOwner && isOrgBillingLoading) ||
isLoadingRoster ||
isPermissionConfigLoading)
const dataState = hasLoadError ? 'error' : isMembersLoading ? 'loading' : 'ready'
const canManage = adminOrOwner && dataState === 'ready'

const removeMemberMutation = useRemoveMember()
const transferOwnershipMutation = useTransferOwnership()
Expand Down Expand Up @@ -131,6 +161,13 @@ export function TeamManagement({
}
}, [hasTeamPlan, hasEnterprisePlan, session?.user?.name, orgName])

useEffect(() => {
if (canManage) return
setInviteModalOpen(false)
setTransferDialogOpen(false)
setRemoveMemberDialog((current) => (current.open ? { ...current, open: false } : current))
}, [canManage])

const handleOrgNameChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const newName = e.target.value
setOrgName(newName)
Expand All @@ -156,7 +193,7 @@ export function TeamManagement({

const handleRemoveMember = useCallback(
async (member: Member) => {
if (!session?.user) return
if (!canManage || !session?.user) return

if (!member.user?.id) {
logger.error('Member object missing user ID', { member })
Expand All @@ -176,12 +213,12 @@ export function TeamManagement({
isExternalRemoval: member.role === 'external',
})
},
[session?.user]
[canManage, session?.user]
)

const confirmRemoveMember = useCallback(async () => {
const { memberId, isSelfRemoval } = removeMemberDialog
if (!session?.user || !memberId) return
if (!canManage || !session?.user || !memberId) return

try {
await removeMemberMutation.mutateAsync({
Expand All @@ -205,30 +242,34 @@ export function TeamManagement({
}, [
removeMemberDialog.memberId,
removeMemberDialog.isSelfRemoval,
canManage,
session?.user?.id,
organizationId,
removeMemberMutation,
])

const handleTransferDialogOpenChange = useCallback(
(next: boolean) => {
if (next && !canManage) return
setTransferDialogOpen(next)
if (!next) {
transferOwnershipMutation.reset()
setTransferPortalError(null)
}
},
[transferOwnershipMutation]
[canManage, transferOwnershipMutation]
)

const handleOpenTransferDialog = useCallback(() => {
if (!canManage) return
transferOwnershipMutation.reset()
setTransferPortalError(null)
setTransferDialogOpen(true)
}, [transferOwnershipMutation])
}, [canManage, transferOwnershipMutation])

const handleConfirmTransfer = useCallback(
async (newOwnerUserId: string) => {
if (!canManage) return
try {
const result = await transferOwnershipMutation.mutateAsync({
orgId: organizationId,
Expand All @@ -245,10 +286,11 @@ export function TeamManagement({
logger.error('Failed to transfer ownership', error)
}
},
[organizationId, transferOwnershipMutation]
[canManage, organizationId, transferOwnershipMutation]
)

const handleOpenTransferBillingPortal = useCallback(() => {
if (!canManage) return
setTransferPortalError(null)
const portalWindow = window.open('', '_blank')
openBillingPortal.mutate(
Expand Down Expand Up @@ -276,14 +318,22 @@ export function TeamManagement({
},
}
)
}, [organizationId, openBillingPortal])
}, [canManage, organizationId, openBillingPortal])

const queryError = orgError
const errorMessage = queryError instanceof Error ? queryError.message : null
const displayOrganization = organization

if (isLoading && !displayOrganization) {
return null
if (isMembersLoading && !displayOrganization) {
return <section aria-label='Organization members' aria-busy data-members-state='loading' />
}

if (hasLoadError && !displayOrganization) {
return (
<section aria-label='Organization members' aria-busy={false} data-members-state='error'>
<SettingsEmptyState>Unable to load organization members</SettingsEmptyState>
</section>
)
}

if (!displayOrganization) {
Expand All @@ -308,12 +358,13 @@ export function TeamManagement({
<>
<section
aria-label='Organization members'
aria-busy={isSessionPending || isLoading || isLoadingRoster}
aria-busy={isMembersLoading}
data-members-state={dataState}
className='flex flex-col gap-7'
>
<SettingsPanel
actions={
adminOrOwner
canManage
? [
{
text: 'Invite',
Expand All @@ -327,7 +378,7 @@ export function TeamManagement({
: []
}
>
{adminOrOwner && (
{canManage && (
<TeamSeatsOverview
billingHref={billingHref}
subscriptionData={orgSubscription}
Expand All @@ -338,19 +389,23 @@ export function TeamManagement({
/>
)}

<OrganizationMemberLists
canManage={adminOrOwner}
organizationId={displayOrganization.id}
roster={roster ?? null}
isLoadingRoster={isLoadingRoster}
currentUserId={session?.user?.id ?? ''}
onRemoveMember={handleRemoveMember}
onTransferOwnership={handleOpenTransferDialog}
/>
{hasLoadError ? (
<SettingsEmptyState>Unable to load organization members</SettingsEmptyState>
) : isMembersLoading ? null : (
<OrganizationMemberLists
canManage={canManage}
organizationId={displayOrganization.id}
roster={roster ?? null}
isLoadingRoster={false}
currentUserId={session?.user?.id ?? ''}
onRemoveMember={handleRemoveMember}
onTransferOwnership={handleOpenTransferDialog}
/>
)}
</SettingsPanel>
</section>

{adminOrOwner && (
{canManage && (
<OrganizationInviteModal
open={inviteModalOpen}
onOpenChange={setInviteModalOpen}
Expand All @@ -362,7 +417,7 @@ export function TeamManagement({
)}

<TransferOwnershipDialog
open={transferDialogOpen}
open={canManage && transferDialogOpen}
onOpenChange={handleTransferDialogOpenChange}
members={roster?.members ?? []}
isLoadingMembers={isLoadingRoster}
Expand All @@ -377,7 +432,7 @@ export function TeamManagement({
/>

<RemoveMemberDialog
open={removeMemberDialog.open}
open={canManage && removeMemberDialog.open}
memberName={removeMemberDialog.memberName}
isSelfRemoval={removeMemberDialog.isSelfRemoval}
isExternalRemoval={removeMemberDialog.isExternalRemoval}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { resolveTeammatesDataState, type TeammatesQueryState } from './teammates-state'

const readyState = {
permissionsError: false,
invitationsError: false,
workspacesError: false,
permissionConfigError: false,
permissionsLoading: false,
permissionsPlaceholder: false,
invitationsLoading: false,
invitationsPlaceholder: false,
workspacesLoading: false,
workspacesPlaceholder: false,
permissionConfigLoading: false,
} satisfies TeammatesQueryState

describe('resolveTeammatesDataState', () => {
it('reports loading while any required boundary is unresolved', () => {
expect(resolveTeammatesDataState({ ...readyState, invitationsPlaceholder: true })).toBe(
'loading'
)
})

it('gives errors priority over loading and placeholder data', () => {
expect(
resolveTeammatesDataState({
...readyState,
permissionConfigError: true,
permissionsLoading: true,
workspacesPlaceholder: true,
})
).toBe('error')
})

it('reports ready only after every required boundary resolves', () => {
expect(resolveTeammatesDataState(readyState)).toBe('ready')
})
})
Loading
Loading