Skip to content

Commit ae7b5ef

Browse files
feat(copilot): service account setup & reconnect in chat (#5786)
* feat(copilot): add service_account_get_setup_link handler Resolves a loosely-specified integration name to the catalog slug whose detail page mounts ConnectServiceAccountModal, and returns `/integrations/{slug}?connect=service-account`. The agent surfaces it via the existing <credential type="link"> tag, so the user gets a Connect button and supplies the key material in Sim's own form — the agent never handles the secret. Exact matches beat fuzzy ones so a caller naming a specific service lands on it (gmail stays Gmail rather than collapsing to Drive), and family names resolve through an explicit canonical map rather than to whichever member sorts first. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Phx1MLjf8Ui3M3VpwisZds * fix(copilot): reject service account ids in oauth_get_auth_link The fuzzy provider match falls back to substring containment, so `slack-custom-bot` contains `slack` and resolved to the Slack OAuth service. The tool then returned a personal-OAuth authorize URL and reported success — a user who asked for a shared custom bot got a Connect button that linked their own account instead. Every service account id degraded this way (notion-, salesforce-, zoom-, linear-), always silently. Guard runs before the fuzzy pass and points at service_account_get_setup_link. Keys off the id being a service-account id, not off the integration offering one, so `slack` and `notion` still resolve for OAuth. Moves the narrowing predicate out of the integration catalog module so callers that need only the predicate skip the integrations.json load and the OAUTH_PROVIDERS walk. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Phx1MLjf8Ui3M3VpwisZds * feat(copilot): open the service account form in-chat instead of linking out The tool handed back a /integrations/{slug}?connect=service-account URL, so accepting the agent's offer navigated away from the conversation that asked for the credential. Adds a `service_account` credential tag that mounts ConnectServiceAccountModal over the chat; setup_url stays as the headless/MCP fallback. The tag carries a provider and no value — the secret is typed into Sim's own form and never enters the transcript — so the validator gets a branch alongside secret_input/sim_key rather than falling through to the value-required check. Extracts useServiceAccountConnectTarget so the chat and the integrations page share one source of truth for the connect label and the preview gate. Custom Slack bots ride the slack_v2 flag; without the shared gate the chat would have surfaced a setup form the integrations page hides. Modal is lazy-loaded off the deep path (not the barrel) to keep three provider-specific setup forms out of the chat's initial chunk. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Phx1MLjf8Ui3M3VpwisZds * fix(copilot): gate service account tool on the same preview flag as the UI The in-chat connect button hides itself when the provider's gating block is preview-hidden (a custom Slack bot needs slack_v2). The tool didn't check this, so it returned success for slack-custom-bot even when slack_v2 was preview-gated off — the agent said "here's the setup form" and the button silently rendered nothing, leaving the user with no form at all. Adds getServiceAccountGatingBlockType as the single source for the provider→gating-block mapping, consumed by both the tool (server-side, via getBlockVisibilityForCopilot) and the connect hook (client overlay). When the gating block is hidden the tool now fails with a fall-back-to-OAuth message instead of promising an invisible form. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Phx1MLjf8Ui3M3VpwisZds * feat(copilot): make the tool own service-account discovery Removes the VFS auth-metadata exposure and returns connectNoun from the service_account_get_setup_link result instead. The VFS aggregate was a second, viewer-independent source of truth that couldn't agree with the per-viewer preview gate (it always hid slack-custom-bot, even for viewers with slack_v2 revealed, while the tool accepts it for them). The tool now resolves the provider, applies the per-viewer gate, and returns either the in-chat button + connectNoun or a fall-back-to-oauth error — one source of truth. connectNoun stays DRY via getServiceAccountConnectNoun, shared with the connect-button label. * feat(copilot): make service-account setup a direct tag, no tool The agent now emits the service_account credential tag directly from intent — like secret_input — instead of round-tripping through a tool. Removes service_account_get_setup_link (handler, registration, display title, Go tool def) and restores auth.serviceAccount as the VFS discovery field so the agent knows which providers support a service account. The link-vs-tag distinction was the wrong axis: only oauth needs a tool, because its button carries a minted URL that can't be reconstructed. The service_account tag carries just a provider name the agent already knows, so it needs no tool — discovery lives in the VFS (auth.serviceAccount, GA-only, so slack's preview-gated custom bot is never proactively offered), and the per-viewer gate lives in the renderer, which renders nothing when a provider isn't available for the viewer (no OAuth fallback — a shared credential and a personal one are different intents). oauth_get_auth_link's service-account-id guard now points at the tag. * feat(copilot): support service-account reconnect from chat Reconnect had no service-account path — it required oauth_get_auth_link and a link tag for every repair, so rotating a workspace service account either errored or pushed the user through OAuth. The service_account tag now takes an optional credentialId; when present the renderer opens the modal in reconnect mode (rotates the secret on that credential in place, id preserved) and labels the button "Reconnect X". credentials.json now carries each credential's type (oauth vs service_account) so the agent can branch: service accounts reconnect via the tag + credentialId, oauth via oauth_get_auth_link as before. * fix(copilot): coherent service-account rejection in oauth_get_auth_link Review round on #5786: - The service-account-id guard threw into the generic catch, which overwrote its recovery hint with a "connect manually" message and a workspace oauth_url — contradictory signals. It now returns a coherent failure directly, before the try, with no oauth_url. - Normalize spaces/underscores before the check so a readable form ("slack custom bot", "google service account") is caught too, not passed to the fuzzy OAuth resolver. - Remove listServiceAccountIntegrationNames — dead after the tool was removed (its only caller was the deleted handler's error copy). * fix(copilot): service-account discovery must un-gate after the block GAs Review round on #5786: describeServiceAccountForOAuthProvider used `getBlock(...)?.preview ?? true`, which treats a GA'd gating block — one that dropped its `preview` flag, exactly slack_v2's documented migration — as still gated, so the custom bot would stay omitted from VFS discovery forever after GA even though the UI shows it. Reuse the canonical isHiddenUnder(null, block) predicate instead, so a non-preview block is visible. Adds service-account-gate.test.ts covering preview → omit, GA → include, and missing → fail-closed with a mocked getBlock (the block registry is globally stubbed, so the real slack_v2 preview flag isn't observable through serializeIntegrationSchema). * fix(copilot): align SA resolver normalization and reject blank credentialId Review round on #5786: - resolveServiceAccountIntegration only lowercased/trimmed, but the oauth_get_auth_link guard normalizes spaces/underscores to hyphens before rejecting a service-account id and steering the agent to a service_account tag. The chat renderer then couldn't resolve those same readable forms ("slack custom bot", "notion_service_account") and rendered nothing. Apply the same normalization to the id lookups (raw query still used for display-name matches). - service_account tag validation rejected a blank provider but allowed a whitespace-only credentialId, which is truthy — the renderer took the reconnect path and tried to rotate a non-existent credential. Reject a blank/whitespace credentialId. * refactor(credentials): route the editor SA picker through the canonical connect hook The workflow-editor credential selector (from #5800's merged picker) resolved its service-account setup surface inline and mounted the modal with NO preview gate — so a `credentialKind: 'service-account'` picker would offer a custom-bot setup even when slack_v2 is preview-gated off, the leak the integrations page and chat already guard against. Route it through the shared useServiceAccountConnectTarget hook (the same resolver chat and the integrations page use): suppress the setup action when `hidden`, and use the hook's vendor-accurate label ("Add private app token", "Set up a custom bot") as the default connect-row copy. Existing service accounts stay selectable; the per-block `credentialLabels.serviceAccountConnect` override still wins. One resolver now backs all three SA connect surfaces. * docs(add-block): document credentialKind and the service-account picker The add-block skill had no mention of credentialKind — the mechanism (#5800) that controls whether an oauth-input offers OAuth, service-account, or a merged picker — and its example was a plain oauth-input mislabeled "Service Account". Documents the three credentialKind modes, that a default oauth-input already lets users select an existing service account (they fold in), and the credentialLabels / allowServiceAccounts companions. Regenerates the .claude and .cursor projections. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6f33a94 commit ae7b5ef

20 files changed

Lines changed: 975 additions & 76 deletions

File tree

.agents/skills/add-block/SKILL.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,25 @@ export const {ServiceName}Block: BlockConfig = {
144144

145145
**Scope descriptions:** When adding a new OAuth provider, also add human-readable descriptions for all scopes in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`.
146146

147+
**Service accounts (shared, app-level credentials):** A plain `oauth-input` already lets users *select* an existing service account — those credentials fold into the picker automatically (a Google service account created for any Google service appears in every Google block's picker). You only set `credentialKind` when you want to change the *connect* action:
148+
149+
```typescript
150+
{
151+
id: 'credential',
152+
title: 'Account',
153+
type: 'oauth-input',
154+
serviceId: '{service}',
155+
requiredScopes: getScopesForService('{service}'),
156+
credentialKind: 'any', // omit | 'service-account' | 'any'
157+
}
158+
```
159+
160+
- **omit (default):** lists OAuth accounts + any existing service accounts; the only connect action is "Connect account" (OAuth). Use this for the common "let users pick a service account someone set up elsewhere, but don't offer inline setup" case — no config needed.
161+
- **`'service-account'`:** service-account credentials *only*, plus an inline setup action that opens the provider's connect modal. Use when a block accepts *only* an app credential.
162+
- **`'any'`:** merged picker — OAuth accounts *and* service accounts in one grouped dropdown, with a connect action for each. Use when a block supports both (e.g. Slack: a personal account *or* a custom bot).
163+
164+
Optional companions: `credentialLabels` (override the picker's section/connect-row copy) and `allowServiceAccounts: true` (trigger-mode only — list service accounts, which triggers otherwise exclude; set only when the trigger's polling path can resolve a service-account token). The connect modal, provider families (Google JSON key, Atlassian token, token-paste, client-credential, Slack bot), and the preview gate are all resolved from `serviceAccountProviderId` — you don't wire them per block.
165+
147166
### Selectors (with dynamic options)
148167
```typescript
149168
// Channel selector (Slack, Discord, etc.)

.claude/commands/add-block.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,25 @@ export const {ServiceName}Block: BlockConfig = {
143143

144144
**Scope descriptions:** When adding a new OAuth provider, also add human-readable descriptions for all scopes in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`.
145145

146+
**Service accounts (shared, app-level credentials):** A plain `oauth-input` already lets users *select* an existing service account — those credentials fold into the picker automatically (a Google service account created for any Google service appears in every Google block's picker). You only set `credentialKind` when you want to change the *connect* action:
147+
148+
```typescript
149+
{
150+
id: 'credential',
151+
title: 'Account',
152+
type: 'oauth-input',
153+
serviceId: '{service}',
154+
requiredScopes: getScopesForService('{service}'),
155+
credentialKind: 'any', // omit | 'service-account' | 'any'
156+
}
157+
```
158+
159+
- **omit (default):** lists OAuth accounts + any existing service accounts; the only connect action is "Connect account" (OAuth). Use this for the common "let users pick a service account someone set up elsewhere, but don't offer inline setup" case — no config needed.
160+
- **`'service-account'`:** service-account credentials *only*, plus an inline setup action that opens the provider's connect modal. Use when a block accepts *only* an app credential.
161+
- **`'any'`:** merged picker — OAuth accounts *and* service accounts in one grouped dropdown, with a connect action for each. Use when a block supports both (e.g. Slack: a personal account *or* a custom bot).
162+
163+
Optional companions: `credentialLabels` (override the picker's section/connect-row copy) and `allowServiceAccounts: true` (trigger-mode only — list service accounts, which triggers otherwise exclude; set only when the trigger's polling path can resolve a service-account token). The connect modal, provider families (Google JSON key, Atlassian token, token-paste, client-credential, Slack bot), and the preview gate are all resolved from `serviceAccountProviderId` — you don't wire them per block.
164+
146165
### Selectors (with dynamic options)
147166
```typescript
148167
// Channel selector (Slack, Discord, etc.)

.cursor/commands/add-block.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,25 @@ export const {ServiceName}Block: BlockConfig = {
138138

139139
**Scope descriptions:** When adding a new OAuth provider, also add human-readable descriptions for all scopes in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`.
140140

141+
**Service accounts (shared, app-level credentials):** A plain `oauth-input` already lets users *select* an existing service account — those credentials fold into the picker automatically (a Google service account created for any Google service appears in every Google block's picker). You only set `credentialKind` when you want to change the *connect* action:
142+
143+
```typescript
144+
{
145+
id: 'credential',
146+
title: 'Account',
147+
type: 'oauth-input',
148+
serviceId: '{service}',
149+
requiredScopes: getScopesForService('{service}'),
150+
credentialKind: 'any', // omit | 'service-account' | 'any'
151+
}
152+
```
153+
154+
- **omit (default):** lists OAuth accounts + any existing service accounts; the only connect action is "Connect account" (OAuth). Use this for the common "let users pick a service account someone set up elsewhere, but don't offer inline setup" case — no config needed.
155+
- **`'service-account'`:** service-account credentials *only*, plus an inline setup action that opens the provider's connect modal. Use when a block accepts *only* an app credential.
156+
- **`'any'`:** merged picker — OAuth accounts *and* service accounts in one grouped dropdown, with a connect action for each. Use when a block supports both (e.g. Slack: a personal account *or* a custom bot).
157+
158+
Optional companions: `credentialLabels` (override the picker's section/connect-row copy) and `allowServiceAccounts: true` (trigger-mode only — list service accounts, which triggers otherwise exclude; set only when the trigger's polling path can resolve a service-account token). The connect modal, provider families (Google JSON key, Atlassian token, token-paste, client-credential, Slack bot), and the preview gate are all resolved from `serviceAccountProviderId` — you don't wire them per block.
159+
141160
### Selectors (with dynamic options)
142161
```typescript
143162
// Channel selector (Slack, Discord, etc.)

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,3 +167,87 @@ describe('parseSpecialTags with <question>', () => {
167167
])
168168
})
169169
})
170+
171+
describe('service_account credential tag', () => {
172+
it('parses a service_account tag into a credential segment', () => {
173+
const body = JSON.stringify({ type: 'service_account', provider: 'slack' })
174+
const { segments } = parseSpecialTags(`Set this up: <credential>${body}</credential>`, false)
175+
176+
const credential = segments.find((segment) => segment.type === 'credential')
177+
expect(credential).toBeDefined()
178+
expect(credential).toMatchObject({
179+
type: 'credential',
180+
data: { type: 'service_account', provider: 'slack' },
181+
})
182+
})
183+
184+
it('carries no value — the secret is typed into Sim’s own form, never the transcript', () => {
185+
const body = JSON.stringify({ type: 'service_account', provider: 'google-sheets' })
186+
const { segments } = parseSpecialTags(`<credential>${body}</credential>`, false)
187+
188+
const credential = segments.find((segment) => segment.type === 'credential')
189+
expect((credential as { data: { value?: string } }).data.value).toBeUndefined()
190+
})
191+
192+
it('suppresses the tag while it is still streaming', () => {
193+
// A half-streamed tag must not flash raw JSON into the message body.
194+
const { segments, hasPendingTag } = parseSpecialTags(
195+
'Set this up: <credential>{"type": "service_a',
196+
true
197+
)
198+
expect(hasPendingTag).toBe(true)
199+
expect(segments.some((segment) => segment.type === 'credential')).toBe(false)
200+
const text = segments
201+
.filter((segment): segment is { type: 'text'; content: string } => segment.type === 'text')
202+
.map((segment) => segment.content)
203+
.join('')
204+
expect(text).not.toContain('service_a')
205+
})
206+
})
207+
208+
describe('service_account tag validation', () => {
209+
it('rejects a provider-less tag, which would render an unresolvable control', () => {
210+
const { segments } = parseSpecialTags(
211+
`<credential>${JSON.stringify({ type: 'service_account' })}</credential>`,
212+
false
213+
)
214+
expect(segments.some((segment) => segment.type === 'credential')).toBe(false)
215+
})
216+
217+
it('rejects a blank provider', () => {
218+
const { segments } = parseSpecialTags(
219+
`<credential>${JSON.stringify({ type: 'service_account', provider: ' ' })}</credential>`,
220+
false
221+
)
222+
expect(segments.some((segment) => segment.type === 'credential')).toBe(false)
223+
})
224+
225+
it('accepts an optional credentialId for reconnect and carries it through', () => {
226+
const body = JSON.stringify({
227+
type: 'service_account',
228+
provider: 'notion',
229+
credentialId: 'cred_abc123',
230+
})
231+
const { segments } = parseSpecialTags(`<credential>${body}</credential>`, false)
232+
const credential = segments.find((segment) => segment.type === 'credential')
233+
expect(credential).toMatchObject({
234+
type: 'credential',
235+
data: { type: 'service_account', provider: 'notion', credentialId: 'cred_abc123' },
236+
})
237+
})
238+
239+
it('rejects a non-string credentialId', () => {
240+
const body = JSON.stringify({ type: 'service_account', provider: 'notion', credentialId: 42 })
241+
const { segments } = parseSpecialTags(`<credential>${body}</credential>`, false)
242+
expect(segments.some((segment) => segment.type === 'credential')).toBe(false)
243+
})
244+
245+
it.each(['', ' '])(
246+
'rejects a blank credentialId (%j) so reconnect cannot target a missing credential',
247+
(credentialId) => {
248+
const body = JSON.stringify({ type: 'service_account', provider: 'notion', credentialId })
249+
const { segments } = parseSpecialTags(`<credential>${body}</credential>`, false)
250+
expect(segments.some((segment) => segment.type === 'credential')).toBe(false)
251+
}
252+
)
253+
})

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { createElement, useMemo, useState } from 'react'
3+
import { createElement, lazy, Suspense, useMemo, useState } from 'react'
44
import {
55
ArrowRight,
66
Button,
@@ -19,6 +19,10 @@ import { useSession } from '@/lib/auth/auth-client'
1919
import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions'
2020
import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils'
2121
import { isSafeHttpUrl } from '@/lib/core/utils/urls'
22+
import {
23+
resolveOAuthServiceForSlug,
24+
resolveServiceAccountIntegration,
25+
} from '@/lib/integrations/oauth-service'
2226
import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth'
2327
import { getServiceConfigByProviderId } from '@/lib/oauth/utils'
2428
import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon'
@@ -27,6 +31,10 @@ import type {
2731
ChatMessageContext,
2832
MothershipResource,
2933
} from '@/app/workspace/[workspaceId]/home/types'
34+
// Deep import, not the barrel: the barrel also re-exports
35+
// ConnectServiceAccountModal, and that edge would pull the modal into this
36+
// chunk and defeat the lazy() split below.
37+
import { useServiceAccountConnectTarget } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/use-service-account-connect'
3038
import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
3139
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
3240
import { useWorkspaceCredential } from '@/hooks/queries/credentials'
@@ -62,13 +70,25 @@ export interface UsageUpgradeTagData {
6270
message: string
6371
}
6472

73+
/**
74+
* Kept out of the chat's initial chunk — it pulls in three provider-specific
75+
* setup forms and is only mounted once a message actually offers a service
76+
* account.
77+
*/
78+
const ConnectServiceAccountModal = lazy(() =>
79+
import(
80+
'@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal'
81+
).then((m) => ({ default: m.ConnectServiceAccountModal }))
82+
)
83+
6584
export const CREDENTIAL_TAG_TYPES = [
6685
'env_key',
6786
'oauth_key',
6887
'sim_key',
6988
'credential_id',
7089
'link',
7190
'secret_input',
91+
'service_account',
7292
] as const
7393

7494
export type CredentialTagType = (typeof CREDENTIAL_TAG_TYPES)[number]
@@ -88,6 +108,11 @@ export interface CredentialTagData {
88108
name?: string
89109
/** Where a secret_input value is persisted. Defaults to "workspace". */
90110
scope?: SecretInputScope
111+
/**
112+
* Existing credential to reconnect in place (service_account only). Present =
113+
* rotate the secret on this credential; absent = create a new one.
114+
*/
115+
credentialId?: string
91116
}
92117

93118
export interface MothershipErrorTagData {
@@ -225,6 +250,20 @@ function isCredentialTagData(value: unknown): value is CredentialTagData {
225250
}
226251
return typeof value.name === 'string' && value.name.trim().length > 0
227252
}
253+
// A service_account tag is a control, not a value: it names the provider
254+
// whose setup form to open, and the user types the secret into that form —
255+
// so it never carries a `value`, but it is useless without a provider. An
256+
// optional `credentialId` reconnects an existing service account in place;
257+
// reject a blank one, since the renderer treats a truthy id as "reconnect"
258+
// and would try to rotate a non-existent credential.
259+
if (value.type === 'service_account') {
260+
if (value.credentialId !== undefined) {
261+
if (typeof value.credentialId !== 'string' || value.credentialId.trim().length === 0) {
262+
return false
263+
}
264+
}
265+
return typeof value.provider === 'string' && value.provider.trim().length > 0
266+
}
228267
// A sim_key chip is platform-filled: the model only marks where the workspace
229268
// API key belongs (it never holds the value) and Sim injects it from the tool
230269
// result, so the tag is valid with or without a `value`. Every other rendered
@@ -870,6 +909,74 @@ function SecretInputDisplay({ data }: { data: CredentialTagData }) {
870909
)
871910
}
872911

912+
/**
913+
* Inline "set up a service account" control rendered for
914+
* `<credential>{"type":"service_account","provider":"slack"}</credential>`.
915+
*
916+
* Opens `ConnectServiceAccountModal` over the chat rather than linking out to
917+
* the integrations page — the user stays in the conversation that asked for
918+
* the credential, and comes back to it with the credential in hand.
919+
*/
920+
function ServiceAccountConnectDisplay({ data }: { data: CredentialTagData }) {
921+
const { workspaceId } = useParams<{ workspaceId: string }>()
922+
const { canEdit } = useUserPermissionsContext()
923+
const [open, setOpen] = useState(false)
924+
925+
const match = useMemo(
926+
() => (data.provider ? resolveServiceAccountIntegration(data.provider) : null),
927+
[data.provider]
928+
)
929+
const service = useMemo(() => (match ? resolveOAuthServiceForSlug(match.slug) : null), [match])
930+
const target = useServiceAccountConnectTarget({
931+
serviceAccountProviderId: match?.serviceAccountProviderId,
932+
serviceName: match?.serviceName,
933+
serviceIcon: service?.serviceIcon,
934+
})
935+
936+
// A credentialId reconnects (rotates the secret on) that existing service
937+
// account in place rather than creating a new one — the modal keeps its id.
938+
const reconnectCredentialId = data.credentialId
939+
const { data: reconnectCredential } = useWorkspaceCredential(reconnectCredentialId)
940+
941+
// Creating a credential mutates the workspace — hide it from read-only
942+
// members, and honour the provider's own preview gate (custom Slack bots
943+
// ride the slack_v2 flag) so chat can't surface what the integrations page
944+
// deliberately hides.
945+
if (!target || target.hidden || !canEdit || !workspaceId) return null
946+
947+
const label = reconnectCredentialId
948+
? `Reconnect ${reconnectCredential?.displayName ?? target.serviceName}`
949+
: `${target.label} for ${target.serviceName}`
950+
951+
return (
952+
<>
953+
<button
954+
type='button'
955+
onClick={() => setOpen(true)}
956+
className='flex w-full items-center gap-2 rounded-2xl border border-[var(--border-1)] px-3 py-2.5 text-left transition-colors hover-hover:bg-[var(--surface-5)]'
957+
>
958+
{createElement(target.serviceIcon, { className: 'size-[16px] shrink-0' })}
959+
<span className='flex-1 text-[var(--text-body)] text-sm'>{label}</span>
960+
<ArrowRight className='size-[16px] shrink-0 text-[var(--text-icon)]' />
961+
</button>
962+
{open && (
963+
<Suspense fallback={null}>
964+
<ConnectServiceAccountModal
965+
open={open}
966+
onOpenChange={setOpen}
967+
workspaceId={workspaceId}
968+
serviceAccountProviderId={target.serviceAccountProviderId}
969+
serviceName={target.serviceName}
970+
serviceIcon={target.serviceIcon}
971+
credentialId={reconnectCredentialId}
972+
credentialDisplayName={reconnectCredential?.displayName ?? undefined}
973+
/>
974+
</Suspense>
975+
)}
976+
</>
977+
)
978+
}
979+
873980
function CredentialLinkDisplay({ data }: { data: CredentialTagData }) {
874981
const { canEdit } = useUserPermissionsContext()
875982

@@ -921,6 +1028,10 @@ function CredentialDisplay({ data }: { data: CredentialTagData }) {
9211028
return <CredentialLinkDisplay data={data} />
9221029
}
9231030

1031+
if (data.type === 'service_account') {
1032+
return <ServiceAccountConnectDisplay data={data} />
1033+
}
1034+
9241035
if (data.type === 'sim_key') {
9251036
// SecretReveal masks itself when there's no value, so a value-less tag (the
9261037
// model's placeholder / persisted form) renders masked and a Sim-filled tag

0 commit comments

Comments
 (0)