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
16 changes: 14 additions & 2 deletions apps/docs/content/docs/workflows/blocks/credential.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,17 @@ Filter the returned OAuth credentials by provider. Select one or more providers

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.

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.
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.

### Discover accounts by provider

1. Choose **List Organization Accounts**.
2. Select a provider such as **Gmail** in **Providers**. Leave it empty to list all allowed providers.
3. Leave **Email** blank. You do not need to know an account's email to discover it.
4. Read **emails** for the provider account addresses, or **credentials** for the corresponding account references.
5. While **hasMore** is true, pass **nextCursor** as **Cursor** with the same filters to read the next page.

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.

### Inputs

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

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

**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.
**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.

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.

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.

Expand Down
44 changes: 44 additions & 0 deletions apps/sim/blocks/blocks/credential.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/** @vitest-environment node */
import { createBlock } from '@sim/testing'
import { expect, it, vi } from 'vitest'

vi.mock('@/triggers', () => ({ getTrigger: () => ({ subBlocks: [] }) }))

import { CredentialBlock } from '@/blocks/blocks/credential'
import { collectBlockFieldIssues } from '@/serializer/index'

it.each(['list_organization_accounts', 'list_organization_mcp_connections'])(
'allows %s through workflow validation without an email',
(operation) => {
const params = { operation, organizationProviders: ['google-email'], mcpProvider: 'fireflies' }
const block = createBlock({
type: 'credential',
subBlocks: {
operation: { id: 'operation', type: 'dropdown', value: operation },
organizationProviders: {
id: 'organizationProviders',
type: 'dropdown',
value: ['google-email'],
},
mcpProvider: { id: 'mcpProvider', type: 'dropdown', value: 'fireflies' },
},
})
expect(collectBlockFieldIssues(block, CredentialBlock, params).missingRequiredFields).toEqual(
[]
)
}
)

it('continues to require the enrollment email when finding one organization account', () => {
const block = createBlock({
type: 'credential',
subBlocks: {
operation: { id: 'operation', type: 'dropdown', value: 'find_organization_account' },
organizationProvider: { id: 'organizationProvider', type: 'dropdown', value: 'google-email' },
},
})
const params = { operation: 'find_organization_account', organizationProvider: 'google-email' }
expect(collectBlockFieldIssues(block, CredentialBlock, params).missingRequiredFields).toEqual([
'Email',
])
})
50 changes: 34 additions & 16 deletions apps/sim/blocks/blocks/credential.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,15 @@ export const CredentialBlock: BlockConfig = {
name: 'Credential',
description: 'Select credentials or find organization accounts and MCP connections',
longDescription:
'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.',
'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.',
bestPractices: `
- Use "Select Credential" to define an OAuth credential once and reference <CredentialBlock.credentialId> in multiple downstream blocks instead of repeating credential IDs.
- Use "List Credentials" with a ForEach loop to iterate over all OAuth accounts (e.g. all Gmail accounts).
- Use the Provider filter to narrow results to specific services (e.g. Gmail, Slack).
- The outputs are credential ID references, not secret values — they are safe to log and inspect.
- Use "List Organization Accounts" with Providers selected and Email blank to discover all accessible accounts for those integrations.
- 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.
- "Find Organization Account" requires an exact enrollment email and provider, and fails unless exactly one active account matches.
- Outputs contain account identities and credential references, never secret values.
- To switch credentials across environments, replace the single Credential block rather than updating every downstream block.
`,
docsLink: 'https://docs.sim.ai/workflows/blocks/credential',
Expand All @@ -39,7 +42,11 @@ export const CredentialBlock: BlockConfig = {
select: ['Select an OAuth credential'],
list: ['List OAuth credentials', { text: 'for', field: 'providerFilter' }],
find_organization_account: ['Find organization account', { text: 'for', field: 'email' }],
list_organization_accounts: ['List organization accounts', { text: 'for', field: 'email' }],
list_organization_accounts: [
'List organization accounts',
{ text: 'from', field: 'organizationProviders' },
{ text: 'for', field: 'email' },
],
find_organization_mcp_connection: [
'Find organization MCP connection',
{ text: 'for', field: 'email' },
Expand Down Expand Up @@ -95,17 +102,6 @@ export const CredentialBlock: BlockConfig = {
canonicalParamId: 'credentialId',
condition: { field: 'operation', value: 'select' },
},
{
id: 'email',
title: 'Email',
type: 'short-input',
placeholder: 'person@example.com',
condition: { field: 'operation', value: ORGANIZATION_OPERATIONS },
required: {
field: 'operation',
value: ['find_organization_account', 'find_organization_mcp_connection'],
},
},
{
id: 'organizationProvider',
title: 'Provider',
Expand All @@ -118,6 +114,8 @@ export const CredentialBlock: BlockConfig = {
id: 'organizationProviders',
title: 'Providers',
type: 'dropdown',
placeholder: 'All allowed providers',
emptyIsValid: true,
multiSelect: true,
selectorKey: 'workspace.credentialGroupProviders',
condition: { field: 'operation', value: 'list_organization_accounts' },
Expand All @@ -130,6 +128,17 @@ export const CredentialBlock: BlockConfig = {
condition: { field: 'operation', value: MCP_OPERATIONS },
required: { field: 'operation', value: 'find_organization_mcp_connection' },
},
{
id: 'email',
title: 'Email',
type: 'short-input',
placeholder: 'Optional for lists; exact enrollment email',
condition: { field: 'operation', value: ORGANIZATION_OPERATIONS },
required: {
field: 'operation',
value: ['find_organization_account', 'find_organization_mcp_connection'],
},
},
{
id: 'limit',
title: 'Limit',
Expand All @@ -152,7 +161,10 @@ export const CredentialBlock: BlockConfig = {
},
inputs: {
operation: { type: 'string', description: 'Credential operation' },
email: { type: 'string', description: 'Enrollment email' },
email: {
type: 'string',
description: 'Exact enrollment email; optional for lists, required for find operations',
},
organizationProvider: {
type: 'string',
description: 'Organization OAuth provider ID for an exact match',
Expand Down Expand Up @@ -199,9 +211,15 @@ export const CredentialBlock: BlockConfig = {
credentials: {
type: 'json',
description:
'Array of OAuth credential objects, each with credentialId, displayName, and providerId',
'OAuth credential objects with credentialId, displayName, and providerId. Organization accounts also include email (enrollment address), accountEmail (provider account address), providerSubjectId, and providerTenantId.',
condition: { field: 'operation', value: ['list', 'list_organization_accounts'] },
},
emails: {
type: 'json',
description:
'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.',
condition: { field: 'operation', value: 'list_organization_accounts' },
},
count: {
type: 'number',
description: 'Number of connections returned',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ it('keeps the compact People rows and resends from the actions menu', async () =
</NuqsTestingAdapter>
)
)
expect(container.textContent).toContain('2 accounts connected')
expect(container.textContent).toContain('Gmail (2)')
expect(container.textContent).not.toContain('Copy new link')
expect(container.textContent).not.toContain('gmail: active')
expect(container.textContent).not.toContain('People (1)')
Expand All @@ -126,6 +126,54 @@ it('keeps the compact People rows and resends from the actions menu', async () =
)
})

it('shows only active OAuth and MCP accounts', async () => {
mocks.people.mockReturnValue({
data: {
pages: [
{
enrollments: [
{
id: 'enrollment-1',
email: 'person@example.com',
status: 'completed',
connections: [
{ provider: 'google-calendar', status: 'revoked', count: 3 },
{ provider: 'gmail', status: 'active', count: 2 },
{ provider: 'google-drive', status: 'needs_reauth', count: 1 },
],
mcpConnections: [
{ mcpServerId: 'active-server', name: 'Research workspace', status: 'active' },
{ mcpServerId: 'revoked-server', name: 'Archived workspace', status: 'revoked' },
],
},
],
},
],
},
})
await renderPeople()

const group = container.querySelector('[aria-label="Connected accounts"]')
expect(group?.textContent).toContain('Gmail (2)')
expect(group?.textContent).toContain('Research workspace')
expect(container.textContent).not.toContain('Google Calendar')
expect(container.textContent).not.toContain('Google Drive')
expect(container.textContent).not.toContain('Archived workspace')
expect(container.textContent).not.toContain('Disconnected')
expect(container.textContent).not.toContain('Reconnect required')
})

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

expect(container.textContent).toContain('person@example.com')
expect(container.textContent).not.toContain('Gmail')
expect(container.textContent).not.toContain('accounts connected')
expect(container.querySelector('[role="group"]')).toBeNull()
})

it('requires revoke confirmation, allows cancellation, and never submits from an unfocused Enter', async () => {
await renderPeople()
await selectPersonAction('Revoke')
Expand Down Expand Up @@ -398,12 +446,12 @@ it('keeps a failed revoke confirmation open for retry and blocks dismissal while
})

it.each([
['invited', [], 'Not connected'],
['completed', [{ provider: 'gmail', status: 'needs_reauth', count: 1 }], 'Reconnect required'],
['revoked', [], 'Access revoked'],
['invited', []],
['completed', [{ provider: 'gmail', status: 'needs_reauth', count: 1 }]],
['revoked', []],
])(
'preserves provider navigation and exposes an honest connection state: %s',
async (status, connections, label) => {
'preserves provider navigation and hides inactive account badges: %s',
async (status, connections) => {
mocks.people.mockReturnValue({
data: {
pages: [
Expand Down Expand Up @@ -442,7 +490,8 @@ it.each([
optionId: 'gmail-option',
})
expect(container.textContent).toContain('Gmail')
expect(container.textContent).toContain(label)
expect(container.textContent).toContain('person@example.com')
expect(container.querySelector('[aria-label="Connected accounts"]')).toBeNull()
expect(container.textContent).not.toContain('No people invited')
}
)
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
'use client'

import { type ReactNode, useState } from 'react'
import { Chip, ChipConfirmModal, ChipModalError, toast } from '@sim/emcn'
import { Avatar, AvatarFallback, Chip, ChipConfirmModal, ChipModalError, toast } from '@sim/emcn'
import { Plus } from '@sim/emcn/icons'
import type { SettingsAction, SettingsBackAction } from '@/components/settings/settings-header'
import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
import { MemberAvatar } from '@/app/workspace/[workspaceId]/settings/components/member-list'
import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
import {
SettingsEmptyState,
Expand Down Expand Up @@ -112,7 +111,13 @@ export function OrganizationAccountPeople({
{enrollments.map((person) => (
<SettingsResourceRow
key={person.id}
icon={<MemberAvatar name={person.email} image={null} />}
icon={
<div className='self-start'>
<Avatar size='sm' aria-hidden>
<AvatarFallback>{person.email.charAt(0).toUpperCase()}</AvatarFallback>
</Avatar>
</div>
}
iconVariant='custom'
title={person.email}
description={<OrganizationPersonConnections person={person} />}
Expand Down
Loading
Loading