Skip to content

Commit 1afa5d3

Browse files
BillLeoutsakosvl346Bill Leoutsakoscursoragent
authored
test(e2e): add enterprise settings workflows (#5849)
* test(e2e): add enterprise workflow coverage Co-authored-by: Cursor <cursoragent@cursor.com> * fix(e2e): harden workflow readiness invariants Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 02e3f46 commit 1afa5d3

23 files changed

Lines changed: 1884 additions & 216 deletions

File tree

apps/sim/app/workspace/[workspaceId]/settings/components/member-list/member-list.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ interface MemberRowProps {
5555
*/
5656
export function MemberRow({ name, email, image, status, roleControl, menu }: MemberRowProps) {
5757
return (
58-
<div className={ROW_CLASSES}>
58+
<div role='group' aria-label={email} className={ROW_CLASSES}>
5959
<MemberAvatar name={name} image={image} />
6060
<span className={ROW_EMAIL_CLASSES}>{email}</span>
6161
<span className={ROW_STATUS_CLASSES}>{status}</span>
@@ -68,6 +68,8 @@ export function MemberRow({ name, email, image, status, roleControl, menu }: Mem
6868
interface MemberSectionProps {
6969
/** Section label, e.g. "Teammates (3)" or a workspace name with a count. */
7070
label: string
71+
/** Stable accessible name when `label` includes a mutable count. */
72+
ariaLabel?: string
7173
/** Renders the empty state instead of the row group. */
7274
isEmpty?: boolean
7375
/** Copy shown when {@link isEmpty} is true. */
@@ -82,12 +84,13 @@ interface MemberSectionProps {
8284
*/
8385
export function MemberSection({
8486
label,
87+
ariaLabel,
8588
isEmpty = false,
8689
emptyText = 'No members yet',
8790
children,
8891
}: MemberSectionProps) {
8992
return (
90-
<SettingsSection label={label}>
93+
<SettingsSection label={label} ariaLabel={ariaLabel}>
9194
{isEmpty ? (
9295
<SettingsEmptyState variant='inline'>{emptyText}</SettingsEmptyState>
9396
) : (

apps/sim/app/workspace/[workspaceId]/settings/components/settings-section/settings-section.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import type { ReactNode } from 'react'
22

33
interface SettingsSectionProps {
44
label: string
5+
/** Stable accessible name when the visible label contains mutable metadata such as a count. */
6+
ariaLabel?: string
57
/** Optional node rendered immediately to the right of the label (e.g. an info tooltip). */
68
headerAccessory?: ReactNode
79
/** Optional control pinned to the far right of the header row (e.g. a Select All chip). */
@@ -15,12 +17,13 @@ interface SettingsSectionProps {
1517
*/
1618
export function SettingsSection({
1719
label,
20+
ariaLabel,
1821
headerAccessory,
1922
action,
2023
children,
2124
}: SettingsSectionProps) {
2225
return (
23-
<section aria-label={label} className='flex flex-col'>
26+
<section aria-label={ariaLabel ?? label} className='flex flex-col'>
2427
<div className='flex items-center gap-1.5 pl-0.5'>
2528
<span className='text-[var(--text-muted)] text-small'>{label}</span>
2629
{headerAccessory}

apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,7 @@ export function OrganizationMemberLists({
425425
{showMembersSection && (
426426
<MemberSection
427427
label={`Members (${orgRowCount})`}
428+
ariaLabel='Members'
428429
isEmpty={!isLoadingRoster && filteredOrgMembers.length + filteredOrgPending.length === 0}
429430
emptyText={isActiveSearch ? `No members matching “${query}”` : 'No members yet'}
430431
>
@@ -449,6 +450,7 @@ export function OrganizationMemberLists({
449450
<MemberSection
450451
key={`workspace-${workspace.id}`}
451452
label={`${workspace.name} (${totalCount})`}
453+
ariaLabel={workspace.name}
452454
isEmpty={visibleMembers.length + visibleInvites.length === 0}
453455
emptyText={
454456
isActiveSearch ? `No members matching “${query}”` : 'No members in this workspace'

apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx

Lines changed: 88 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { useSession } from '@/lib/auth/auth-client'
77
import { getSubscriptionAccessState } from '@/lib/billing/client/utils'
88
import { getBaseUrl } from '@/lib/core/utils/urls'
99
import { generateSlug, isAdminOrOwner, type Member } from '@/lib/workspaces/organization'
10+
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
1011
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
1112
import {
1213
NoOrganizationView,
@@ -38,23 +39,52 @@ export function TeamManagement({
3839
organizationId,
3940
billingHref = `/organization/${organizationId}/settings/billing`,
4041
}: TeamManagementProps) {
41-
const { data: session, isPending: isSessionPending } = useSession()
42-
const { isInvitationsDisabled } = usePermissionConfig()
42+
const { data: session, isPending: isSessionPending, error: sessionError } = useSession()
43+
const {
44+
isInvitationsDisabled,
45+
isPermissionLoading: isPermissionConfigLoading,
46+
isPermissionError: isPermissionConfigError,
47+
} = usePermissionConfig()
4348

4449
const { data: userSubscriptionData } = useSubscriptionData()
4550
const subscriptionAccess = getSubscriptionAccessState(userSubscriptionData?.data)
4651
const hasTeamPlan = subscriptionAccess.hasUsableTeamAccess
4752
const hasEnterprisePlan = subscriptionAccess.hasUsableEnterpriseAccess
4853

49-
const { data: organization, isLoading, error: orgError } = useOrganization(organizationId)
54+
const {
55+
data: organization,
56+
isLoading,
57+
isError: isOrganizationError,
58+
error: orgError,
59+
} = useOrganization(organizationId)
5060
const adminOrOwner = isAdminOrOwner(organization, session?.user?.email)
5161

52-
const { data: organizationBillingData, isLoading: isOrgBillingLoading } = useOrganizationBilling(
53-
organizationId,
54-
{ enabled: adminOrOwner }
55-
)
56-
57-
const { data: roster, isLoading: isLoadingRoster } = useOrganizationRoster(organizationId)
62+
const {
63+
data: organizationBillingData,
64+
isLoading: isOrgBillingLoading,
65+
isError: isOrganizationBillingError,
66+
} = useOrganizationBilling(organizationId, { enabled: adminOrOwner })
67+
68+
const {
69+
data: roster,
70+
isLoading: isLoadingRoster,
71+
isError: isRosterError,
72+
} = useOrganizationRoster(organizationId)
73+
const hasLoadError =
74+
Boolean(sessionError) ||
75+
isOrganizationError ||
76+
(adminOrOwner && isOrganizationBillingError) ||
77+
isRosterError ||
78+
isPermissionConfigError
79+
const isMembersLoading =
80+
!hasLoadError &&
81+
(isSessionPending ||
82+
isLoading ||
83+
(adminOrOwner && isOrgBillingLoading) ||
84+
isLoadingRoster ||
85+
isPermissionConfigLoading)
86+
const dataState = hasLoadError ? 'error' : isMembersLoading ? 'loading' : 'ready'
87+
const canManage = adminOrOwner && dataState === 'ready'
5888

5989
const removeMemberMutation = useRemoveMember()
6090
const transferOwnershipMutation = useTransferOwnership()
@@ -131,6 +161,13 @@ export function TeamManagement({
131161
}
132162
}, [hasTeamPlan, hasEnterprisePlan, session?.user?.name, orgName])
133163

164+
useEffect(() => {
165+
if (canManage) return
166+
setInviteModalOpen(false)
167+
setTransferDialogOpen(false)
168+
setRemoveMemberDialog((current) => (current.open ? { ...current, open: false } : current))
169+
}, [canManage])
170+
134171
const handleOrgNameChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
135172
const newName = e.target.value
136173
setOrgName(newName)
@@ -156,7 +193,7 @@ export function TeamManagement({
156193

157194
const handleRemoveMember = useCallback(
158195
async (member: Member) => {
159-
if (!session?.user) return
196+
if (!canManage || !session?.user) return
160197

161198
if (!member.user?.id) {
162199
logger.error('Member object missing user ID', { member })
@@ -176,12 +213,12 @@ export function TeamManagement({
176213
isExternalRemoval: member.role === 'external',
177214
})
178215
},
179-
[session?.user]
216+
[canManage, session?.user]
180217
)
181218

182219
const confirmRemoveMember = useCallback(async () => {
183220
const { memberId, isSelfRemoval } = removeMemberDialog
184-
if (!session?.user || !memberId) return
221+
if (!canManage || !session?.user || !memberId) return
185222

186223
try {
187224
await removeMemberMutation.mutateAsync({
@@ -205,30 +242,34 @@ export function TeamManagement({
205242
}, [
206243
removeMemberDialog.memberId,
207244
removeMemberDialog.isSelfRemoval,
245+
canManage,
208246
session?.user?.id,
209247
organizationId,
210248
removeMemberMutation,
211249
])
212250

213251
const handleTransferDialogOpenChange = useCallback(
214252
(next: boolean) => {
253+
if (next && !canManage) return
215254
setTransferDialogOpen(next)
216255
if (!next) {
217256
transferOwnershipMutation.reset()
218257
setTransferPortalError(null)
219258
}
220259
},
221-
[transferOwnershipMutation]
260+
[canManage, transferOwnershipMutation]
222261
)
223262

224263
const handleOpenTransferDialog = useCallback(() => {
264+
if (!canManage) return
225265
transferOwnershipMutation.reset()
226266
setTransferPortalError(null)
227267
setTransferDialogOpen(true)
228-
}, [transferOwnershipMutation])
268+
}, [canManage, transferOwnershipMutation])
229269

230270
const handleConfirmTransfer = useCallback(
231271
async (newOwnerUserId: string) => {
272+
if (!canManage) return
232273
try {
233274
const result = await transferOwnershipMutation.mutateAsync({
234275
orgId: organizationId,
@@ -245,10 +286,11 @@ export function TeamManagement({
245286
logger.error('Failed to transfer ownership', error)
246287
}
247288
},
248-
[organizationId, transferOwnershipMutation]
289+
[canManage, organizationId, transferOwnershipMutation]
249290
)
250291

251292
const handleOpenTransferBillingPortal = useCallback(() => {
293+
if (!canManage) return
252294
setTransferPortalError(null)
253295
const portalWindow = window.open('', '_blank')
254296
openBillingPortal.mutate(
@@ -276,14 +318,22 @@ export function TeamManagement({
276318
},
277319
}
278320
)
279-
}, [organizationId, openBillingPortal])
321+
}, [canManage, organizationId, openBillingPortal])
280322

281323
const queryError = orgError
282324
const errorMessage = queryError instanceof Error ? queryError.message : null
283325
const displayOrganization = organization
284326

285-
if (isLoading && !displayOrganization) {
286-
return null
327+
if (isMembersLoading && !displayOrganization) {
328+
return <section aria-label='Organization members' aria-busy data-members-state='loading' />
329+
}
330+
331+
if (hasLoadError && !displayOrganization) {
332+
return (
333+
<section aria-label='Organization members' aria-busy={false} data-members-state='error'>
334+
<SettingsEmptyState>Unable to load organization members</SettingsEmptyState>
335+
</section>
336+
)
287337
}
288338

289339
if (!displayOrganization) {
@@ -308,12 +358,13 @@ export function TeamManagement({
308358
<>
309359
<section
310360
aria-label='Organization members'
311-
aria-busy={isSessionPending || isLoading || isLoadingRoster}
361+
aria-busy={isMembersLoading}
362+
data-members-state={dataState}
312363
className='flex flex-col gap-7'
313364
>
314365
<SettingsPanel
315366
actions={
316-
adminOrOwner
367+
canManage
317368
? [
318369
{
319370
text: 'Invite',
@@ -327,7 +378,7 @@ export function TeamManagement({
327378
: []
328379
}
329380
>
330-
{adminOrOwner && (
381+
{canManage && (
331382
<TeamSeatsOverview
332383
billingHref={billingHref}
333384
subscriptionData={orgSubscription}
@@ -338,19 +389,23 @@ export function TeamManagement({
338389
/>
339390
)}
340391

341-
<OrganizationMemberLists
342-
canManage={adminOrOwner}
343-
organizationId={displayOrganization.id}
344-
roster={roster ?? null}
345-
isLoadingRoster={isLoadingRoster}
346-
currentUserId={session?.user?.id ?? ''}
347-
onRemoveMember={handleRemoveMember}
348-
onTransferOwnership={handleOpenTransferDialog}
349-
/>
392+
{hasLoadError ? (
393+
<SettingsEmptyState>Unable to load organization members</SettingsEmptyState>
394+
) : isMembersLoading ? null : (
395+
<OrganizationMemberLists
396+
canManage={canManage}
397+
organizationId={displayOrganization.id}
398+
roster={roster ?? null}
399+
isLoadingRoster={false}
400+
currentUserId={session?.user?.id ?? ''}
401+
onRemoveMember={handleRemoveMember}
402+
onTransferOwnership={handleOpenTransferDialog}
403+
/>
404+
)}
350405
</SettingsPanel>
351406
</section>
352407

353-
{adminOrOwner && (
408+
{canManage && (
354409
<OrganizationInviteModal
355410
open={inviteModalOpen}
356411
onOpenChange={setInviteModalOpen}
@@ -362,7 +417,7 @@ export function TeamManagement({
362417
)}
363418

364419
<TransferOwnershipDialog
365-
open={transferDialogOpen}
420+
open={canManage && transferDialogOpen}
366421
onOpenChange={handleTransferDialogOpenChange}
367422
members={roster?.members ?? []}
368423
isLoadingMembers={isLoadingRoster}
@@ -377,7 +432,7 @@ export function TeamManagement({
377432
/>
378433

379434
<RemoveMemberDialog
380-
open={removeMemberDialog.open}
435+
open={canManage && removeMemberDialog.open}
381436
memberName={removeMemberDialog.memberName}
382437
isSelfRemoval={removeMemberDialog.isSelfRemoval}
383438
isExternalRemoval={removeMemberDialog.isExternalRemoval}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { resolveTeammatesDataState, type TeammatesQueryState } from './teammates-state'
3+
4+
const readyState = {
5+
permissionsError: false,
6+
invitationsError: false,
7+
workspacesError: false,
8+
permissionConfigError: false,
9+
permissionsLoading: false,
10+
permissionsPlaceholder: false,
11+
invitationsLoading: false,
12+
invitationsPlaceholder: false,
13+
workspacesLoading: false,
14+
workspacesPlaceholder: false,
15+
permissionConfigLoading: false,
16+
} satisfies TeammatesQueryState
17+
18+
describe('resolveTeammatesDataState', () => {
19+
it('reports loading while any required boundary is unresolved', () => {
20+
expect(resolveTeammatesDataState({ ...readyState, invitationsPlaceholder: true })).toBe(
21+
'loading'
22+
)
23+
})
24+
25+
it('gives errors priority over loading and placeholder data', () => {
26+
expect(
27+
resolveTeammatesDataState({
28+
...readyState,
29+
permissionConfigError: true,
30+
permissionsLoading: true,
31+
workspacesPlaceholder: true,
32+
})
33+
).toBe('error')
34+
})
35+
36+
it('reports ready only after every required boundary resolves', () => {
37+
expect(resolveTeammatesDataState(readyState)).toBe('ready')
38+
})
39+
})

0 commit comments

Comments
 (0)