Skip to content

Commit 49fae45

Browse files
fix(credential-groups): expose provider emails and align people rows (#7904)
* fix(credential-groups): expose provider emails and align people rows * fix(credential-groups): keep people rows compact and active only * fix(audit): compare effective block fields from base snapshots
1 parent 2a5b22c commit 49fae45

15 files changed

Lines changed: 755 additions & 235 deletions

‎apps/docs/content/docs/workflows/blocks/credential.mdx‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,17 @@ Filter the returned OAuth credentials by provider. Select one or more providers
8181

8282
An organization owner or admin must first [set up connected accounts](/platform/connected-accounts) and allow this workflow's workspace. The block uses the organization that owns the workspace; there is no credential group or organization selector.
8383

84-
Every authorized workflow in an allowed workspace can use every active contribution in the organization's pool. Results are not restricted to the running user's own accounts, and no separate per-workflow grant is required. Normal workflow permissions still apply.
84+
Every authorized workflow in an allowed workspace can discover active contributions for the integrations allowed in that workspace. Results are not restricted to the running user's own accounts, and no separate per-workflow grant is required. Normal workflow permissions still apply. Workspace and integration access are checked again on every page.
85+
86+
### Discover accounts by provider
87+
88+
1. Choose **List Organization Accounts**.
89+
2. Select a provider such as **Gmail** in **Providers**. Leave it empty to list all allowed providers.
90+
3. Leave **Email** blank. You do not need to know an account's email to discover it.
91+
4. Read **emails** for the provider account addresses, or **credentials** for the corresponding account references.
92+
5. While **hasMore** is true, pass **nextCursor** as **Cursor** with the same filters to read the next page.
93+
94+
Multiple accounts are returned separately, including accounts contributed by the same person. Disconnected accounts, accounts needing reconnection, and revoked invitations are excluded. Listing never chooses an account automatically for a downstream block.
8595

8696
### Inputs
8797

@@ -102,7 +112,9 @@ Find operations fail unless there is exactly one active matching connection. Lis
102112

103113
**Find Organization Account** returns `credentialId`, `displayName`, `providerId`, and the invitation `email`. Pass `credentialId` into the corresponding integration block's credential field in advanced mode.
104114

105-
**List Organization Accounts** returns these account references in `credentials`, along with `count`, `hasMore`, and `nextCursor`. `count` is the number returned on this page. Feed `credentials` into a ForEach loop and use `<loop.currentItem.credentialId>` inside the loop. To process additional pages, pass `nextCursor` into another call with the same filters while `hasMore` is true; the block does not fetch all pages automatically.
115+
**List Organization Accounts** returns these account references in `credentials`, with an additional `accountEmail` field containing the email verified by the OAuth provider. The existing `email` field remains the person's invitation address, which can differ from their provider account address. An optional **Email** input continues to filter by that exact invitation address.
116+
117+
The list also returns `emails`, `count`, `hasMore`, and `nextCursor`. `emails` contains the provider account addresses on this page in the same order as `credentials`; it preserves separate accounts even when addresses repeat. `count` is the number of accounts returned on this page. Feed `credentials` into a ForEach loop and use `<loop.currentItem.credentialId>` inside the loop. To process additional pages, pass `nextCursor` into another call with the same filters while `hasMore` is true; the block does not fetch all pages automatically.
106118

107119
For example, name a Credential block **account**, choose **Find Organization Account**, set **Email** to `alex@example.com`, and select **Gmail**. Reference `<account.credentialId>` in a Gmail block to act using Alex's contribution.
108120

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/** @vitest-environment node */
2+
import { createBlock } from '@sim/testing'
3+
import { expect, it, vi } from 'vitest'
4+
5+
vi.mock('@/triggers', () => ({ getTrigger: () => ({ subBlocks: [] }) }))
6+
7+
import { CredentialBlock } from '@/blocks/blocks/credential'
8+
import { collectBlockFieldIssues } from '@/serializer/index'
9+
10+
it.each(['list_organization_accounts', 'list_organization_mcp_connections'])(
11+
'allows %s through workflow validation without an email',
12+
(operation) => {
13+
const params = { operation, organizationProviders: ['google-email'], mcpProvider: 'fireflies' }
14+
const block = createBlock({
15+
type: 'credential',
16+
subBlocks: {
17+
operation: { id: 'operation', type: 'dropdown', value: operation },
18+
organizationProviders: {
19+
id: 'organizationProviders',
20+
type: 'dropdown',
21+
value: ['google-email'],
22+
},
23+
mcpProvider: { id: 'mcpProvider', type: 'dropdown', value: 'fireflies' },
24+
},
25+
})
26+
expect(collectBlockFieldIssues(block, CredentialBlock, params).missingRequiredFields).toEqual(
27+
[]
28+
)
29+
}
30+
)
31+
32+
it('continues to require the enrollment email when finding one organization account', () => {
33+
const block = createBlock({
34+
type: 'credential',
35+
subBlocks: {
36+
operation: { id: 'operation', type: 'dropdown', value: 'find_organization_account' },
37+
organizationProvider: { id: 'organizationProvider', type: 'dropdown', value: 'google-email' },
38+
},
39+
})
40+
const params = { operation: 'find_organization_account', organizationProvider: 'google-email' }
41+
expect(collectBlockFieldIssues(block, CredentialBlock, params).missingRequiredFields).toEqual([
42+
'Email',
43+
])
44+
})

‎apps/sim/blocks/blocks/credential.ts‎

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,15 @@ export const CredentialBlock: BlockConfig = {
2121
name: 'Credential',
2222
description: 'Select credentials or find organization accounts and MCP connections',
2323
longDescription:
24-
'Select workspace OAuth credentials or find and list organization accounts in an allowlisted workspace. Organization accounts are shared with every authorized workflow in that workspace. Returns credential references and account metadata. Manage invitations in organization settings.',
24+
'Select workspace OAuth credentials or find and list organization accounts in an allowlisted workspace. List Organization Accounts discovers connected accounts by provider without requiring an email. An optional exact enrollment email narrows the list. Only active accounts for integrations allowed in the executing workspace are returned; disconnected accounts are excluded. Results are paginated using hasMore and nextCursor. Manage invitations in organization settings.',
2525
bestPractices: `
2626
- Use "Select Credential" to define an OAuth credential once and reference <CredentialBlock.credentialId> in multiple downstream blocks instead of repeating credential IDs.
2727
- Use "List Credentials" with a ForEach loop to iterate over all OAuth accounts (e.g. all Gmail accounts).
2828
- Use the Provider filter to narrow results to specific services (e.g. Gmail, Slack).
29-
- The outputs are credential ID references, not secret values — they are safe to log and inspect.
29+
- Use "List Organization Accounts" with Providers selected and Email blank to discover all accessible accounts for those integrations.
30+
- Organization lists return one page at a time. While hasMore is true, pass nextCursor as Cursor with the same filters to get every matching account.
31+
- "Find Organization Account" requires an exact enrollment email and provider, and fails unless exactly one active account matches.
32+
- Outputs contain account identities and credential references, never secret values.
3033
- To switch credentials across environments, replace the single Credential block rather than updating every downstream block.
3134
`,
3235
docsLink: 'https://docs.sim.ai/workflows/blocks/credential',
@@ -39,7 +42,11 @@ export const CredentialBlock: BlockConfig = {
3942
select: ['Select an OAuth credential'],
4043
list: ['List OAuth credentials', { text: 'for', field: 'providerFilter' }],
4144
find_organization_account: ['Find organization account', { text: 'for', field: 'email' }],
42-
list_organization_accounts: ['List organization accounts', { text: 'for', field: 'email' }],
45+
list_organization_accounts: [
46+
'List organization accounts',
47+
{ text: 'from', field: 'organizationProviders' },
48+
{ text: 'for', field: 'email' },
49+
],
4350
find_organization_mcp_connection: [
4451
'Find organization MCP connection',
4552
{ text: 'for', field: 'email' },
@@ -95,17 +102,6 @@ export const CredentialBlock: BlockConfig = {
95102
canonicalParamId: 'credentialId',
96103
condition: { field: 'operation', value: 'select' },
97104
},
98-
{
99-
id: 'email',
100-
title: 'Email',
101-
type: 'short-input',
102-
placeholder: 'person@example.com',
103-
condition: { field: 'operation', value: ORGANIZATION_OPERATIONS },
104-
required: {
105-
field: 'operation',
106-
value: ['find_organization_account', 'find_organization_mcp_connection'],
107-
},
108-
},
109105
{
110106
id: 'organizationProvider',
111107
title: 'Provider',
@@ -118,6 +114,8 @@ export const CredentialBlock: BlockConfig = {
118114
id: 'organizationProviders',
119115
title: 'Providers',
120116
type: 'dropdown',
117+
placeholder: 'All allowed providers',
118+
emptyIsValid: true,
121119
multiSelect: true,
122120
selectorKey: 'workspace.credentialGroupProviders',
123121
condition: { field: 'operation', value: 'list_organization_accounts' },
@@ -130,6 +128,17 @@ export const CredentialBlock: BlockConfig = {
130128
condition: { field: 'operation', value: MCP_OPERATIONS },
131129
required: { field: 'operation', value: 'find_organization_mcp_connection' },
132130
},
131+
{
132+
id: 'email',
133+
title: 'Email',
134+
type: 'short-input',
135+
placeholder: 'Optional for lists; exact enrollment email',
136+
condition: { field: 'operation', value: ORGANIZATION_OPERATIONS },
137+
required: {
138+
field: 'operation',
139+
value: ['find_organization_account', 'find_organization_mcp_connection'],
140+
},
141+
},
133142
{
134143
id: 'limit',
135144
title: 'Limit',
@@ -152,7 +161,10 @@ export const CredentialBlock: BlockConfig = {
152161
},
153162
inputs: {
154163
operation: { type: 'string', description: 'Credential operation' },
155-
email: { type: 'string', description: 'Enrollment email' },
164+
email: {
165+
type: 'string',
166+
description: 'Exact enrollment email; optional for lists, required for find operations',
167+
},
156168
organizationProvider: {
157169
type: 'string',
158170
description: 'Organization OAuth provider ID for an exact match',
@@ -199,9 +211,15 @@ export const CredentialBlock: BlockConfig = {
199211
credentials: {
200212
type: 'json',
201213
description:
202-
'Array of OAuth credential objects, each with credentialId, displayName, and providerId',
214+
'OAuth credential objects with credentialId, displayName, and providerId. Organization accounts also include email (enrollment address), accountEmail (provider account address), providerSubjectId, and providerTenantId.',
203215
condition: { field: 'operation', value: ['list', 'list_organization_accounts'] },
204216
},
217+
emails: {
218+
type: 'json',
219+
description:
220+
'Provider account email addresses on this page, in the same order as credentials. Multiple accounts are preserved; follow nextCursor while hasMore is true for additional pages.',
221+
condition: { field: 'operation', value: 'list_organization_accounts' },
222+
},
205223
count: {
206224
type: 'number',
207225
description: 'Number of connections returned',

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

Lines changed: 56 additions & 7 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,6 +126,54 @@ it('keeps the compact People rows and resends from the actions menu', async () =
126126
)
127127
})
128128

129+
it('shows only active OAuth and MCP accounts', async () => {
130+
mocks.people.mockReturnValue({
131+
data: {
132+
pages: [
133+
{
134+
enrollments: [
135+
{
136+
id: 'enrollment-1',
137+
email: 'person@example.com',
138+
status: 'completed',
139+
connections: [
140+
{ provider: 'google-calendar', status: 'revoked', count: 3 },
141+
{ provider: 'gmail', status: 'active', count: 2 },
142+
{ provider: 'google-drive', status: 'needs_reauth', count: 1 },
143+
],
144+
mcpConnections: [
145+
{ mcpServerId: 'active-server', name: 'Research workspace', status: 'active' },
146+
{ mcpServerId: 'revoked-server', name: 'Archived workspace', status: 'revoked' },
147+
],
148+
},
149+
],
150+
},
151+
],
152+
},
153+
})
154+
await renderPeople()
155+
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')
164+
})
165+
166+
it('hides stale connected badges after the person’s access is revoked', async () => {
167+
const result = mocks.people()
168+
result.data.pages[0].enrollments[0].status = 'revoked'
169+
await renderPeople()
170+
171+
expect(container.textContent).toContain('person@example.com')
172+
expect(container.textContent).not.toContain('Gmail')
173+
expect(container.textContent).not.toContain('accounts connected')
174+
expect(container.querySelector('[role="group"]')).toBeNull()
175+
})
176+
129177
it('requires revoke confirmation, allows cancellation, and never submits from an unfocused Enter', async () => {
130178
await renderPeople()
131179
await selectPersonAction('Revoke')
@@ -398,12 +446,12 @@ it('keeps a failed revoke confirmation open for retry and blocks dismissal while
398446
})
399447

400448
it.each([
401-
['invited', [], 'Not connected'],
402-
['completed', [{ provider: 'gmail', status: 'needs_reauth', count: 1 }], 'Reconnect required'],
403-
['revoked', [], 'Access revoked'],
449+
['invited', []],
450+
['completed', [{ provider: 'gmail', status: 'needs_reauth', count: 1 }]],
451+
['revoked', []],
404452
])(
405-
'preserves provider navigation and exposes an honest connection state: %s',
406-
async (status, connections, label) => {
453+
'preserves provider navigation and hides inactive account badges: %s',
454+
async (status, connections) => {
407455
mocks.people.mockReturnValue({
408456
data: {
409457
pages: [
@@ -442,7 +490,8 @@ it.each([
442490
optionId: 'gmail-option',
443491
})
444492
expect(container.textContent).toContain('Gmail')
445-
expect(container.textContent).toContain(label)
493+
expect(container.textContent).toContain('person@example.com')
494+
expect(container.querySelector('[aria-label="Connected accounts"]')).toBeNull()
446495
expect(container.textContent).not.toContain('No people invited')
447496
}
448497
)

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
'use client'
22

33
import { type ReactNode, useState } from 'react'
4-
import { Chip, ChipConfirmModal, ChipModalError, toast } from '@sim/emcn'
4+
import { Avatar, AvatarFallback, Chip, ChipConfirmModal, ChipModalError, toast } from '@sim/emcn'
55
import { Plus } from '@sim/emcn/icons'
66
import type { SettingsAction, SettingsBackAction } from '@/components/settings/settings-header'
77
import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
8-
import { MemberAvatar } from '@/app/workspace/[workspaceId]/settings/components/member-list'
98
import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
109
import {
1110
SettingsEmptyState,
@@ -112,7 +111,13 @@ export function OrganizationAccountPeople({
112111
{enrollments.map((person) => (
113112
<SettingsResourceRow
114113
key={person.id}
115-
icon={<MemberAvatar name={person.email} image={null} />}
114+
icon={
115+
<div className='self-start'>
116+
<Avatar size='sm' aria-hidden>
117+
<AvatarFallback>{person.email.charAt(0).toUpperCase()}</AvatarFallback>
118+
</Avatar>
119+
</div>
120+
}
116121
iconVariant='custom'
117122
title={person.email}
118123
description={<OrganizationPersonConnections person={person} />}

0 commit comments

Comments
 (0)