From 9b07452379cba5effc9c47d968cc547e35b9d13e Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 14 Aug 2026 19:06:05 -0700 Subject: [PATCH 01/12] fix(credential-groups): show reconnect after authorization --- .../enroll/[token]/oauth-reconnect-link.test.tsx | 15 ++++++++++++++- .../enroll/[token]/oauth-reconnect-link.tsx | 5 +++-- .../app/credential-groups/enroll/[token]/page.tsx | 1 + 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/credential-groups/enroll/[token]/oauth-reconnect-link.test.tsx b/apps/sim/app/credential-groups/enroll/[token]/oauth-reconnect-link.test.tsx index 7df36ad1d79..23b2293cb9a 100644 --- a/apps/sim/app/credential-groups/enroll/[token]/oauth-reconnect-link.test.tsx +++ b/apps/sim/app/credential-groups/enroll/[token]/oauth-reconnect-link.test.tsx @@ -12,7 +12,7 @@ vi.mock('@sim/emcn', () => ({ import { OAuthConnectLink } from '@/app/credential-groups/enroll/[token]/oauth-reconnect-link' describe('OAuthConnectLink', () => { - it('presents enrollment authorization as Connect', () => { + it('presents an unconnected authorization as Connect', () => { ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true const container = document.createElement('div') const root = createRoot(container) @@ -24,4 +24,17 @@ describe('OAuthConnectLink', () => { expect(link?.getAttribute('href')).toBe('/oauth/start') act(() => root.unmount()) }) + + it('presents a connected authorization as Reconnect', () => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const container = document.createElement('div') + const root = createRoot(container) + + act(() => root.render()) + + const link = container.querySelector('a') + expect(link?.textContent).toBe('Reconnect') + expect(link?.getAttribute('href')).toBe('/oauth/start') + act(() => root.unmount()) + }) }) diff --git a/apps/sim/app/credential-groups/enroll/[token]/oauth-reconnect-link.tsx b/apps/sim/app/credential-groups/enroll/[token]/oauth-reconnect-link.tsx index f460446b24f..ab2fcffbfba 100644 --- a/apps/sim/app/credential-groups/enroll/[token]/oauth-reconnect-link.tsx +++ b/apps/sim/app/credential-groups/enroll/[token]/oauth-reconnect-link.tsx @@ -4,12 +4,13 @@ import { chipVariants } from '@sim/emcn' interface OAuthConnectLinkProps { href: string + reconnect?: boolean } -export function OAuthConnectLink({ href }: OAuthConnectLinkProps) { +export function OAuthConnectLink({ href, reconnect = false }: OAuthConnectLinkProps) { return ( - Connect + {reconnect ? 'Reconnect' : 'Connect'} ) } diff --git a/apps/sim/app/credential-groups/enroll/[token]/page.tsx b/apps/sim/app/credential-groups/enroll/[token]/page.tsx index 29e8ac35ac3..6887aa88f9a 100644 --- a/apps/sim/app/credential-groups/enroll/[token]/page.tsx +++ b/apps/sim/app/credential-groups/enroll/[token]/page.tsx @@ -167,6 +167,7 @@ export default async function CredentialGroupEnrollmentPage({ trailing={ } /> From 975245f15463ab54f1e60b418fcb44eacbd1777b Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 14 Aug 2026 19:38:22 -0700 Subject: [PATCH 02/12] feat(slack): include managed user auth in custom bots --- .../connect-slack-bot-modal.tsx | 28 ++++++++++++---- .../slack-managed-user-scopes.ts | 6 ++++ .../credential-groups/slack-managed-users.ts | 7 ++-- .../lib/credential-groups/slack-provider.ts | 9 +++-- apps/sim/triggers/slack/capabilities.test.ts | 33 +++++++++++++++---- apps/sim/triggers/slack/capabilities.ts | 23 +++++++++++++ 6 files changed, 87 insertions(+), 19 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx index ba923cc1ffd..449e1dbe83a 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx @@ -23,7 +23,12 @@ import { useCreateWorkspaceCredential, useUpdateWorkspaceCredential, } from '@/hooks/queries/credentials' -import { buildSlackManifest, SLACK_CAPABILITIES } from '@/triggers/slack/capabilities' +import { + buildSlackManifest, + getSlackManagedUserAuthorizationManifestConfig, + SLACK_CAPABILITIES, + SLACK_MANAGED_USER_AUTHORIZATION_CAPABILITY, +} from '@/triggers/slack/capabilities' const logger = createLogger('ConnectSlackBotModal') @@ -31,11 +36,16 @@ const DEFAULT_APP_NAME = 'Sim Bot' const DONE_STEP = 4 /** Every capability is granted by default; trimming is an opt-in dropdown. */ -const ALL_CAPABILITIES = new Set(SLACK_CAPABILITIES.map((c) => c.id)) +const CUSTOM_BOT_CAPABILITIES = [ + ...SLACK_CAPABILITIES, + SLACK_MANAGED_USER_AUTHORIZATION_CAPABILITY, +] as const + +const ALL_CAPABILITIES = new Set(CUSTOM_BOT_CAPABILITIES.map((capability) => capability.id)) -const CAPABILITY_OPTIONS: ChipDropdownOption[] = SLACK_CAPABILITIES.map((c) => ({ - value: c.id, - label: c.label, +const CAPABILITY_OPTIONS: ChipDropdownOption[] = CUSTOM_BOT_CAPABILITIES.map((capability) => ({ + value: capability.id, + label: capability.label, })) interface ConnectSlackBotModalProps { @@ -118,10 +128,14 @@ export function ConnectSlackBotModal({ ) const manifestJson = useMemo(() => { + const managedUserAuthorization = selected.has(SLACK_MANAGED_USER_AUTHORIZATION_CAPABILITY.id) + ? getSlackManagedUserAuthorizationManifestConfig(getBaseUrl()) + : undefined const manifest = buildSlackManifest(selected, { appName: appName.trim() || DEFAULT_APP_NAME, webhookUrl: requestUrl, description: appDescription, + ...(managedUserAuthorization ? { managedUserAuthorization } : {}), }) return JSON.stringify(manifest, null, 2) }, [selected, appName, appDescription, requestUrl]) @@ -269,7 +283,7 @@ function StepConfigure({ capabilityIds, onCapabilityIdsChange, }: StepConfigureProps) { - const allSelected = capabilityIds.length === SLACK_CAPABILITIES.length + const allSelected = capabilityIds.length === CUSTOM_BOT_CAPABILITIES.length return (
@@ -310,7 +324,7 @@ function StepConfigure({ {allSelected && (

Full access — the bot can read and send messages, react, upload files, and chat as an AI - assistant. + assistant, and people can authorize it through Credential Groups.

)}
diff --git a/apps/sim/lib/credential-groups/slack-managed-user-scopes.ts b/apps/sim/lib/credential-groups/slack-managed-user-scopes.ts index 9e6ec9990ce..3358297be25 100644 --- a/apps/sim/lib/credential-groups/slack-managed-user-scopes.ts +++ b/apps/sim/lib/credential-groups/slack-managed-user-scopes.ts @@ -27,3 +27,9 @@ export const SLACK_MANAGED_USER_SCOPES = [ 'users:read', 'users:read.email', ] as const + +export const SLACK_MANAGED_USER_CONFIGURATION_CALLBACK_PATH = + '/api/credential-groups/slack-managed-users/callback' + +export const SLACK_MANAGED_USER_ENROLLMENT_CALLBACK_PATH = + '/api/credential-groups/oauth/slack/callback' diff --git a/apps/sim/lib/credential-groups/slack-managed-users.ts b/apps/sim/lib/credential-groups/slack-managed-users.ts index 3d7368d6f3c..e2f0653f9e8 100644 --- a/apps/sim/lib/credential-groups/slack-managed-users.ts +++ b/apps/sim/lib/credential-groups/slack-managed-users.ts @@ -14,7 +14,10 @@ import { decryptCredentialGroupProviderConfiguration, encryptCredentialGroupProviderConfiguration, } from '@/lib/credential-groups/provider-configuration' -import { SLACK_MANAGED_USER_SCOPES } from '@/lib/credential-groups/slack-managed-user-scopes' +import { + SLACK_MANAGED_USER_CONFIGURATION_CALLBACK_PATH, + SLACK_MANAGED_USER_SCOPES, +} from '@/lib/credential-groups/slack-managed-user-scopes' import type { DbOrTx } from '@/lib/db/types' import { SLACK_CUSTOM_BOT_PROVIDER_ID, SLACK_CUSTOM_BOT_SECRET_TYPE } from '@/lib/oauth/types' @@ -410,7 +413,7 @@ export async function exchangeSlackUserAuthorization(params: { } export function getSlackManagedUsersRedirectUri(): string { - return `${getBaseUrl()}/api/credential-groups/slack-managed-users/callback` + return `${getBaseUrl()}${SLACK_MANAGED_USER_CONFIGURATION_CALLBACK_PATH}` } export async function createSlackManagedUsersAttempt(params: { diff --git a/apps/sim/lib/credential-groups/slack-provider.ts b/apps/sim/lib/credential-groups/slack-provider.ts index d6633c862b7..4ee972dce31 100644 --- a/apps/sim/lib/credential-groups/slack-provider.ts +++ b/apps/sim/lib/credential-groups/slack-provider.ts @@ -11,7 +11,10 @@ import { } from '@/lib/credential-groups/provider-adapter' import { getSlackCredentialGroupConfiguration } from '@/lib/credential-groups/provider-configuration' import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers' -import { SLACK_MANAGED_USER_SCOPES } from '@/lib/credential-groups/slack-managed-user-scopes' +import { + SLACK_MANAGED_USER_ENROLLMENT_CALLBACK_PATH, + SLACK_MANAGED_USER_SCOPES, +} from '@/lib/credential-groups/slack-managed-user-scopes' import { exchangeSlackUserAuthorization, getSlackCustomBotCredential, @@ -128,7 +131,7 @@ export const slackCredentialGroupProviderAdapter: CredentialGroupProviderAdapter 409 ) } - const redirectUri = `${getBaseUrl()}/api/credential-groups/oauth/${PROVIDER}/callback` + const redirectUri = `${getBaseUrl()}${SLACK_MANAGED_USER_ENROLLMENT_CALLBACK_PATH}` return { redirectUri, buildAuthorizationUrl: ({ state }) => { @@ -148,7 +151,7 @@ export const slackCredentialGroupProviderAdapter: CredentialGroupProviderAdapter credentialGroupId: context.credentialGroupId, slackBotCredentialId: context.option.slackBotCredentialId, }) - const redirectUri = `${getBaseUrl()}/api/credential-groups/oauth/${PROVIDER}/callback` + const redirectUri = `${getBaseUrl()}${SLACK_MANAGED_USER_ENROLLMENT_CALLBACK_PATH}` if ( currentPolicy.authorizationAppId !== policy.authorizationAppId || attempt.redirectUri !== redirectUri diff --git a/apps/sim/triggers/slack/capabilities.test.ts b/apps/sim/triggers/slack/capabilities.test.ts index 4e965129564..b1b2615174f 100644 --- a/apps/sim/triggers/slack/capabilities.test.ts +++ b/apps/sim/triggers/slack/capabilities.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest' -import { buildSlackManifest } from '@/triggers/slack/capabilities' +import { SLACK_MANAGED_USER_SCOPES } from '@/lib/credential-groups/slack-managed-user-scopes' +import { + buildSlackManifest, + getSlackManagedUserAuthorizationManifestConfig, + SLACK_MANAGED_USER_AUTHORIZATION_CAPABILITY, +} from '@/triggers/slack/capabilities' const opts = { appName: 'Test Bot', webhookUrl: 'https://sim.test/api/webhooks/slack' } @@ -55,24 +60,38 @@ describe('buildSlackManifest - description', () => { }) describe('buildSlackManifest - managed users', () => { + it('defines managed user authorization as enabled by default', () => { + expect(SLACK_MANAGED_USER_AUTHORIZATION_CAPABILITY).toMatchObject({ + id: 'managed_user_authorization', + defaultChecked: true, + }) + }) + it('adds user OAuth configuration and its bot prerequisite', () => { + const managedUserAuthorization = + getSlackManagedUserAuthorizationManifestConfig('https://sim.ai') const manifest = buildSlackManifest(new Set(['action_send']), { appName: 'Managed Slack', webhookUrl: 'https://sim.ai/api/webhooks/slack/custom/credential-id', - managedUserAuthorization: { - redirectUrls: ['https://sim.ai/setup', 'https://sim.ai/connect'], - userScopes: ['im:history', 'users:read'], - }, + managedUserAuthorization, }) expect(manifest).toMatchObject({ oauth_config: { - redirect_urls: ['https://sim.ai/setup', 'https://sim.ai/connect'], + redirect_urls: [ + 'https://sim.ai/api/credential-groups/slack-managed-users/callback', + 'https://sim.ai/api/credential-groups/oauth/slack/callback', + ], scopes: { bot: ['chat:write', 'users:read'], - user: ['im:history', 'users:read'], + user: [...SLACK_MANAGED_USER_SCOPES].sort(), }, }, }) }) + + it('omits managed user OAuth configuration when disabled', () => { + const manifest = buildSlackManifest(new Set(['action_send']), opts) + expect(manifest.oauth_config).toEqual({ scopes: { bot: ['chat:write'] } }) + }) }) diff --git a/apps/sim/triggers/slack/capabilities.ts b/apps/sim/triggers/slack/capabilities.ts index b2499064062..63b4f854ccc 100644 --- a/apps/sim/triggers/slack/capabilities.ts +++ b/apps/sim/triggers/slack/capabilities.ts @@ -1,3 +1,9 @@ +import { + SLACK_MANAGED_USER_CONFIGURATION_CALLBACK_PATH, + SLACK_MANAGED_USER_ENROLLMENT_CALLBACK_PATH, + SLACK_MANAGED_USER_SCOPES, +} from '@/lib/credential-groups/slack-managed-user-scopes' + /** * Slack app capabilities that can be toggled on in the manifest generator. * @@ -206,6 +212,23 @@ export const SLACK_CAPABILITIES: readonly SlackCapability[] = [ }, ] as const +export const SLACK_MANAGED_USER_AUTHORIZATION_CAPABILITY = { + id: 'managed_user_authorization', + label: 'Managed user authorization', + description: 'Let people authorize this Slack app for use in Credential Groups.', + defaultChecked: true, +} as const + +export function getSlackManagedUserAuthorizationManifestConfig(baseUrl: string) { + return { + redirectUrls: [ + `${baseUrl}${SLACK_MANAGED_USER_CONFIGURATION_CALLBACK_PATH}`, + `${baseUrl}${SLACK_MANAGED_USER_ENROLLMENT_CALLBACK_PATH}`, + ], + userScopes: SLACK_MANAGED_USER_SCOPES, + } +} + const WEBHOOK_URL_PLACEHOLDER = '' export interface BuildManifestOptions { From 5a07c4fc8244f8c9f0fbf4c7a5fa55310165968b Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 14 Aug 2026 22:41:56 -0700 Subject: [PATCH 03/12] feat(credential-groups): add enrollment completion page --- .../enroll/[token]/complete/route.test.ts | 7 +++---- .../enroll/[token]/complete/route.ts | 11 ++++++----- .../credential-groups/enrollment-redirect.ts | 18 ++++++++++++++++-- .../app/credential-groups/complete/page.tsx | 18 ++++++++++++++++++ .../credential-groups/enroll/[token]/page.tsx | 19 ++++++++----------- 5 files changed, 51 insertions(+), 22 deletions(-) create mode 100644 apps/sim/app/credential-groups/complete/page.tsx diff --git a/apps/sim/app/api/credential-groups/enroll/[token]/complete/route.test.ts b/apps/sim/app/api/credential-groups/enroll/[token]/complete/route.test.ts index bad8457d5fd..c402f5a56a2 100644 --- a/apps/sim/app/api/credential-groups/enroll/[token]/complete/route.test.ts +++ b/apps/sim/app/api/credential-groups/enroll/[token]/complete/route.test.ts @@ -53,11 +53,10 @@ describe('credential group enrollment completion route', () => { const enrollmentRequest = request() const response = await POST(enrollmentRequest, context) - expect(response.status).toBe(307) - expect(response.headers.get('location')).toBe( - '/credential-groups/enroll/invitation-token?submitted=1' - ) + expect(response.status).toBe(303) + expect(response.headers.get('location')).toBe('/credential-groups/complete') expect(response.headers.get('cache-control')).toBe('no-store') + expect(response.headers.get('referrer-policy')).toBe('no-referrer') expect(mocks.complete).toHaveBeenCalledWith({ principal, input: {}, diff --git a/apps/sim/app/api/credential-groups/enroll/[token]/complete/route.ts b/apps/sim/app/api/credential-groups/enroll/[token]/complete/route.ts index d33a0709cab..3eca407eb5c 100644 --- a/apps/sim/app/api/credential-groups/enroll/[token]/complete/route.ts +++ b/apps/sim/app/api/credential-groups/enroll/[token]/complete/route.ts @@ -6,7 +6,10 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { authenticateCredentialGroupEnrollment } from '@/lib/credential-groups/application/enrollment-auth' import { completePublicCredentialGroupEnrollment } from '@/lib/credential-groups/application/public-enrollment' import { enforcePublicCredentialGroupIpRateLimit } from '@/lib/credential-groups/rate-limit' -import { createCredentialGroupEnrollmentRedirect } from '@/app/api/credential-groups/enrollment-redirect' +import { + createCredentialGroupCompletionRedirect, + createCredentialGroupEnrollmentRedirect, +} from '@/app/api/credential-groups/enrollment-redirect' export const dynamic = 'force-dynamic' export const runtime = 'nodejs' @@ -36,9 +39,7 @@ export const POST = withRouteHandler( if (completed === null) { return createCredentialGroupEnrollmentRedirect(token, { oauth: 'unavailable' }) } - return createCredentialGroupEnrollmentRedirect( - token, - completed ? { submitted: '1' } : { oauth: 'incomplete' } - ) + if (completed) return createCredentialGroupCompletionRedirect() + return createCredentialGroupEnrollmentRedirect(token, { oauth: 'incomplete' }) } ) diff --git a/apps/sim/app/api/credential-groups/enrollment-redirect.ts b/apps/sim/app/api/credential-groups/enrollment-redirect.ts index a72ec009906..b2a34e897bf 100644 --- a/apps/sim/app/api/credential-groups/enrollment-redirect.ts +++ b/apps/sim/app/api/credential-groups/enrollment-redirect.ts @@ -1,5 +1,10 @@ import { NextResponse } from 'next/server' +const NO_STORE_REDIRECT_HEADERS = { + 'Cache-Control': 'no-store', + 'Referrer-Policy': 'no-referrer', +} as const + export function createCredentialGroupEnrollmentRedirect( token: string, params: Record @@ -10,8 +15,17 @@ export function createCredentialGroupEnrollmentRedirect( status: 307, headers: { Location: location, - 'Cache-Control': 'no-store', - 'Referrer-Policy': 'no-referrer', + ...NO_STORE_REDIRECT_HEADERS, + }, + }) +} + +export function createCredentialGroupCompletionRedirect(): NextResponse { + return new NextResponse(null, { + status: 303, + headers: { + Location: '/credential-groups/complete', + ...NO_STORE_REDIRECT_HEADERS, }, }) } diff --git a/apps/sim/app/credential-groups/complete/page.tsx b/apps/sim/app/credential-groups/complete/page.tsx new file mode 100644 index 00000000000..4841914b303 --- /dev/null +++ b/apps/sim/app/credential-groups/complete/page.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from 'next' +import { AuthHeader, AuthShell } from '@/app/(auth)/components' + +export const metadata: Metadata = { + title: 'Accounts connected', + robots: { index: false, follow: false }, +} + +export default function CredentialGroupCompletePage() { + return ( + + + + ) +} diff --git a/apps/sim/app/credential-groups/enroll/[token]/page.tsx b/apps/sim/app/credential-groups/enroll/[token]/page.tsx index 6887aa88f9a..4076f4f690f 100644 --- a/apps/sim/app/credential-groups/enroll/[token]/page.tsx +++ b/apps/sim/app/credential-groups/enroll/[token]/page.tsx @@ -109,7 +109,6 @@ export default async function CredentialGroupEnrollmentPage({ const resolvedSearchParams = await searchParams const oauthStatus = getSearchParam(resolvedSearchParams, 'oauth') const connectedOptionId = getSearchParam(resolvedSearchParams, 'connected') - const submitted = getSearchParam(resolvedSearchParams, 'submitted') const oauthMessage = oauthStatus && oauthStatus in OAUTH_MESSAGES ? OAUTH_MESSAGES[oauthStatus as keyof typeof OAUTH_MESSAGES] @@ -118,16 +117,14 @@ export default async function CredentialGroupEnrollmentPage({ const connectedOption = connectedOptionId ? activeOptions.find((option) => option.id === connectedOptionId) : undefined - const notification = submitted - ? { message: 'Accounts submitted successfully.', variant: 'success' as const } - : connectedOptionId - ? { - message: `${connectedOption ? getCredentialGroupProviderService(connectedOption.provider).name : 'Account'} connected successfully.`, - variant: 'success' as const, - } - : oauthMessage - ? { message: oauthMessage, variant: 'error' as const } - : null + const notification = connectedOptionId + ? { + message: `${connectedOption ? getCredentialGroupProviderService(connectedOption.provider).name : 'Account'} connected successfully.`, + variant: 'success' as const, + } + : oauthMessage + ? { message: oauthMessage, variant: 'error' as const } + : null const allConnected = activeOptions.length > 0 && activeOptions.every( From e9c8b79431ed4eabd4708383ec2358bddfcd095b Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 14 Aug 2026 23:05:32 -0700 Subject: [PATCH 04/12] fix(credential-groups): align settings order and block color --- .../app/workspace/[workspaceId]/settings/navigation.test.ts | 2 +- apps/sim/blocks/blocks/credential-group.ts | 2 +- apps/sim/components/settings/navigation.test.ts | 4 ++-- apps/sim/components/settings/navigation.ts | 6 +++--- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts index ef9f1a9e5c5..9cdae3dcb08 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts @@ -69,7 +69,6 @@ describe('unified settings navigation', () => { expect(idsForSection('workspace')).toEqual([ 'teammates', 'secrets', - 'credential-groups', 'mcp', 'custom-tools', 'byok', @@ -77,6 +76,7 @@ describe('unified settings navigation', () => { 'workflow-mcp-servers', 'apikeys', 'sandboxes', + 'credential-groups', 'recently-deleted', ]) expect(idsForSection('organization')).toEqual([ diff --git a/apps/sim/blocks/blocks/credential-group.ts b/apps/sim/blocks/blocks/credential-group.ts index 5b1fbd3e7cc..55bec3b9ab8 100644 --- a/apps/sim/blocks/blocks/credential-group.ts +++ b/apps/sim/blocks/blocks/credential-group.ts @@ -102,7 +102,7 @@ export const CredentialGroupBlock: BlockConfig = { - "Send Invite" sends one email. Use a loop when invitations should come from a dynamic list. `, docsLink: 'https://docs.sim.ai/workflows/blocks/credential-group', - bgColor: '#7C3AED', + bgColor: '#8B5CF6', icon: GridOffset, canvasPresentation: { defaultTitle: 'Credential Groups', diff --git a/apps/sim/components/settings/navigation.test.ts b/apps/sim/components/settings/navigation.test.ts index 6a66e3ed75a..c6afcf93ca2 100644 --- a/apps/sim/components/settings/navigation.test.ts +++ b/apps/sim/components/settings/navigation.test.ts @@ -93,6 +93,7 @@ describe('settings navigation boundaries', () => { 'secrets', 'byok', 'sandboxes', + 'credential-groups', 'custom-tools', 'mcp', 'workflow-mcp-servers', @@ -100,7 +101,6 @@ describe('settings navigation boundaries', () => { 'inbox', 'recently-deleted', 'forks', - 'credential-groups', 'custom-blocks', 'self-host', ]) @@ -492,10 +492,10 @@ describe('settings navigation boundaries', () => { 'teammates', 'byok', 'sandboxes', + 'credential-groups', 'workflow-mcp-servers', 'recently-deleted', 'forks', - 'credential-groups', 'custom-blocks', 'self-host', ]) diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts index d7707e37bcb..f70cc4193d1 100644 --- a/apps/sim/components/settings/navigation.ts +++ b/apps/sim/components/settings/navigation.ts @@ -537,13 +537,13 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] id: 'credential-groups', description: 'Collect and manage OAuth credentials for people outside this workspace.', group: 'workspace', - order: 2, + order: 9, requiresEnterprise: true, allowNonOrgAdmin: true, selfHostedOverride: true, }, planes: { - workspace: { id: 'credential-groups', group: 'enterprise', order: 10 }, + workspace: { id: 'credential-groups', group: 'workspace', order: 4 }, }, }, { @@ -676,7 +676,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] id: 'recently-deleted', description: 'Restore items deleted in the last 30 days.', group: 'workspace', - order: 9, + order: 10, }, planes: { workspace: { id: 'recently-deleted', group: 'system', order: 9 }, From daa9ee4bdee7890bae4753d1ef30a952413fc252 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 14 Aug 2026 23:40:06 -0700 Subject: [PATCH 05/12] fix(credential-groups): delete credentials on access revoke --- .../components/credential-group-detail.tsx | 10 ++++---- .../lib/credential-groups/enrollments.test.ts | 23 +++++++++++++++++++ apps/sim/lib/credential-groups/enrollments.ts | 3 +-- 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/apps/sim/ee/credential-groups/components/credential-group-detail.tsx b/apps/sim/ee/credential-groups/components/credential-group-detail.tsx index 672ed564a66..51255a2b8bd 100644 --- a/apps/sim/ee/credential-groups/components/credential-group-detail.tsx +++ b/apps/sim/ee/credential-groups/components/credential-group-detail.tsx @@ -234,10 +234,10 @@ export function CredentialGroupDetail({ groupId, enrollmentId: revokingEnrollment.id, }) - toast.success(`Invitation revoked for ${revokingEnrollment.email}`) + toast.success(`Access revoked for ${revokingEnrollment.email}`) setRevokingEnrollmentId(null) } catch (error) { - toast.error(getErrorMessage(error, 'Failed to revoke invitation')) + toast.error(getErrorMessage(error, 'Failed to revoke access')) } } @@ -365,9 +365,9 @@ export function CredentialGroupDetail({ !open && !revoke.isPending && setRevokingEnrollmentId(null)} - srTitle='Revoke invitation' - title='Revoke invitation?' - text={`Revoke the invitation for ${revokingEnrollment?.email ?? 'this user'}? Their private link will stop working immediately.`} + srTitle='Revoke access' + title='Revoke access?' + text={`Revoke access for ${revokingEnrollment?.email ?? 'this user'}? Their private link will stop working and all accounts they connected to this Credential Group will be removed.`} dismissLabel='Cancel' confirm={{ label: revoke.isPending ? 'Revoking...' : 'Revoke', diff --git a/apps/sim/lib/credential-groups/enrollments.test.ts b/apps/sim/lib/credential-groups/enrollments.test.ts index 4a826736f2e..593c5e7baba 100644 --- a/apps/sim/lib/credential-groups/enrollments.test.ts +++ b/apps/sim/lib/credential-groups/enrollments.test.ts @@ -33,6 +33,7 @@ import { completeCredentialGroupEnrollment, listCredentialGroupEnrollments, resendCredentialGroupEnrollment, + revokeCredentialGroupEnrollment, } from '@/lib/credential-groups/enrollments' import { CREDENTIAL_GROUP_PROVIDER_IDS } from '@/lib/credential-groups/providers' import { sendEmail } from '@/lib/messaging/email/mailer' @@ -202,6 +203,28 @@ describe('resendCredentialGroupEnrollment', () => { }) }) +describe('revokeCredentialGroupEnrollment', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('deletes every managed credential collected under the revoked enrollment', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ email: ENROLLMENT.email }]) + dbChainMockFns.returning.mockResolvedValueOnce([ + { ...ENROLLMENT, status: 'revoked', revokedAt: new Date() }, + ]) + + const result = await revokeCredentialGroupEnrollment('workspace-1', 'group-1', ENROLLMENT.id) + + expect(result.status).toBe('revoked') + expect(dbChainMockFns.update).toHaveBeenCalledOnce() + expect(dbChainMockFns.update).toHaveBeenCalledWith(schemaMock.credentialGroupEnrollment) + expect(dbChainMockFns.delete).toHaveBeenCalledOnce() + expect(dbChainMockFns.delete).toHaveBeenCalledWith(schemaMock.credential) + }) +}) + describe('completeCredentialGroupEnrollment', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/lib/credential-groups/enrollments.ts b/apps/sim/lib/credential-groups/enrollments.ts index aa3a8b24d9f..1509a2073bb 100644 --- a/apps/sim/lib/credential-groups/enrollments.ts +++ b/apps/sim/lib/credential-groups/enrollments.ts @@ -673,8 +673,7 @@ export async function revokeCredentialGroupEnrollment( if (!revoked) throw new Error('Credential group enrollment update returned no row') await tx - .update(credential) - .set({ managedOauthStatus: 'revoked', revokedAt: now, updatedAt: now }) + .delete(credential) .where( and( eq(credential.type, 'managed_oauth'), From 26e7c9e6ef50e9f3b81a5321a8abc927ce662602 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 14 Aug 2026 23:44:17 -0700 Subject: [PATCH 06/12] fix(credential-groups): use person icon for enrollments --- .../credential-groups/components/credential-group-detail.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sim/ee/credential-groups/components/credential-group-detail.tsx b/apps/sim/ee/credential-groups/components/credential-group-detail.tsx index 51255a2b8bd..965982d9dfb 100644 --- a/apps/sim/ee/credential-groups/components/credential-group-detail.tsx +++ b/apps/sim/ee/credential-groups/components/credential-group-detail.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { Chip, ChipConfirmModal, ChipModalTabs, ChipTag, toast } from '@sim/emcn' -import { ArrowLeft, KeySquare, Plus } from '@sim/emcn/icons' +import { ArrowLeft, Plus, User } from '@sim/emcn/icons' import { getErrorMessage } from '@sim/utils/errors' import { useQueryState } from 'nuqs' import { saveDiscardActions } from '@/components/settings/save-discard-actions' @@ -311,7 +311,7 @@ export function CredentialGroupDetail({ return ( } + icon={} iconFilled title={enrollment.email} description={ From 8e68dbfa97b3cade21667154356bf808253a823a Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 14 Aug 2026 23:51:17 -0700 Subject: [PATCH 07/12] fix(credential-groups): keep enrollment submit visible --- .../credential-groups/enroll/[token]/page.tsx | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/apps/sim/app/credential-groups/enroll/[token]/page.tsx b/apps/sim/app/credential-groups/enroll/[token]/page.tsx index 4076f4f690f..be6f2d96b6a 100644 --- a/apps/sim/app/credential-groups/enroll/[token]/page.tsx +++ b/apps/sim/app/credential-groups/enroll/[token]/page.tsx @@ -172,17 +172,19 @@ export default async function CredentialGroupEnrollmentPage({ })} - {(allConnected || enrollment.status === 'completed') && ( -
+ - - {enrollment.status === 'completed' ? 'Submitted' : 'Submit'} - - - )} + {enrollment.status === 'completed' ? 'Submitted' : 'Submit'} +
+ ) From 86fa5e07204fdf6f80042677c4a85f523063b753 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 15 Aug 2026 00:04:12 -0700 Subject: [PATCH 08/12] fix(credential-groups): make enrollment connections optional --- .../enroll/[token]/complete/route.test.ts | 8 +-- .../enroll/[token]/complete/route.ts | 3 +- .../credential-groups/enroll/[token]/page.tsx | 13 +---- .../components/credential-group-details.tsx | 4 +- .../lib/credential-groups/enrollments.test.ts | 42 +++----------- apps/sim/lib/credential-groups/enrollments.ts | 55 ++----------------- .../credential-groups/slack-managed-users.ts | 2 +- 7 files changed, 20 insertions(+), 107 deletions(-) diff --git a/apps/sim/app/api/credential-groups/enroll/[token]/complete/route.test.ts b/apps/sim/app/api/credential-groups/enroll/[token]/complete/route.test.ts index c402f5a56a2..7807219fc59 100644 --- a/apps/sim/app/api/credential-groups/enroll/[token]/complete/route.test.ts +++ b/apps/sim/app/api/credential-groups/enroll/[token]/complete/route.test.ts @@ -49,7 +49,7 @@ describe('credential group enrollment completion route', () => { mocks.complete.mockResolvedValue({ completed: true }) }) - it('submits a fully connected enrollment through its invitation principal', async () => { + it('submits optional account selections through its invitation principal', async () => { const enrollmentRequest = request() const response = await POST(enrollmentRequest, context) @@ -64,13 +64,13 @@ describe('credential group enrollment completion route', () => { }) }) - it('redirects an incomplete enrollment without marking it complete', async () => { - mocks.complete.mockResolvedValue({ completed: false }) + it('returns to an unavailable enrollment when completion loses authorization', async () => { + mocks.complete.mockResolvedValue({ completed: null }) const response = await POST(request(), context) expect(response.headers.get('location')).toBe( - '/credential-groups/enroll/invitation-token?oauth=incomplete' + '/credential-groups/enroll/invitation-token?oauth=unavailable' ) }) diff --git a/apps/sim/app/api/credential-groups/enroll/[token]/complete/route.ts b/apps/sim/app/api/credential-groups/enroll/[token]/complete/route.ts index 3eca407eb5c..7c1ffec4029 100644 --- a/apps/sim/app/api/credential-groups/enroll/[token]/complete/route.ts +++ b/apps/sim/app/api/credential-groups/enroll/[token]/complete/route.ts @@ -39,7 +39,6 @@ export const POST = withRouteHandler( if (completed === null) { return createCredentialGroupEnrollmentRedirect(token, { oauth: 'unavailable' }) } - if (completed) return createCredentialGroupCompletionRedirect() - return createCredentialGroupEnrollmentRedirect(token, { oauth: 'incomplete' }) + return createCredentialGroupCompletionRedirect() } ) diff --git a/apps/sim/app/credential-groups/enroll/[token]/page.tsx b/apps/sim/app/credential-groups/enroll/[token]/page.tsx index be6f2d96b6a..86f7699e9c7 100644 --- a/apps/sim/app/credential-groups/enroll/[token]/page.tsx +++ b/apps/sim/app/credential-groups/enroll/[token]/page.tsx @@ -68,7 +68,6 @@ const OAUTH_MESSAGES = { permissions_required: 'All requested permissions are required to connect this account.', configuration_changed: 'This credential option changed. Reload the page and try again.', rate_limited: 'Too many authorization attempts. Wait a few minutes and try again.', - incomplete: 'Connect every account before submitting.', unavailable: 'Account authorization is temporarily unavailable. Please try again.', failed: 'Account authorization did not complete. Please try again.', } as const @@ -125,12 +124,6 @@ export default async function CredentialGroupEnrollmentPage({ : oauthMessage ? { message: oauthMessage, variant: 'error' as const } : null - const allConnected = - activeOptions.length > 0 && - activeOptions.every( - (option) => option.connections.length === 1 && option.connections[0]?.status === 'connected' - ) - return ( {notification && ( @@ -177,11 +170,7 @@ export default async function CredentialGroupEnrollmentPage({ method='post' className='mt-6 flex justify-end' > - + {enrollment.status === 'completed' ? 'Submitted' : 'Submit'} diff --git a/apps/sim/ee/credential-groups/components/credential-group-details.tsx b/apps/sim/ee/credential-groups/components/credential-group-details.tsx index 9af6a3da772..7104fe3214c 100644 --- a/apps/sim/ee/credential-groups/components/credential-group-details.tsx +++ b/apps/sim/ee/credential-groups/components/credential-group-details.tsx @@ -48,7 +48,7 @@ function toOptionUpdateInput( const common = { id: option.id, label: getCredentialGroupProviderService(option.provider).name, - required: true, + required: false, } if (option.provider !== 'slack') return { ...common, provider: option.provider } return { @@ -101,7 +101,7 @@ export function CredentialGroupDetails({ const nextOption: NonNullable[number] = { provider, label: service.name, - required: true, + required: false, } return updateOptions([...existing, nextOption], `${service.name} added`) } diff --git a/apps/sim/lib/credential-groups/enrollments.test.ts b/apps/sim/lib/credential-groups/enrollments.test.ts index 593c5e7baba..83da325723a 100644 --- a/apps/sim/lib/credential-groups/enrollments.test.ts +++ b/apps/sim/lib/credential-groups/enrollments.test.ts @@ -229,14 +229,6 @@ describe('completeCredentialGroupEnrollment', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - adapter.getPolicy.mockResolvedValue({ - provider: 'gmail', - providerId: 'google-email', - authorizationAppId: 'google:client', - requiredScopes: ['scope'], - scopeVersion: 1, - }) - adapter.hasRequiredScopes.mockReturnValue(true) }) it('returns unavailable when revocation wins before completion acquires the lifecycle lock', async () => { @@ -261,18 +253,6 @@ describe('completeCredentialGroupEnrollment', () => { inviterName: 'Inviter', }, ]) - queueTableRows(schemaMock.credential, [ - { - optionId: 'option-1', - status: 'active', - scopeVersion: 1, - authorizationAppId: 'google:client', - grantedScopes: ['scope'], - displayName: 'alex@example.com', - metadata: { email: 'alex@example.com' }, - grantedAt: new Date('2026-08-11T12:05:00.000Z'), - }, - ]) queueTableRows(schemaMock.credentialGroupEnrollment, [ { status: 'revoked', @@ -287,10 +267,10 @@ describe('completeCredentialGroupEnrollment', () => { expect(dbChainMockFns.update).not.toHaveBeenCalled() }) - it('refuses completion when a connection needs reauthorization under the row locks', async () => { + it('completes when the recipient skips every optional account', async () => { queueTableRows(schemaMock.credentialGroupEnrollment, [ { - enrollment: { ...ENROLLMENT, status: 'in_progress' }, + enrollment: { ...ENROLLMENT, status: 'invited' }, groupId: 'group-1', groupName: 'Group', groupStatus: 'active', @@ -311,7 +291,7 @@ describe('completeCredentialGroupEnrollment', () => { ]) queueTableRows(schemaMock.credentialGroupEnrollment, [ { - status: 'in_progress', + status: 'invited', invitationTokenHash: ENROLLMENT.invitationTokenHash, invitationExpiresAt: ENROLLMENT.invitationExpiresAt, }, @@ -330,20 +310,12 @@ describe('completeCredentialGroupEnrollment', () => { ], }, ]) - queueTableRows(schemaMock.credential, [ - { - optionId: 'option-1', - status: 'needs_reauth', - scopeVersion: 1, - authorizationAppId: 'google:client', - grantedScopes: ['scope'], - grantedAt: new Date('2026-08-11T12:05:00.000Z'), - }, - ]) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: ENROLLMENT.id }]) - await expect(completeCredentialGroupEnrollment('invitation-token')).resolves.toBe(false) + await expect(completeCredentialGroupEnrollment('invitation-token')).resolves.toBe(true) - expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(dbChainMockFns.update).toHaveBeenCalledWith(schemaMock.credentialGroupEnrollment) + expect(dbChainMockFns.from).not.toHaveBeenCalledWith(schemaMock.credential) expect(adapter.getPolicy).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/credential-groups/enrollments.ts b/apps/sim/lib/credential-groups/enrollments.ts index 1509a2073bb..d61083e1cf8 100644 --- a/apps/sim/lib/credential-groups/enrollments.ts +++ b/apps/sim/lib/credential-groups/enrollments.ts @@ -779,8 +779,8 @@ async function buildPublicCredentialGroupEnrollment( } } -/** Finalizes an enrollment only after every active credential option has one usable connection. */ -export async function completeCredentialGroupEnrollment(token: string): Promise { +/** Finalizes an enrollment after the recipient finishes their optional account selections. */ +export async function completeCredentialGroupEnrollment(token: string): Promise { const row = await resolvePublicEnrollmentRowByIdentity({ invitationTokenHash: hashInvitationToken(token), }) @@ -790,7 +790,7 @@ export async function completeCredentialGroupEnrollment(token: string): Promise< export async function completeAuthorizedCredentialGroupEnrollment( identity: PublicCredentialGroupEnrollmentIdentity -): Promise { +): Promise { const row = await resolveAuthorizedPublicEnrollmentRow(identity) if (!row) return null return completeResolvedCredentialGroupEnrollment(row, identity) @@ -799,7 +799,7 @@ export async function completeAuthorizedCredentialGroupEnrollment( async function completeResolvedCredentialGroupEnrollment( row: NonNullable>>, identity: PublicCredentialGroupEnrollmentIdentity -): Promise { +): Promise { return db.transaction(async (tx) => { await lockCredentialGroupEnrollmentLifecycle(tx, row.enrollment.id) const now = new Date() @@ -825,7 +825,6 @@ async function completeResolvedCredentialGroupEnrollment( const [group] = await tx .select({ status: credentialGroup.status, - options: credentialGroup.options, }) .from(credentialGroup) .where( @@ -838,52 +837,6 @@ async function completeResolvedCredentialGroupEnrollment( .for('update') if (!group || group.status !== 'active') return null - const activeOptions = group.options.filter((option) => option.status === 'active') - if (activeOptions.length === 0) return false - const connections = await tx - .select({ - optionId: credential.credentialGroupOptionId, - status: credential.managedOauthStatus, - scopeVersion: credential.managedOauthScopeVersion, - authorizationAppId: credential.authorizationAppId, - grantedScopes: credential.grantedScopes, - grantedAt: credential.grantedAt, - }) - .from(credential) - .where( - and( - eq(credential.type, 'managed_oauth'), - eq(credential.credentialGroupEnrollmentId, row.enrollment.id) - ) - ) - .for('update') - - for (const option of activeOptions) { - if (!isCredentialGroupProvider(option.provider)) { - throw new Error(`Unsupported Credential Group provider: ${option.provider}`) - } - const matchingConnections = connections.filter( - (connection) => connection.optionId === option.id - ) - if (matchingConnections.length !== 1) return false - const [connection] = matchingConnections - if (!connection || connection.status !== 'active' || !connection.grantedAt) return false - - const adapter = getCredentialGroupProviderAdapter(option.provider) - const policy = await adapter.getPolicy(option, { - workspaceId: identity.workspaceId, - credentialGroupId: identity.credentialGroupId, - executor: tx, - }) - if ( - connection.authorizationAppId !== policy.authorizationAppId || - connection.scopeVersion !== policy.scopeVersion || - !adapter.hasRequiredScopes(connection.grantedScopes ?? [], policy.requiredScopes) - ) { - return false - } - } - const [completed] = await tx .update(credentialGroupEnrollment) .set({ status: 'completed', completedAt: now, updatedAt: now }) diff --git a/apps/sim/lib/credential-groups/slack-managed-users.ts b/apps/sim/lib/credential-groups/slack-managed-users.ts index e2f0653f9e8..8814d4f97f3 100644 --- a/apps/sim/lib/credential-groups/slack-managed-users.ts +++ b/apps/sim/lib/credential-groups/slack-managed-users.ts @@ -671,7 +671,7 @@ export async function exchangeAndConfigureSlackManagedUsers(params: { authorizationAppId, requiredScopes: [...SLACK_MANAGED_USER_SCOPES], scopeVersion, - required: existingOption?.required ?? true, + required: false, status: existingOption?.status ?? ('active' as const), } const options = existingOption From 4fbc52efc916cc73323c94cc8587a295dfdb2762 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 15 Aug 2026 00:39:34 -0700 Subject: [PATCH 09/12] fix(credential-groups): simplify people actions --- .../enrollments/[enrollmentId]/route.ts | 2 +- .../credential-group-detail.test.ts | 38 ------ .../components/credential-group-detail.tsx | 123 ++++++------------ .../application/manage-groups.test.ts | 39 +++++- .../application/manage-groups.ts | 3 +- 5 files changed, 84 insertions(+), 121 deletions(-) delete mode 100644 apps/sim/ee/credential-groups/components/credential-group-detail.test.ts diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/[enrollmentId]/route.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/[enrollmentId]/route.ts index 1a3fc67b10f..431646e3626 100644 --- a/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/[enrollmentId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/[enrollmentId]/route.ts @@ -16,7 +16,7 @@ export const DELETE = defineInternalJsonRoute({ reason: 'Preserve existing internal Credential Group revocation behavior', }), errorPolicy: createCredentialGroupInternalErrorPolicy( - 'Failed to revoke credential group enrollment' + 'Failed to delete person from credential group' ), mapInput: ({ params }) => ({ assertedWorkspaceId: params.id, diff --git a/apps/sim/ee/credential-groups/components/credential-group-detail.test.ts b/apps/sim/ee/credential-groups/components/credential-group-detail.test.ts deleted file mode 100644 index e0f11afb3c8..00000000000 --- a/apps/sim/ee/credential-groups/components/credential-group-detail.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * @vitest-environment node - */ -import { describe, expect, it } from 'vitest' -import type { CredentialGroupEnrollmentDetail } from '@/lib/api/contracts/credential-groups' -import { getEnrollmentStatus } from '@/ee/credential-groups/components/credential-group-detail' - -const ENROLLMENT: CredentialGroupEnrollmentDetail = { - id: 'enrollment-1', - credentialGroupId: 'group-1', - email: 'person@example.com', - status: 'in_progress', - expiresAt: '2026-08-13T00:00:00.000Z', - invitedAt: '2026-08-12T00:00:00.000Z', - sentAt: '2026-08-12T00:00:00.000Z', - completedAt: null, - revokedAt: null, - expired: true, - createdAt: '2026-08-12T00:00:00.000Z', - updatedAt: '2026-08-13T00:00:00.000Z', - connections: [{ provider: 'gmail', status: 'needs_reauth', count: 1 }], -} - -describe('Credential Group enrollment status', () => { - it('keeps expired incomplete invitations ahead of credential reauthorization', () => { - expect(getEnrollmentStatus(ENROLLMENT, ['gmail'])).toEqual({ - label: 'Expired', - invalid: true, - }) - }) - - it('shows reauthorization for completed enrollments after their invitation expires', () => { - expect(getEnrollmentStatus({ ...ENROLLMENT, status: 'completed' }, ['gmail'])).toEqual({ - label: 'Reconnect needed', - invalid: false, - }) - }) -}) diff --git a/apps/sim/ee/credential-groups/components/credential-group-detail.tsx b/apps/sim/ee/credential-groups/components/credential-group-detail.tsx index 965982d9dfb..4746efc5187 100644 --- a/apps/sim/ee/credential-groups/components/credential-group-detail.tsx +++ b/apps/sim/ee/credential-groups/components/credential-group-detail.tsx @@ -1,7 +1,7 @@ 'use client' import { useState } from 'react' -import { Chip, ChipConfirmModal, ChipModalTabs, ChipTag, toast } from '@sim/emcn' +import { Chip, ChipConfirmModal, ChipModalTabs, toast } from '@sim/emcn' import { ArrowLeft, Plus, User } from '@sim/emcn/icons' import { getErrorMessage } from '@sim/utils/errors' import { useQueryState } from 'nuqs' @@ -9,7 +9,6 @@ import { saveDiscardActions } from '@/components/settings/save-discard-actions' import type { CredentialGroupEnrollment, CredentialGroupEnrollmentConnection, - CredentialGroupEnrollmentDetail, } from '@/lib/api/contracts/credential-groups' import type { CredentialGroupProvider } from '@/lib/credential-groups/providers' import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers' @@ -53,35 +52,6 @@ const CREDENTIAL_GROUP_TABS = [ { value: 'people', label: 'People' }, ] as const -export function getEnrollmentStatus( - enrollment: CredentialGroupEnrollmentDetail, - activeProviders: CredentialGroupProvider[] -) { - if (enrollment.status === 'revoked') return { label: 'Revoked', invalid: false } - if (enrollment.status === 'delivery_failed') return { label: 'Delivery failed', invalid: true } - if (enrollment.status !== 'completed' && enrollment.expired) { - return { label: 'Expired', invalid: true } - } - const needsReauthorization = enrollment.connections.some( - (connection) => connection.status === 'needs_reauth' - ) - if (needsReauthorization) return { label: 'Reconnect needed', invalid: false } - const connectedProviders = new Set( - enrollment.connections - .filter((connection) => connection.status === 'active') - .map((connection) => connection.provider) - ) - const allProvidersConnected = - activeProviders.length > 0 && - activeProviders.every((provider) => connectedProviders.has(provider)) - if (enrollment.status === 'completed' && allProvidersConnected) { - return { label: 'Connected', invalid: false } - } - if (enrollment.status === 'completed') return { label: 'In progress', invalid: false } - if (enrollment.status === 'in_progress') return { label: 'In progress', invalid: false } - return { label: 'Invited', invalid: false } -} - interface EnrollmentConnectionsProps { connections: CredentialGroupEnrollmentConnection[] } @@ -124,7 +94,7 @@ export function CredentialGroupDetail({ providerId: SLACK_CUSTOM_BOT_PROVIDER_ID, }) const resend = useResendCredentialGroupEnrollment() - const revoke = useRevokeCredentialGroupEnrollment() + const deleteEnrollment = useRevokeCredentialGroupEnrollment() const updateGroup = useUpdateCredentialGroup() const deleteGroup = useDeleteCredentialGroup() const [activeTab, setActiveTab] = useQueryState(credentialGroupTabParam.key, { @@ -133,18 +103,14 @@ export function CredentialGroupDetail({ }) const [showInvite, setShowInvite] = useState(false) const [showDelete, setShowDelete] = useState(false) - const [revokingEnrollmentId, setRevokingEnrollmentId] = useState(null) + const [deletingEnrollmentId, setDeletingEnrollmentId] = useState(null) const [draftName, setDraftName] = useState(null) const [draftDescription, setDraftDescription] = useState(null) const credentialGroup = detail.data?.pages[0]?.credentialGroup const enrollments = detail.data?.pages.flatMap((page) => page.enrollments) ?? [] - const revokingEnrollment = revokingEnrollmentId - ? (enrollments.find((enrollment) => enrollment.id === revokingEnrollmentId) ?? null) + const deletingEnrollment = deletingEnrollmentId + ? (enrollments.find((enrollment) => enrollment.id === deletingEnrollmentId) ?? null) : null - const activeProviders = - credentialGroup?.options - .filter((option) => option.status === 'active') - .map((option) => option.provider) ?? [] const configurationReady = Boolean(credentialGroup?.options.length) && credentialGroup?.options.every( @@ -226,18 +192,18 @@ export function CredentialGroupDetail({ } } - const handleRevoke = async () => { - if (!revokingEnrollment) return + const handleDeleteEnrollment = async () => { + if (!deletingEnrollment) return try { - await revoke.mutateAsync({ + await deleteEnrollment.mutateAsync({ workspaceId, groupId, - enrollmentId: revokingEnrollment.id, + enrollmentId: deletingEnrollment.id, }) - toast.success(`Access revoked for ${revokingEnrollment.email}`) - setRevokingEnrollmentId(null) + toast.success(`${deletingEnrollment.email} deleted`) + setDeletingEnrollmentId(null) } catch (error) { - toast.error(getErrorMessage(error, 'Failed to revoke access')) + toast.error(getErrorMessage(error, 'Failed to delete person')) } } @@ -307,7 +273,6 @@ export function CredentialGroupDetail({ ) : (
{enrollments.map((enrollment) => { - const status = getEnrollmentStatus(enrollment, activeProviders) return ( } - badge={ - - {status.label} - - } trailing={ - enrollment.status === 'revoked' ? undefined : ( - void handleResend(enrollment), - disabled: resend.isPending, - }, - { - label: 'Revoke', - destructive: true, - onSelect: () => setRevokingEnrollmentId(enrollment.id), - }, - ]} - /> - ) + void handleResend(enrollment), + disabled: resend.isPending, + }, + { + label: 'Delete', + destructive: true, + onSelect: () => setDeletingEnrollmentId(enrollment.id), + }, + ]} + /> } /> ) @@ -363,16 +318,24 @@ export function CredentialGroupDetail({ /> )} !open && !revoke.isPending && setRevokingEnrollmentId(null)} - srTitle='Revoke access' - title='Revoke access?' - text={`Revoke access for ${revokingEnrollment?.email ?? 'this user'}? Their private link will stop working and all accounts they connected to this Credential Group will be removed.`} + open={Boolean(deletingEnrollment)} + onOpenChange={(open) => + !open && !deleteEnrollment.isPending && setDeletingEnrollmentId(null) + } + srTitle='Delete person' + title='Delete person' + text={[ + `Delete ${deletingEnrollment?.email ?? 'this person'}?`, + { + text: ' Their private link will stop working and all accounts they connected to this Credential Group will be removed.', + error: true, + }, + ]} dismissLabel='Cancel' confirm={{ - label: revoke.isPending ? 'Revoking...' : 'Revoke', - onClick: handleRevoke, - disabled: revoke.isPending, + label: deleteEnrollment.isPending ? 'Deleting...' : 'Delete', + onClick: handleDeleteEnrollment, + disabled: deleteEnrollment.isPending, }} /> ({ create: vi.fn(), + get: vi.fn(), list: vi.fn(), + listEnrollments: vi.fn(), requireAvailable: vi.fn(), resolveGroup: vi.fn(), resolvePermission: vi.fn(), @@ -22,11 +24,23 @@ vi.mock('@/lib/credential-groups/application/context', () => ({ vi.mock('@/lib/credential-groups/service', () => ({ createCredentialGroup: mocks.create, deleteCredentialGroup: vi.fn(), - getCredentialGroup: vi.fn(), + getCredentialGroup: mocks.get, listCredentialGroups: mocks.list, updateCredentialGroup: vi.fn(), })) +vi.mock('@/lib/credential-groups/enrollments', () => ({ + CredentialGroupEnrollmentError: class CredentialGroupEnrollmentError extends Error { + constructor( + message: string, + readonly status: number + ) { + super(message) + } + }, + listCredentialGroupEnrollments: mocks.listEnrollments, +})) + vi.mock('@sim/platform-authz/workspace', () => ({ permissionSatisfies: (permission: string | null, required: string) => permission === 'admin' || permission === required, @@ -35,6 +49,7 @@ vi.mock('@sim/platform-authz/workspace', () => ({ import { createCredentialGroupSettings, + getCredentialGroupSettings, listCredentialGroupSettings, } from '@/lib/credential-groups/application/manage-groups' @@ -62,10 +77,17 @@ describe('Credential Group Settings application operations', () => { beforeEach(() => { vi.clearAllMocks() mocks.resolveWorkspace.mockResolvedValue(workspaceContext) + mocks.resolveGroup.mockResolvedValue({ + ...workspaceContext, + credentialGroupId: 'group-1', + name: 'Support', + }) mocks.resolvePermission.mockResolvedValue('admin') mocks.requireAvailable.mockResolvedValue(undefined) mocks.list.mockResolvedValue([]) mocks.create.mockResolvedValue({ id: 'group-1', name: 'Support' }) + mocks.get.mockResolvedValue({ id: 'group-1', name: 'Support' }) + mocks.listEnrollments.mockResolvedValue({ enrollments: [], nextCursor: null }) }) it('rejects an enrollment bearer before loading workspace settings', async () => { @@ -115,4 +137,19 @@ describe('Credential Group Settings application operations', () => { options: [], }) }) + + it('excludes deleted people from Credential Group settings', async () => { + await getCredentialGroupSettings.execute({ + principal: sessionPrincipal, + input: { + assertedWorkspaceId: 'workspace-1', + credentialGroupId: 'group-1', + limit: 50, + }, + }) + + expect(mocks.listEnrollments).toHaveBeenCalledWith('workspace-1', 'group-1', 50, undefined, { + statuses: ['invited', 'in_progress', 'completed', 'delivery_failed'], + }) + }) }) diff --git a/apps/sim/lib/credential-groups/application/manage-groups.ts b/apps/sim/lib/credential-groups/application/manage-groups.ts index f8e9f420bf2..f3fbd3e6761 100644 --- a/apps/sim/lib/credential-groups/application/manage-groups.ts +++ b/apps/sim/lib/credential-groups/application/manage-groups.ts @@ -108,7 +108,8 @@ export const getCredentialGroupSettings = defineAuthorizedWorkspaceUseCase({ context.workspaceId, context.credentialGroupId, input.limit, - input.cursor + input.cursor, + { statuses: ['invited', 'in_progress', 'completed', 'delivery_failed'] } ) return { credentialGroup, ...enrollmentPage } } catch (error) { From 51afa70b4541efe30c738be7740771440570984b Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 15 Aug 2026 00:46:17 -0700 Subject: [PATCH 10/12] fix(credential-groups): preserve pagination after deletion --- .../lib/credential-groups/enrollments.test.ts | 33 +++++++++++++++++++ apps/sim/lib/credential-groups/enrollments.ts | 5 +-- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/apps/sim/lib/credential-groups/enrollments.test.ts b/apps/sim/lib/credential-groups/enrollments.test.ts index 83da325723a..5c7b41fa5f5 100644 --- a/apps/sim/lib/credential-groups/enrollments.test.ts +++ b/apps/sim/lib/credential-groups/enrollments.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { inArray } from 'drizzle-orm' import { beforeEach, describe, expect, it, vi } from 'vitest' const { adapter } = vi.hoisted(() => ({ @@ -133,6 +134,38 @@ describe('listCredentialGroupEnrollments', () => { ) expect(dbChainMockFns.select).not.toHaveBeenCalled() }) + + it('continues pagination when the cursor enrollment was deleted between pages', async () => { + const remainingEnrollment = { + ...ENROLLMENT, + id: 'enrollment-2', + invitedAt: new Date('2026-08-10T12:00:00.000Z'), + } + dbChainMockFns.limit + .mockResolvedValueOnce([{ options: [] }]) + .mockResolvedValueOnce([ + { id: ENROLLMENT.id, invitedAt: new Date('2026-08-11T12:00:00.000Z') }, + ]) + .mockResolvedValueOnce([{ enrollment: remainingEnrollment }]) + + const result = await listCredentialGroupEnrollments( + 'workspace-1', + 'group-1', + 50, + ENROLLMENT.id, + { statuses: ['invited', 'in_progress', 'completed', 'delivery_failed'] } + ) + + expect(result.enrollments).toHaveLength(1) + expect(result.enrollments[0]?.id).toBe(remainingEnrollment.id) + expect(inArray).toHaveBeenCalledOnce() + expect(inArray).toHaveBeenCalledWith(schemaMock.credentialGroupEnrollment.status, [ + 'invited', + 'in_progress', + 'completed', + 'delivery_failed', + ]) + }) }) describe('resendCredentialGroupEnrollment', () => { diff --git a/apps/sim/lib/credential-groups/enrollments.ts b/apps/sim/lib/credential-groups/enrollments.ts index d61083e1cf8..265215738ac 100644 --- a/apps/sim/lib/credential-groups/enrollments.ts +++ b/apps/sim/lib/credential-groups/enrollments.ts @@ -457,10 +457,7 @@ export async function listCredentialGroupEnrollments( eq(credentialGroupEnrollment.id, cursor), eq(credentialGroup.id, groupId), eq(credentialGroup.workspaceId, workspaceId), - filters.email ? eq(credentialGroupEnrollment.email, filters.email) : undefined, - filters.statuses?.length - ? inArray(credentialGroupEnrollment.status, filters.statuses) - : undefined + filters.email ? eq(credentialGroupEnrollment.email, filters.email) : undefined ) ) .limit(1) From a18cbf32ed141b7c6ce17c9df80d7ff3197d13ce Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 15 Aug 2026 01:06:54 -0700 Subject: [PATCH 11/12] fix(credential-groups): delete removed enrollments --- .../enrollments/[enrollmentId]/route.ts | 12 +-- .../components/credential-group-detail.tsx | 4 +- apps/sim/hooks/queries/credential-groups.ts | 6 +- .../lib/api/contracts/credential-groups.ts | 2 +- .../application/list-people.ts | 6 +- .../application/manage-enrollments.test.ts | 4 +- .../application/manage-enrollments.ts | 14 +-- .../application/operations.ts | 4 +- .../lib/credential-groups/enrollments.test.ts | 43 +++++---- apps/sim/lib/credential-groups/enrollments.ts | 92 +++++++++++-------- .../workflow-block-view-interaction.test.tsx | 5 + .../workflow-block/workflow-block-view.tsx | 2 + 12 files changed, 115 insertions(+), 79 deletions(-) diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/[enrollmentId]/route.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/[enrollmentId]/route.ts index 431646e3626..6ccbd8882a0 100644 --- a/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/[enrollmentId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/[enrollmentId]/route.ts @@ -1,19 +1,19 @@ -import { revokeCredentialGroupEnrollmentContract } from '@/lib/api/contracts/credential-groups' +import { deleteCredentialGroupEnrollmentContract } from '@/lib/api/contracts/credential-groups' import { defineInternalJsonRoute, internalRateLimits, internalSessionAuth, } from '@/lib/api/server/routes' -import { revokeCredentialGroupEnrollmentSettings } from '@/lib/credential-groups/application/manage-enrollments' +import { deleteCredentialGroupEnrollmentSettings } from '@/lib/credential-groups/application/manage-enrollments' import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy' export const DELETE = defineInternalJsonRoute({ - contract: revokeCredentialGroupEnrollmentContract, + contract: deleteCredentialGroupEnrollmentContract, auth: internalSessionAuth, - operation: credentialGroupOperations.revokeEnrollment, + operation: credentialGroupOperations.deleteEnrollment, rateLimit: internalRateLimits.none({ - reason: 'Preserve existing internal Credential Group revocation behavior', + reason: 'Preserve existing internal Credential Group deletion behavior', }), errorPolicy: createCredentialGroupInternalErrorPolicy( 'Failed to delete person from credential group' @@ -23,5 +23,5 @@ export const DELETE = defineInternalJsonRoute({ credentialGroupId: params.groupId, enrollmentId: params.enrollmentId, }), - useCase: revokeCredentialGroupEnrollmentSettings, + useCase: deleteCredentialGroupEnrollmentSettings, }) diff --git a/apps/sim/ee/credential-groups/components/credential-group-detail.tsx b/apps/sim/ee/credential-groups/components/credential-group-detail.tsx index 4746efc5187..062b8783cd8 100644 --- a/apps/sim/ee/credential-groups/components/credential-group-detail.tsx +++ b/apps/sim/ee/credential-groups/components/credential-group-detail.tsx @@ -33,8 +33,8 @@ import { CredentialGroupInviteModal } from '@/ee/credential-groups/components/cr import { useCredentialGroupDetail, useDeleteCredentialGroup, + useDeleteCredentialGroupEnrollment, useResendCredentialGroupEnrollment, - useRevokeCredentialGroupEnrollment, useUpdateCredentialGroup, } from '@/hooks/queries/credential-groups' import { useWorkspaceCredentials } from '@/hooks/queries/credentials' @@ -94,7 +94,7 @@ export function CredentialGroupDetail({ providerId: SLACK_CUSTOM_BOT_PROVIDER_ID, }) const resend = useResendCredentialGroupEnrollment() - const deleteEnrollment = useRevokeCredentialGroupEnrollment() + const deleteEnrollment = useDeleteCredentialGroupEnrollment() const updateGroup = useUpdateCredentialGroup() const deleteGroup = useDeleteCredentialGroup() const [activeTab, setActiveTab] = useQueryState(credentialGroupTabParam.key, { diff --git a/apps/sim/hooks/queries/credential-groups.ts b/apps/sim/hooks/queries/credential-groups.ts index c231296b2cc..6aa7af64f29 100644 --- a/apps/sim/hooks/queries/credential-groups.ts +++ b/apps/sim/hooks/queries/credential-groups.ts @@ -6,10 +6,10 @@ import type { ContractBodyInput } from '@/lib/api/contracts' import { createCredentialGroupContract, deleteCredentialGroupContract, + deleteCredentialGroupEnrollmentContract, getCredentialGroupContract, inviteCredentialGroupEnrollmentsContract, resendCredentialGroupEnrollmentContract, - revokeCredentialGroupEnrollmentContract, startSlackCredentialGroupConfigurationContract, updateCredentialGroupContract, } from '@/lib/api/contracts/credential-groups' @@ -184,7 +184,7 @@ export function useResendCredentialGroupEnrollment() { }) } -export function useRevokeCredentialGroupEnrollment() { +export function useDeleteCredentialGroupEnrollment() { const queryClient = useQueryClient() return useMutation({ mutationFn: async ({ @@ -196,7 +196,7 @@ export function useRevokeCredentialGroupEnrollment() { groupId: string enrollmentId: string }) => - requestJson(revokeCredentialGroupEnrollmentContract, { + requestJson(deleteCredentialGroupEnrollmentContract, { params: { id: workspaceId, groupId, enrollmentId }, }), onSettled: (_data, _error, variables) => { diff --git a/apps/sim/lib/api/contracts/credential-groups.ts b/apps/sim/lib/api/contracts/credential-groups.ts index 37cc07a4fe9..fc9bf7dc522 100644 --- a/apps/sim/lib/api/contracts/credential-groups.ts +++ b/apps/sim/lib/api/contracts/credential-groups.ts @@ -351,7 +351,7 @@ export const resendCredentialGroupEnrollmentContract = defineRouteContract({ }, }) -export const revokeCredentialGroupEnrollmentContract = defineRouteContract({ +export const deleteCredentialGroupEnrollmentContract = defineRouteContract({ method: 'DELETE', path: '/api/workspaces/[id]/credential-groups/[groupId]/enrollments/[enrollmentId]', params: credentialGroupEnrollmentParamsSchema, diff --git a/apps/sim/lib/credential-groups/application/list-people.ts b/apps/sim/lib/credential-groups/application/list-people.ts index 418ee854c6b..cfb5a479f99 100644 --- a/apps/sim/lib/credential-groups/application/list-people.ts +++ b/apps/sim/lib/credential-groups/application/list-people.ts @@ -69,7 +69,11 @@ export const listCredentialGroupPeople = defineAuthorizedWorkspaceUseCase({ } catch (error) { if (error instanceof CredentialGroupEnrollmentError) { throw new OrchestrationError( - error.status === 404 ? 'validation' : error.status === 409 ? 'conflict' : 'internal', + error.status === 400 || error.status === 404 + ? 'validation' + : error.status === 409 + ? 'conflict' + : 'internal', error.message ) } diff --git a/apps/sim/lib/credential-groups/application/manage-enrollments.test.ts b/apps/sim/lib/credential-groups/application/manage-enrollments.test.ts index 10c044b9634..24bc1ba580a 100644 --- a/apps/sim/lib/credential-groups/application/manage-enrollments.test.ts +++ b/apps/sim/lib/credential-groups/application/manage-enrollments.test.ts @@ -21,15 +21,15 @@ vi.mock('@/lib/credential-groups/enrollments', () => ({ CredentialGroupEnrollmentError: class CredentialGroupEnrollmentError extends Error { constructor( message: string, - readonly status: 404 | 409 | 502 + readonly status: 400 | 404 | 409 | 502 ) { super(message) } }, + deleteCredentialGroupEnrollment: vi.fn(), inviteCredentialGroupEnrollments: mocks.invite, loadCredentialGroupInviterIdentity: mocks.loadInviter, resendCredentialGroupEnrollment: vi.fn(), - revokeCredentialGroupEnrollment: vi.fn(), })) vi.mock('@sim/platform-authz/workspace', () => ({ diff --git a/apps/sim/lib/credential-groups/application/manage-enrollments.ts b/apps/sim/lib/credential-groups/application/manage-enrollments.ts index 8896aa495c3..b4220f4a18c 100644 --- a/apps/sim/lib/credential-groups/application/manage-enrollments.ts +++ b/apps/sim/lib/credential-groups/application/manage-enrollments.ts @@ -9,10 +9,10 @@ import { credentialGroupOperations } from '@/lib/credential-groups/application/o import { validateCredentialGroupInvitationEmails } from '@/lib/credential-groups/application/validation' import { CredentialGroupEnrollmentError, + deleteCredentialGroupEnrollment, inviteCredentialGroupEnrollments, loadCredentialGroupInviterIdentity, resendCredentialGroupEnrollment, - revokeCredentialGroupEnrollment, } from '@/lib/credential-groups/enrollments' interface CredentialGroupEnrollmentSettingsInput { @@ -109,20 +109,20 @@ export const resendCredentialGroupEnrollmentSettings = defineAuthorizedWorkspace }), }) -export interface RevokeCredentialGroupEnrollmentSettingsInput +export interface DeleteCredentialGroupEnrollmentSettingsInput extends CredentialGroupEnrollmentSettingsInput { enrollmentId: string } -export const revokeCredentialGroupEnrollmentSettings = defineAuthorizedWorkspaceUseCase({ - operation: credentialGroupOperations.revokeEnrollment, - resolveContext: ({ input }: { input: RevokeCredentialGroupEnrollmentSettingsInput }) => +export const deleteCredentialGroupEnrollmentSettings = defineAuthorizedWorkspaceUseCase({ + operation: credentialGroupOperations.deleteEnrollment, + resolveContext: ({ input }: { input: DeleteCredentialGroupEnrollmentSettingsInput }) => resolveCredentialGroupSettingsContext(input.credentialGroupId, input.assertedWorkspaceId), authorizationOptions: {}, async execute({ input, context }) { await requireCredentialGroupSettingsAvailable(context.workspaceId) try { - const credentialGroupEnrollment = await revokeCredentialGroupEnrollment( + const credentialGroupEnrollment = await deleteCredentialGroupEnrollment( context.workspaceId, context.credentialGroupId, input.enrollmentId @@ -137,7 +137,7 @@ export const revokeCredentialGroupEnrollmentSettings = defineAuthorizedWorkspace resourceType: AuditResourceType.CREDENTIAL_GROUP, resourceId: context.credentialGroupId, resourceName: context.name, - description: `Revoked Credential Group access for ${result.credentialGroupEnrollment.email}`, + description: `Deleted ${result.credentialGroupEnrollment.email} from the Credential Group`, metadata: { enrollmentId: result.credentialGroupEnrollment.id }, }), }) diff --git a/apps/sim/lib/credential-groups/application/operations.ts b/apps/sim/lib/credential-groups/application/operations.ts index ecaa65a92fe..fdd1b524246 100644 --- a/apps/sim/lib/credential-groups/application/operations.ts +++ b/apps/sim/lib/credential-groups/application/operations.ts @@ -43,8 +43,8 @@ export const credentialGroupOperations = { workspaceApiKey: 'deny', principalKinds: ['session'], }), - revokeEnrollment: defineWorkspaceOperation({ - id: 'credential_groups.enrollments.revoke', + deleteEnrollment: defineWorkspaceOperation({ + id: 'credential_groups.enrollments.delete', minimumRole: 'admin', workspaceApiKey: 'deny', principalKinds: ['session'], diff --git a/apps/sim/lib/credential-groups/enrollments.test.ts b/apps/sim/lib/credential-groups/enrollments.test.ts index 5c7b41fa5f5..b7696e2ee69 100644 --- a/apps/sim/lib/credential-groups/enrollments.test.ts +++ b/apps/sim/lib/credential-groups/enrollments.test.ts @@ -32,9 +32,9 @@ vi.mock('@/lib/credential-groups/provider-registry', () => ({ import { completeCredentialGroupEnrollment, + deleteCredentialGroupEnrollment, listCredentialGroupEnrollments, resendCredentialGroupEnrollment, - revokeCredentialGroupEnrollment, } from '@/lib/credential-groups/enrollments' import { CREDENTIAL_GROUP_PROVIDER_IDS } from '@/lib/credential-groups/providers' import { sendEmail } from '@/lib/messaging/email/mailer' @@ -143,22 +143,26 @@ describe('listCredentialGroupEnrollments', () => { } dbChainMockFns.limit .mockResolvedValueOnce([{ options: [] }]) - .mockResolvedValueOnce([ - { id: ENROLLMENT.id, invitedAt: new Date('2026-08-11T12:00:00.000Z') }, - ]) + .mockResolvedValueOnce([{ enrollment: ENROLLMENT }, { enrollment: remainingEnrollment }]) + .mockResolvedValueOnce([{ options: [] }]) .mockResolvedValueOnce([{ enrollment: remainingEnrollment }]) + const firstPage = await listCredentialGroupEnrollments('workspace-1', 'group-1', 1, undefined, { + statuses: ['invited', 'in_progress', 'completed', 'delivery_failed'], + }) + if (!firstPage.nextCursor) throw new Error('Expected a next enrollment cursor') const result = await listCredentialGroupEnrollments( 'workspace-1', 'group-1', 50, - ENROLLMENT.id, + firstPage.nextCursor, { statuses: ['invited', 'in_progress', 'completed', 'delivery_failed'] } ) + expect(firstPage.nextCursor).toEqual(expect.any(String)) expect(result.enrollments).toHaveLength(1) expect(result.enrollments[0]?.id).toBe(remainingEnrollment.id) - expect(inArray).toHaveBeenCalledOnce() + expect(dbChainMockFns.limit).toHaveBeenCalledTimes(4) expect(inArray).toHaveBeenCalledWith(schemaMock.credentialGroupEnrollment.status, [ 'invited', 'in_progress', @@ -166,6 +170,16 @@ describe('listCredentialGroupEnrollments', () => { 'delivery_failed', ]) }) + + it('rejects a malformed enrollment cursor', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ options: [] }]) + + await expect( + listCredentialGroupEnrollments('workspace-1', 'group-1', 50, 'not-a-cursor') + ).rejects.toMatchObject({ message: 'Enrollment cursor is invalid', status: 400 }) + + expect(dbChainMockFns.limit).toHaveBeenCalledOnce() + }) }) describe('resendCredentialGroupEnrollment', () => { @@ -236,25 +250,22 @@ describe('resendCredentialGroupEnrollment', () => { }) }) -describe('revokeCredentialGroupEnrollment', () => { +describe('deleteCredentialGroupEnrollment', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() }) - it('deletes every managed credential collected under the revoked enrollment', async () => { + it('deletes the enrollment and lets its foreign-key cascade remove managed credentials', async () => { dbChainMockFns.limit.mockResolvedValueOnce([{ email: ENROLLMENT.email }]) - dbChainMockFns.returning.mockResolvedValueOnce([ - { ...ENROLLMENT, status: 'revoked', revokedAt: new Date() }, - ]) + dbChainMockFns.returning.mockResolvedValueOnce([ENROLLMENT]) - const result = await revokeCredentialGroupEnrollment('workspace-1', 'group-1', ENROLLMENT.id) + const result = await deleteCredentialGroupEnrollment('workspace-1', 'group-1', ENROLLMENT.id) - expect(result.status).toBe('revoked') - expect(dbChainMockFns.update).toHaveBeenCalledOnce() - expect(dbChainMockFns.update).toHaveBeenCalledWith(schemaMock.credentialGroupEnrollment) + expect(result.id).toBe(ENROLLMENT.id) + expect(dbChainMockFns.update).not.toHaveBeenCalled() expect(dbChainMockFns.delete).toHaveBeenCalledOnce() - expect(dbChainMockFns.delete).toHaveBeenCalledWith(schemaMock.credential) + expect(dbChainMockFns.delete).toHaveBeenCalledWith(schemaMock.credentialGroupEnrollment) }) }) diff --git a/apps/sim/lib/credential-groups/enrollments.ts b/apps/sim/lib/credential-groups/enrollments.ts index 265215738ac..c3403131c01 100644 --- a/apps/sim/lib/credential-groups/enrollments.ts +++ b/apps/sim/lib/credential-groups/enrollments.ts @@ -126,13 +126,52 @@ async function lockCredentialGroupInvitationTarget( export class CredentialGroupEnrollmentError extends Error { constructor( message: string, - readonly status: 404 | 409 | 502 + readonly status: 400 | 404 | 409 | 502 ) { super(message) this.name = 'CredentialGroupEnrollmentError' } } +interface CredentialGroupEnrollmentCursor { + id: string + invitedAt: Date +} + +function encodeCredentialGroupEnrollmentCursor( + enrollment: Pick +): string { + return Buffer.from( + JSON.stringify({ id: enrollment.id, invitedAt: enrollment.invitedAt.toISOString() }) + ).toString('base64url') +} + +function decodeCredentialGroupEnrollmentCursor(cursor: string): CredentialGroupEnrollmentCursor { + try { + const decoded: unknown = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) + if (!decoded || typeof decoded !== 'object' || Array.isArray(decoded)) { + throw new Error('Cursor payload must be an object') + } + const { id, invitedAt: invitedAtValue } = decoded as Record + if ( + typeof id !== 'string' || + !id.trim() || + id !== id.trim() || + id.length > 128 || + typeof invitedAtValue !== 'string' + ) { + throw new Error('Cursor payload is malformed') + } + const invitedAt = new Date(invitedAtValue) + if (Number.isNaN(invitedAt.getTime()) || invitedAt.toISOString() !== invitedAtValue) { + throw new Error('Cursor timestamp is invalid') + } + return { id, invitedAt } + } catch { + throw new CredentialGroupEnrollmentError('Enrollment cursor is invalid', 400) + } +} + function hashInvitationToken(token: string): string { return sha256Hex(token) } @@ -443,27 +482,7 @@ export async function listCredentialGroupEnrollments( .filter((option) => option.status === 'active') .map((option) => option.id) - let cursorPosition: { id: string; invitedAt: Date } | undefined - if (cursor) { - const [cursorRow] = await db - .select({ id: credentialGroupEnrollment.id, invitedAt: credentialGroupEnrollment.invitedAt }) - .from(credentialGroupEnrollment) - .innerJoin( - credentialGroup, - eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId) - ) - .where( - and( - eq(credentialGroupEnrollment.id, cursor), - eq(credentialGroup.id, groupId), - eq(credentialGroup.workspaceId, workspaceId), - filters.email ? eq(credentialGroupEnrollment.email, filters.email) : undefined - ) - ) - .limit(1) - if (!cursorRow) throw new CredentialGroupEnrollmentError('Enrollment cursor not found', 404) - cursorPosition = cursorRow - } + const cursorPosition = cursor ? decodeCredentialGroupEnrollmentCursor(cursor) : undefined const rows = await db .select({ enrollment: credentialGroupEnrollment }) @@ -535,12 +554,18 @@ export async function listCredentialGroupEnrollments( if (current) current.push(summary) else connectionsByEnrollment.set(connection.enrollmentId, [summary]) } + const nextCursorEnrollment = hasNextPage ? pageRows.at(-1)?.enrollment : undefined + if (hasNextPage && !nextCursorEnrollment) { + throw new Error('Credential group enrollment page is missing its cursor boundary') + } return { enrollments: pageRows.map(({ enrollment }) => ({ ...toCredentialGroupEnrollment(enrollment), connections: connectionsByEnrollment.get(enrollment.id) ?? [], })), - nextCursor: hasNextPage ? (pageRows.at(-1)?.enrollment.id ?? null) : null, + nextCursor: nextCursorEnrollment + ? encodeCredentialGroupEnrollmentCursor(nextCursorEnrollment) + : null, } } @@ -634,7 +659,7 @@ export async function resendCredentialGroupEnrollment( }) } -export async function revokeCredentialGroupEnrollment( +export async function deleteCredentialGroupEnrollment( workspaceId: string, groupId: string, enrollmentId: string @@ -656,10 +681,8 @@ export async function revokeCredentialGroupEnrollment( return db.transaction(async (tx) => { await lockCredentialGroupInvitationTarget(tx, groupId, existing.email) await lockCredentialGroupEnrollmentLifecycle(tx, enrollmentId) - const now = new Date() - const [revoked] = await tx - .update(credentialGroupEnrollment) - .set({ status: 'revoked', revokedAt: now, updatedAt: now }) + const [deleted] = await tx + .delete(credentialGroupEnrollment) .where( and( eq(credentialGroupEnrollment.id, enrollmentId), @@ -667,17 +690,8 @@ export async function revokeCredentialGroupEnrollment( ) ) .returning() - if (!revoked) throw new Error('Credential group enrollment update returned no row') - - await tx - .delete(credential) - .where( - and( - eq(credential.type, 'managed_oauth'), - eq(credential.credentialGroupEnrollmentId, enrollmentId) - ) - ) - return toCredentialGroupEnrollment(revoked) + if (!deleted) throw new CredentialGroupEnrollmentError('Enrollment not found', 404) + return toCredentialGroupEnrollment(deleted) }) } diff --git a/packages/workflow-renderer/src/workflow-block/workflow-block-view-interaction.test.tsx b/packages/workflow-renderer/src/workflow-block/workflow-block-view-interaction.test.tsx index 5ac534d06b3..6a4347ae993 100644 --- a/packages/workflow-renderer/src/workflow-block/workflow-block-view-interaction.test.tsx +++ b/packages/workflow-renderer/src/workflow-block/workflow-block-view-interaction.test.tsx @@ -226,6 +226,7 @@ describe('WorkflowTypeTag colors', () => { expect(getWorkflowTypeRole('api')).toBe('interface') expect(getWorkflowTypeRole('condition')).toBe('logic') expect(getWorkflowTypeRole('credential')).toBe('state') + expect(getWorkflowTypeRole('credential_group')).toBe('identity') expect(getWorkflowTypeRole('router_v2')).toBe('flow') expect(getWorkflowTypeRole('table')).toBe('records') expect(getWorkflowTypeRole('a2a')).toBe('neutral') @@ -244,6 +245,10 @@ describe('WorkflowTypeTag colors', () => { expect(getWorkflowTypeAccent('parallel')).toEqual({ variant: 'workflow', tone: 'ash' }) expect(getWorkflowTypeAccent('router')).toEqual({ variant: 'workflow', tone: 'ash' }) expect(getWorkflowTypeAccent('condition')).toEqual({ variant: 'workflow', tone: 'orange' }) + expect(getWorkflowTypeAccent('credential_group')).toEqual({ + variant: 'workflow', + tone: 'purple', + }) expect(getWorkflowTypeAccent('image_generator_v2')).toEqual({ variant: 'workflow', tone: 'purple', diff --git a/packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx b/packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx index 71c7b5ab6d9..4a83665add1 100644 --- a/packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx +++ b/packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx @@ -105,6 +105,7 @@ const WORKFLOW_ROLE_ACCENTS = { state: { variant: 'workflow', tone: 'yellow' }, flow: { variant: 'workflow', tone: 'ash' }, records: { variant: 'workflow', tone: 'green' }, + identity: { variant: 'workflow', tone: 'purple' }, neutral: { variant: 'workflow', tone: 'neutral' }, generative: { variant: 'workflow', tone: 'purple' }, knowledge: { variant: 'workflow', tone: 'content' }, @@ -118,6 +119,7 @@ const WORKFLOW_TYPE_ROLES = { api: 'interface', condition: 'logic', credential: 'state', + credential_group: 'identity', deployments: 'neutral', enrichment: 'knowledge', evaluator: 'logic', From c3f455a274f9e0586f4ef3f24ae12e34cb508f20 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 15 Aug 2026 01:35:50 -0700 Subject: [PATCH 12/12] fix(credential-groups): hydrate canvas labels --- .../workflow-block/workflow-block.tsx | 8 ++ apps/sim/blocks/blocks/credential-group.ts | 13 ++- apps/sim/blocks/types.ts | 3 +- .../queries/dynamic-subblock-options.test.tsx | 110 ++++++++++++++++++ .../hooks/queries/dynamic-subblock-options.ts | 71 +++++++++++ .../emcn/src/components/chip-tag/chip-tag.tsx | 2 + .../workflow-block-view-interaction.test.tsx | 31 ++++- .../workflow-block/workflow-block-view.tsx | 2 +- 8 files changed, 231 insertions(+), 9 deletions(-) create mode 100644 apps/sim/hooks/queries/dynamic-subblock-options.test.tsx create mode 100644 apps/sim/hooks/queries/dynamic-subblock-options.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx index 406dd5f06a0..3babe49ba7a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx @@ -100,6 +100,7 @@ import { getDependsOnFields } from '@/blocks/utils' import { useKnowledgeBase } from '@/hooks/kb/use-knowledge' import { useCustomTools } from '@/hooks/queries/custom-tools' import { useDeployWorkflow } from '@/hooks/queries/deployments' +import { useDynamicSubBlockOptionDisplayName } from '@/hooks/queries/dynamic-subblock-options' import { useMcpServers, useMcpToolsQuery } from '@/hooks/queries/mcp' import { useCredentialName } from '@/hooks/queries/oauth/oauth-credentials' import { useSandboxes } from '@/hooks/queries/sandboxes' @@ -380,6 +381,12 @@ const SubBlockRow = memo(function SubBlockRow({ () => resolveDropdownLabel(subBlock, rawValue), [subBlock, rawValue] ) + const dynamicOptionDisplayName = useDynamicSubBlockOptionDisplayName({ + workspaceId, + blockId, + subBlock, + value: rawValue, + }) const resolveContextValue = useCallback( (key: string): string | undefined => { @@ -568,6 +575,7 @@ const SubBlockRow = memo(function SubBlockRow({ const hydratedName = credentialName || dropdownLabel || + dynamicOptionDisplayName || variablesDisplayValue || filterDisplayValue || toolsDisplayValue || diff --git a/apps/sim/blocks/blocks/credential-group.ts b/apps/sim/blocks/blocks/credential-group.ts index 55bec3b9ab8..5f821fe2db5 100644 --- a/apps/sim/blocks/blocks/credential-group.ts +++ b/apps/sim/blocks/blocks/credential-group.ts @@ -21,13 +21,14 @@ const CREDENTIAL_GROUP_CANONICAL_GROUP = { advancedIds: ['manualCredentialGroup'], } as const satisfies CanonicalGroup -async function fetchCachedCredentialGroups() { +async function fetchCachedCredentialGroups(signal?: AbortSignal) { const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId if (!workspaceId) return [] return getQueryClient().fetchQuery({ queryKey: credentialGroupKeys.list(workspaceId), - queryFn: ({ signal }) => fetchCredentialGroupList(workspaceId, signal), + queryFn: ({ signal: querySignal }) => + fetchCredentialGroupList(workspaceId, signal ?? querySignal), staleTime: CREDENTIAL_GROUP_LIST_STALE_TIME, }) } @@ -169,8 +170,8 @@ export const CredentialGroupBlock: BlockConfig = { .map((group) => ({ label: group.name, id: group.id })) .sort((a, b) => a.label.localeCompare(b.label)) }, - fetchOptionById: async (_blockId: string, optionId: string) => { - const groups = await fetchCachedCredentialGroups() + fetchOptionById: async (_blockId: string, optionId: string, signal?: AbortSignal) => { + const groups = await fetchCachedCredentialGroups(signal) const group = groups.find((candidate) => candidate.id === optionId) return group ? { label: group.name, id: group.id } : null }, @@ -219,10 +220,10 @@ export const CredentialGroupBlock: BlockConfig = { }) .sort((a, b) => a.label.localeCompare(b.label)) }, - fetchOptionById: async (blockId: string, optionId: string) => { + fetchOptionById: async (blockId: string, optionId: string, signal?: AbortSignal) => { const credentialGroupId = resolveCredentialGroupIdForBlock(blockId) if (!credentialGroupId) return null - const groups = await fetchCachedCredentialGroups() + const groups = await fetchCachedCredentialGroups(signal) const group = groups.find((candidate) => candidate.id === credentialGroupId) const option = group?.options.find( (candidate) => diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index da688484873..3d9c312436e 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -489,7 +489,8 @@ export interface SubBlockConfig { // Called when component mounts with a stored value to display the correct label before options load fetchOptionById?: ( blockId: string, - optionId: string + optionId: string, + signal?: AbortSignal ) => Promise<{ label: string; id: string } | null> /** * tool-input only: tool categories the consuming block cannot execute. They diff --git a/apps/sim/hooks/queries/dynamic-subblock-options.test.tsx b/apps/sim/hooks/queries/dynamic-subblock-options.test.tsx new file mode 100644 index 00000000000..6fe376384ba --- /dev/null +++ b/apps/sim/hooks/queries/dynamic-subblock-options.test.tsx @@ -0,0 +1,110 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { SubBlockConfig } from '@/blocks/types' +import { useDynamicSubBlockOptionDisplayName } from '@/hooks/queries/dynamic-subblock-options' + +interface HookHarness { + result: () => T + unmount: () => void +} + +function renderHookWithClient(useHook: () => T): HookHarness { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const container = document.createElement('div') + const root: Root = createRoot(container) + let latest!: T + + function Probe() { + latest = useHook() + return null + } + + act(() => { + root.render( + + + + ) + }) + + return { + result: () => latest, + unmount: () => act(() => root.unmount()), + } +} + +async function waitForResult(assertion: () => void) { + await act(async () => { + await vi.waitFor(assertion, { interval: 1 }) + }) +} + +describe('useDynamicSubBlockOptionDisplayName', () => { + const mounted: Array<() => void> = [] + + afterEach(() => { + mounted.splice(0).forEach((unmount) => unmount()) + vi.clearAllMocks() + }) + + it('hydrates a stored dynamic dropdown id to its label', async () => { + const fetchOptionById = vi.fn(async (_blockId: string, optionId: string) => ({ + id: optionId, + label: 'Customer support accounts', + })) + const subBlock = { + id: 'credentialGroup', + title: 'Credential Group', + type: 'dropdown', + options: [], + fetchOptionById, + } satisfies SubBlockConfig + + const hook = renderHookWithClient(() => + useDynamicSubBlockOptionDisplayName({ + workspaceId: 'workspace-1', + blockId: 'block-1', + subBlock, + value: 'group-uuid', + }) + ) + mounted.push(hook.unmount) + + await waitForResult(() => expect(hook.result()).toBe('Customer support accounts')) + + expect(fetchOptionById).toHaveBeenCalledWith('block-1', 'group-uuid', expect.any(AbortSignal)) + }) + + it('summarizes every selected dynamic option without dropping ids', async () => { + const fetchOptionById = vi.fn(async (_blockId: string, optionId: string) => ({ + id: optionId, + label: optionId === 'gmail' ? 'Gmail' : 'Slack', + })) + const subBlock = { + id: 'providerFilter', + title: 'Provider', + type: 'dropdown', + options: [], + multiSelect: true, + fetchOptionById, + } satisfies SubBlockConfig + + const hook = renderHookWithClient(() => + useDynamicSubBlockOptionDisplayName({ + workspaceId: 'workspace-1', + blockId: 'block-1', + subBlock, + value: ['gmail', 'slack'], + }) + ) + mounted.push(hook.unmount) + + await waitForResult(() => expect(hook.result()).toBe('Gmail, Slack')) + }) +}) diff --git a/apps/sim/hooks/queries/dynamic-subblock-options.ts b/apps/sim/hooks/queries/dynamic-subblock-options.ts new file mode 100644 index 00000000000..16f50005a8c --- /dev/null +++ b/apps/sim/hooks/queries/dynamic-subblock-options.ts @@ -0,0 +1,71 @@ +import { useMemo } from 'react' +import { useQueries } from '@tanstack/react-query' +import { summarizeNames } from '@/lib/workflows/subblocks/display' +import type { SubBlockConfig } from '@/blocks/types' + +export const DYNAMIC_SUBBLOCK_OPTION_STALE_TIME = 30 * 1000 + +export const dynamicSubBlockOptionKeys = { + all: ['dynamic-subblock-options'] as const, + details: () => [...dynamicSubBlockOptionKeys.all, 'detail'] as const, + detail: (workspaceId?: string, blockId?: string, subBlockId?: string, optionId?: string) => + [ + ...dynamicSubBlockOptionKeys.details(), + workspaceId ?? '', + blockId ?? '', + subBlockId ?? '', + optionId ?? '', + ] as const, +} + +interface UseDynamicSubBlockOptionDisplayNameArgs { + workspaceId?: string + blockId?: string + subBlock?: SubBlockConfig + value: unknown +} + +function getResolvableOptionIds(value: unknown): string[] { + const values = typeof value === 'string' ? [value] : Array.isArray(value) ? value : [] + return values.filter( + (entry): entry is string => + typeof entry === 'string' && + entry.length > 0 && + !entry.startsWith('<') && + !entry.includes('{{') + ) +} + +/** Resolves labels for dropdown options whose choices are loaded dynamically. */ +export function useDynamicSubBlockOptionDisplayName({ + workspaceId, + blockId, + subBlock, + value, +}: UseDynamicSubBlockOptionDisplayNameArgs): string | null { + const optionIds = useMemo(() => getResolvableOptionIds(value), [value]) + const fetchOptionById = subBlock?.fetchOptionById + const canResolve = Boolean(blockId && fetchOptionById && optionIds.length > 0) + + const queries = useQueries({ + queries: canResolve + ? optionIds.map((optionId) => ({ + queryKey: dynamicSubBlockOptionKeys.detail(workspaceId, blockId, subBlock?.id, optionId), + queryFn: ({ signal }) => { + if (!blockId || !fetchOptionById) { + throw new Error('Dynamic subblock option resolver is required') + } + return fetchOptionById(blockId, optionId, signal) + }, + staleTime: DYNAMIC_SUBBLOCK_OPTION_STALE_TIME, + })) + : [], + }) + + return useMemo(() => { + if (!canResolve || queries.length !== optionIds.length) return null + const labels = queries.map((query) => query.data?.label) + if (!labels.every((label): label is string => Boolean(label))) return null + return summarizeNames(labels) + }, [canResolve, optionIds.length, queries]) +} diff --git a/packages/emcn/src/components/chip-tag/chip-tag.tsx b/packages/emcn/src/components/chip-tag/chip-tag.tsx index 479a27d6132..cd64a5405ae 100644 --- a/packages/emcn/src/components/chip-tag/chip-tag.tsx +++ b/packages/emcn/src/components/chip-tag/chip-tag.tsx @@ -84,6 +84,7 @@ const chipTagVariants = cva( green: '', yellow: '', purple: '', + identity: '', content: '', }, brandForeground: { @@ -113,6 +114,7 @@ const chipTagVariants = cva( { variant: 'workflow', tone: 'green', className: 'bg-[#188F00] text-[#F8F8F8]' }, { variant: 'workflow', tone: 'yellow', className: 'bg-[#FFEF08] text-[#1A1A1A]' }, { variant: 'workflow', tone: 'purple', className: 'bg-[#AA00FF] text-[#F8F8F8]' }, + { variant: 'workflow', tone: 'identity', className: 'bg-[#8B5CF6] text-[#F8F8F8]' }, { variant: 'workflow', tone: 'content', className: 'bg-[#007E80] text-[#FFFFFF]' }, { variant: 'brand', brandForeground: 'light', className: 'text-[#FFFFFF]' }, { variant: 'brand', brandForeground: 'dark', className: 'text-[#000000]' }, diff --git a/packages/workflow-renderer/src/workflow-block/workflow-block-view-interaction.test.tsx b/packages/workflow-renderer/src/workflow-block/workflow-block-view-interaction.test.tsx index 6a4347ae993..d3d3ca40467 100644 --- a/packages/workflow-renderer/src/workflow-block/workflow-block-view-interaction.test.tsx +++ b/packages/workflow-renderer/src/workflow-block/workflow-block-view-interaction.test.tsx @@ -247,7 +247,7 @@ describe('WorkflowTypeTag colors', () => { expect(getWorkflowTypeAccent('condition')).toEqual({ variant: 'workflow', tone: 'orange' }) expect(getWorkflowTypeAccent('credential_group')).toEqual({ variant: 'workflow', - tone: 'purple', + tone: 'identity', }) expect(getWorkflowTypeAccent('image_generator_v2')).toEqual({ variant: 'workflow', @@ -277,6 +277,7 @@ describe('WorkflowTypeTag colors', () => { <> + ) ) @@ -289,6 +290,34 @@ describe('WorkflowTypeTag colors', () => { 'bg-[#AA00FF]', 'text-[#F8F8F8]' ) + expect(host.querySelector('[data-workflow-type-icon="credential_group"]')).toHaveClass( + 'bg-[#8B5CF6]', + 'text-[#F8F8F8]' + ) + }) + + it('renders the Credential Groups header tag with its purple identity fill', () => { + const host = document.createElement('div') + document.body.appendChild(host) + const root = createRoot(host) + mountedRoots.add(root) + mountedHosts.add(host) + + act(() => + root.render( + + ) + ) + + expect(host.querySelector('[data-workflow-type-accent="credential_group"]')).toHaveClass( + 'bg-[#8B5CF6]', + 'text-[#F8F8F8]' + ) }) it('uses the provider background with a contrasting shared icon and label color', () => { diff --git a/packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx b/packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx index 4a83665add1..1a4dcae994d 100644 --- a/packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx +++ b/packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx @@ -105,7 +105,7 @@ const WORKFLOW_ROLE_ACCENTS = { state: { variant: 'workflow', tone: 'yellow' }, flow: { variant: 'workflow', tone: 'ash' }, records: { variant: 'workflow', tone: 'green' }, - identity: { variant: 'workflow', tone: 'purple' }, + identity: { variant: 'workflow', tone: 'identity' }, neutral: { variant: 'workflow', tone: 'neutral' }, generative: { variant: 'workflow', tone: 'purple' }, knowledge: { variant: 'workflow', tone: 'content' },