Skip to content

Commit 2081acc

Browse files
fix(credential-groups): keep people rows compact and active only
1 parent 3489b81 commit 2081acc

3 files changed

Lines changed: 137 additions & 148 deletions

File tree

‎apps/sim/ee/credential-groups/components/organization-account-people.test.tsx‎

Lines changed: 19 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ it('keeps the compact People rows and resends from the actions menu', async () =
115115
</NuqsTestingAdapter>
116116
)
117117
)
118-
expect(container.textContent).toContain('2 accounts connected')
118+
expect(container.textContent).toContain('Gmail (2)')
119119
expect(container.textContent).not.toContain('Copy new link')
120120
expect(container.textContent).not.toContain('gmail: active')
121121
expect(container.textContent).not.toContain('People (1)')
@@ -126,7 +126,7 @@ it('keeps the compact People rows and resends from the actions menu', async () =
126126
)
127127
})
128128

129-
it('counts active accounts and separates disconnected and reconnecting providers', async () => {
129+
it('shows only active OAuth and MCP accounts', async () => {
130130
mocks.people.mockReturnValue({
131131
data: {
132132
pages: [
@@ -153,27 +153,23 @@ it('counts active accounts and separates disconnected and reconnecting providers
153153
})
154154
await renderPeople()
155155

156-
expect(container.textContent).toContain('3 accounts connected')
157-
const groups = Array.from(container.querySelectorAll('[role="group"]'))
158-
expect(groups.map((group) => group.getAttribute('aria-label'))).toEqual([
159-
'Connected',
160-
'Reconnect required',
161-
'Disconnected',
162-
])
163-
expect(groups[0]?.textContent).toContain('Gmail (2)')
164-
expect(groups[0]?.textContent).toContain('Research workspace')
165-
expect(groups[0]?.textContent).not.toContain('Calendar')
166-
expect(groups[1]?.textContent).toContain('Google Drive')
167-
expect(groups[2]?.textContent).toContain('Google Calendar (3)')
168-
expect(groups[2]?.textContent).toContain('Archived workspace')
156+
const group = container.querySelector('[aria-label="Connected accounts"]')
157+
expect(group?.textContent).toContain('Gmail (2)')
158+
expect(group?.textContent).toContain('Research workspace')
159+
expect(container.textContent).not.toContain('Google Calendar')
160+
expect(container.textContent).not.toContain('Google Drive')
161+
expect(container.textContent).not.toContain('Archived workspace')
162+
expect(container.textContent).not.toContain('Disconnected')
163+
expect(container.textContent).not.toContain('Reconnect required')
169164
})
170165

171166
it('hides stale connected badges after the person’s access is revoked', async () => {
172167
const result = mocks.people()
173168
result.data.pages[0].enrollments[0].status = 'revoked'
174169
await renderPeople()
175170

176-
expect(container.textContent).toContain('Access revoked')
171+
expect(container.textContent).toContain('person@example.com')
172+
expect(container.textContent).not.toContain('Gmail')
177173
expect(container.textContent).not.toContain('accounts connected')
178174
expect(container.querySelector('[role="group"]')).toBeNull()
179175
})
@@ -450,12 +446,12 @@ it('keeps a failed revoke confirmation open for retry and blocks dismissal while
450446
})
451447

452448
it.each([
453-
['invited', [], 'Not connected'],
454-
['completed', [{ provider: 'gmail', status: 'needs_reauth', count: 1 }], 'Reconnect required'],
455-
['revoked', [], 'Access revoked'],
449+
['invited', []],
450+
['completed', [{ provider: 'gmail', status: 'needs_reauth', count: 1 }]],
451+
['revoked', []],
456452
])(
457-
'preserves provider navigation and exposes an honest connection state: %s',
458-
async (status, connections, label) => {
453+
'preserves provider navigation and hides inactive account badges: %s',
454+
async (status, connections) => {
459455
mocks.people.mockReturnValue({
460456
data: {
461457
pages: [
@@ -494,7 +490,8 @@ it.each([
494490
optionId: 'gmail-option',
495491
})
496492
expect(container.textContent).toContain('Gmail')
497-
expect(container.textContent).toContain(label)
493+
expect(container.textContent).toContain('person@example.com')
494+
expect(container.querySelector('[aria-label="Connected accounts"]')).toBeNull()
498495
expect(container.textContent).not.toContain('No people invited')
499496
}
500497
)

‎apps/sim/ee/credential-groups/components/organization-account-people.tsx‎

Lines changed: 55 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
11
'use client'
22

33
import { type ReactNode, useState } from 'react'
4-
import { Avatar, AvatarFallback, Chip, ChipConfirmModal, ChipModalError, toast } from '@sim/emcn'
4+
import {
5+
Avatar,
6+
AvatarFallback,
7+
Chip,
8+
ChipConfirmModal,
9+
ChipModalError,
10+
OverflowText,
11+
toast,
12+
} from '@sim/emcn'
513
import { Plus } from '@sim/emcn/icons'
614
import type { SettingsAction, SettingsBackAction } from '@/components/settings/settings-header'
715
import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
@@ -16,10 +24,7 @@ import {
1624
SettingsResourceRow,
1725
} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
1826
import { OrganizationAccountInviteModal } from '@/ee/credential-groups/components/organization-account-invite-modal'
19-
import {
20-
getOrganizationPersonConnectionSummary,
21-
OrganizationPersonConnections,
22-
} from '@/ee/credential-groups/components/organization-person-connections'
27+
import { OrganizationPersonConnections } from '@/ee/credential-groups/components/organization-person-connections'
2328
import {
2429
useOrganizationAccountPeople,
2530
useResendOrganizationAccountInvitation,
@@ -112,52 +117,52 @@ export function OrganizationAccountPeople({
112117
) : (
113118
<div className={RESOURCE_LIST_STACK}>
114119
{enrollments.map((person) => (
115-
<div key={person.id} className='min-w-0 pb-2'>
116-
<SettingsResourceRow
117-
icon={
118-
<Avatar size='md' aria-hidden>
119-
<AvatarFallback>{person.email.charAt(0).toUpperCase()}</AvatarFallback>
120-
</Avatar>
121-
}
122-
iconVariant='custom'
123-
title={person.email}
124-
description={getOrganizationPersonConnectionSummary(person)}
125-
trailing={
126-
<RowActionsMenu
127-
label={`${person.email} actions`}
128-
actions={[
129-
{
130-
label: 'Resend',
131-
disabled: pending || requestDisabled || person.status === 'revoked',
132-
onSelect: () =>
133-
resend.mutate(
134-
{
135-
organizationId,
136-
enrollmentId: person.id,
137-
...(searchConnection
138-
? { optionId: searchConnection.optionId }
139-
: {}),
140-
},
141-
{ onSuccess: () => toast.success('Invitation sent') }
142-
),
143-
},
144-
{
145-
label: searchConnection ? 'Revoke all account access' : 'Revoke',
146-
destructive: true,
147-
disabled: pending || person.status === 'revoked',
148-
onSelect: () => {
149-
revoke.reset()
150-
setRevokingPerson({ id: person.id, email: person.email })
151-
},
120+
<SettingsResourceRow
121+
key={person.id}
122+
icon={
123+
<Avatar size='sm' aria-hidden>
124+
<AvatarFallback>{person.email.charAt(0).toUpperCase()}</AvatarFallback>
125+
</Avatar>
126+
}
127+
iconVariant='custom'
128+
title={
129+
<span className='flex min-w-0 items-center gap-3'>
130+
<OverflowText label={person.email} className='min-w-28 max-w-[240px]' />
131+
<OrganizationPersonConnections person={person} />
132+
</span>
133+
}
134+
trailing={
135+
<RowActionsMenu
136+
label={`${person.email} actions`}
137+
actions={[
138+
{
139+
label: 'Resend',
140+
disabled: pending || requestDisabled || person.status === 'revoked',
141+
onSelect: () =>
142+
resend.mutate(
143+
{
144+
organizationId,
145+
enrollmentId: person.id,
146+
...(searchConnection
147+
? { optionId: searchConnection.optionId }
148+
: {}),
149+
},
150+
{ onSuccess: () => toast.success('Invitation sent') }
151+
),
152+
},
153+
{
154+
label: searchConnection ? 'Revoke all account access' : 'Revoke',
155+
destructive: true,
156+
disabled: pending || person.status === 'revoked',
157+
onSelect: () => {
158+
revoke.reset()
159+
setRevokingPerson({ id: person.id, email: person.email })
152160
},
153-
]}
154-
/>
155-
}
156-
/>
157-
<div className='min-w-0 pl-[42px]'>
158-
<OrganizationPersonConnections person={person} />
159-
</div>
160-
</div>
161+
},
162+
]}
163+
/>
164+
}
165+
/>
161166
))}
162167
</div>
163168
)}
Lines changed: 63 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
1-
import { ChipTag, OverflowText } from '@sim/emcn'
1+
'use client'
2+
3+
import { useRef } from 'react'
4+
import {
5+
ChipTag,
6+
cn,
7+
OverflowText,
8+
scrollFadeAttributes,
9+
scrollFadeXClass,
10+
useScrollEdges,
11+
} from '@sim/emcn'
212
import type { CredentialGroupEnrollmentDetail } from '@/lib/api/contracts/credential-groups'
313
import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers'
414
import { resolveCredentialDisplay } from '@/lib/integrations/credential-display'
@@ -7,87 +17,64 @@ interface OrganizationPersonConnectionsProps {
717
person: CredentialGroupEnrollmentDetail
818
}
919

10-
const CONNECTION_STATUS_LABELS = {
11-
active: 'Connected',
12-
needs_reauth: 'Reconnect required',
13-
revoked: 'Disconnected',
14-
} as const
15-
16-
export function getOrganizationPersonConnectionSummary(person: CredentialGroupEnrollmentDetail) {
17-
if (person.status === 'revoked') return 'Access revoked'
18-
19-
const connected =
20-
person.connections.reduce(
21-
(count, connection) => count + (connection.status === 'active' ? connection.count : 0),
22-
0
23-
) + person.mcpConnections.filter((connection) => connection.status === 'active').length
24-
if (connected > 0) return `${connected} ${connected === 1 ? 'account' : 'accounts'} connected`
25-
26-
const statuses = [...person.connections, ...person.mcpConnections].map(({ status }) => status)
27-
if (statuses.includes('needs_reauth')) return 'Reconnect required'
28-
if (statuses.includes('revoked')) return 'Disconnected'
29-
if (person.status === 'delivery_failed') return 'Connection request failed'
30-
if (person.expired) return 'Connection request expired'
31-
return 'Not connected'
32-
}
33-
3420
export function OrganizationPersonConnections({ person }: OrganizationPersonConnectionsProps) {
35-
if (person.status === 'revoked') return null
21+
const connectionsRef = useRef<HTMLSpanElement>(null)
3622

37-
const connections = person.connections.map((connection) => {
38-
const display =
39-
connection.provider === 'gitlab'
40-
? resolveCredentialDisplay({
41-
type: 'personal_token',
42-
providerId: 'gitlab',
43-
displayName: 'GitLab',
44-
})
45-
: undefined
46-
const service =
47-
connection.provider !== 'gitlab'
48-
? getCredentialGroupProviderService(connection.provider)
49-
: undefined
50-
return {
51-
key: `${connection.provider}:${connection.status}`,
52-
name: service?.name ?? display?.detailTitle ?? 'GitLab',
53-
icon: service?.icon ?? display?.icon ?? undefined,
54-
status: connection.status,
55-
count: connection.count,
56-
}
57-
})
23+
const connections = person.connections
24+
.filter((connection) => connection.status === 'active')
25+
.map((connection) => {
26+
const display =
27+
connection.provider === 'gitlab'
28+
? resolveCredentialDisplay({
29+
type: 'personal_token',
30+
providerId: 'gitlab',
31+
displayName: 'GitLab',
32+
})
33+
: undefined
34+
const service =
35+
connection.provider !== 'gitlab'
36+
? getCredentialGroupProviderService(connection.provider)
37+
: undefined
38+
return {
39+
key: `${connection.provider}:${connection.status}`,
40+
name: service?.name ?? display?.detailTitle ?? 'GitLab',
41+
icon: service?.icon ?? display?.icon ?? undefined,
42+
count: connection.count,
43+
}
44+
})
5845
const accounts = [
5946
...connections,
60-
...person.mcpConnections.map((connection) => ({
61-
key: `mcp:${connection.mcpServerId}`,
62-
name: connection.name,
63-
status: connection.status,
64-
count: 1,
65-
icon: undefined,
66-
})),
47+
...person.mcpConnections
48+
.filter((connection) => connection.status === 'active')
49+
.map((connection) => ({
50+
key: `mcp:${connection.mcpServerId}`,
51+
name: connection.name,
52+
count: 1,
53+
icon: undefined,
54+
})),
6755
]
6856

57+
const visible = person.status !== 'revoked' && accounts.length > 0
58+
const edges = useScrollEdges(connectionsRef, { axis: 'x', enabled: visible })
59+
60+
if (!visible) return null
61+
6962
return (
70-
<div className='flex min-w-0 flex-col gap-1.5'>
71-
{(['active', 'needs_reauth', 'revoked'] as const).map((status) => {
72-
const matching = accounts.filter((account) => account.status === status)
73-
if (matching.length === 0) return null
74-
return (
75-
<div key={status} role='group' aria-label={CONNECTION_STATUS_LABELS[status]}>
76-
{status !== 'active' && (
77-
<p className='mb-1 text-[var(--text-muted)] text-caption'>
78-
{CONNECTION_STATUS_LABELS[status]}
79-
</p>
80-
)}
81-
<div className='flex min-w-0 flex-wrap gap-1'>
82-
{matching.map(({ key, name, icon, count }) => (
83-
<ChipTag key={key} variant='gray' leftIcon={icon} className='max-w-full'>
84-
<OverflowText label={count > 1 ? `${name} (${count})` : name} />
85-
</ChipTag>
86-
))}
87-
</div>
88-
</div>
89-
)
90-
})}
91-
</div>
63+
<span
64+
ref={connectionsRef}
65+
role='group'
66+
aria-label='Connected accounts'
67+
className={cn(
68+
scrollFadeXClass,
69+
'flex min-w-0 items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
70+
)}
71+
{...scrollFadeAttributes(edges)}
72+
>
73+
{accounts.map(({ key, name, icon, count }) => (
74+
<ChipTag key={key} variant='gray' leftIcon={icon} className='max-w-[180px] shrink-0'>
75+
<OverflowText label={count > 1 ? `${name} (${count})` : name} />
76+
</ChipTag>
77+
))}
78+
</span>
9279
)
9380
}

0 commit comments

Comments
 (0)