Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -155,3 +155,87 @@ describe('parseSpecialTags with <question>', () => {
])
})
})

describe('service_account credential tag', () => {
it('parses a service_account tag into a credential segment', () => {
const body = JSON.stringify({ type: 'service_account', provider: 'slack' })
const { segments } = parseSpecialTags(`Set this up: <credential>${body}</credential>`, false)

const credential = segments.find((segment) => segment.type === 'credential')
expect(credential).toBeDefined()
expect(credential).toMatchObject({
type: 'credential',
data: { type: 'service_account', provider: 'slack' },
})
})

it('carries no value — the secret is typed into Sim’s own form, never the transcript', () => {
const body = JSON.stringify({ type: 'service_account', provider: 'google-sheets' })
const { segments } = parseSpecialTags(`<credential>${body}</credential>`, false)

const credential = segments.find((segment) => segment.type === 'credential')
expect((credential as { data: { value?: string } }).data.value).toBeUndefined()
})

it('suppresses the tag while it is still streaming', () => {
// A half-streamed tag must not flash raw JSON into the message body.
const { segments, hasPendingTag } = parseSpecialTags(
'Set this up: <credential>{"type": "service_a',
true
)
expect(hasPendingTag).toBe(true)
expect(segments.some((segment) => segment.type === 'credential')).toBe(false)
const text = segments
.filter((segment): segment is { type: 'text'; content: string } => segment.type === 'text')
.map((segment) => segment.content)
.join('')
expect(text).not.toContain('service_a')
})
})

describe('service_account tag validation', () => {
it('rejects a provider-less tag, which would render an unresolvable control', () => {
const { segments } = parseSpecialTags(
`<credential>${JSON.stringify({ type: 'service_account' })}</credential>`,
false
)
expect(segments.some((segment) => segment.type === 'credential')).toBe(false)
})

it('rejects a blank provider', () => {
const { segments } = parseSpecialTags(
`<credential>${JSON.stringify({ type: 'service_account', provider: ' ' })}</credential>`,
false
)
expect(segments.some((segment) => segment.type === 'credential')).toBe(false)
})

it('accepts an optional credentialId for reconnect and carries it through', () => {
const body = JSON.stringify({
type: 'service_account',
provider: 'notion',
credentialId: 'cred_abc123',
})
const { segments } = parseSpecialTags(`<credential>${body}</credential>`, false)
const credential = segments.find((segment) => segment.type === 'credential')
expect(credential).toMatchObject({
type: 'credential',
data: { type: 'service_account', provider: 'notion', credentialId: 'cred_abc123' },
})
})

it('rejects a non-string credentialId', () => {
const body = JSON.stringify({ type: 'service_account', provider: 'notion', credentialId: 42 })
const { segments } = parseSpecialTags(`<credential>${body}</credential>`, false)
expect(segments.some((segment) => segment.type === 'credential')).toBe(false)
})

it.each(['', ' '])(
'rejects a blank credentialId (%j) so reconnect cannot target a missing credential',
(credentialId) => {
const body = JSON.stringify({ type: 'service_account', provider: 'notion', credentialId })
const { segments } = parseSpecialTags(`<credential>${body}</credential>`, false)
expect(segments.some((segment) => segment.type === 'credential')).toBe(false)
}
)
})
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

import { createElement, useMemo, useState } from 'react'
import { createElement, lazy, Suspense, useMemo, useState } from 'react'
import {
ArrowRight,
Button,
Expand All @@ -19,6 +19,10 @@ import { useSession } from '@/lib/auth/auth-client'
import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions'
import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils'
import { isSafeHttpUrl } from '@/lib/core/utils/urls'
import {
resolveOAuthServiceForSlug,
resolveServiceAccountIntegration,
} from '@/lib/integrations/oauth-service'
import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth'
import { getServiceConfigByProviderId } from '@/lib/oauth/utils'
import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon'
Expand All @@ -27,6 +31,10 @@ import type {
ChatMessageContext,
MothershipResource,
} from '@/app/workspace/[workspaceId]/home/types'
// Deep import, not the barrel: the barrel also re-exports
// ConnectServiceAccountModal, and that edge would pull the modal into this
// chunk and defeat the lazy() split below.
import { useServiceAccountConnectTarget } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/use-service-account-connect'
import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useWorkspaceCredential } from '@/hooks/queries/credentials'
Expand Down Expand Up @@ -62,13 +70,25 @@ export interface UsageUpgradeTagData {
message: string
}

/**
* Kept out of the chat's initial chunk — it pulls in three provider-specific
* setup forms and is only mounted once a message actually offers a service
* account.
*/
const ConnectServiceAccountModal = lazy(() =>
import(
'@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal'
).then((m) => ({ default: m.ConnectServiceAccountModal }))
)

export const CREDENTIAL_TAG_TYPES = [
'env_key',
'oauth_key',
'sim_key',
'credential_id',
'link',
'secret_input',
'service_account',
] as const

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

export interface MothershipErrorTagData {
Expand Down Expand Up @@ -225,6 +250,20 @@ function isCredentialTagData(value: unknown): value is CredentialTagData {
}
return typeof value.name === 'string' && value.name.trim().length > 0
}
// A service_account tag is a control, not a value: it names the provider
// whose setup form to open, and the user types the secret into that form —
// so it never carries a `value`, but it is useless without a provider. An
// optional `credentialId` reconnects an existing service account in place;
// reject a blank one, since the renderer treats a truthy id as "reconnect"
// and would try to rotate a non-existent credential.
if (value.type === 'service_account') {
if (value.credentialId !== undefined) {
if (typeof value.credentialId !== 'string' || value.credentialId.trim().length === 0) {
return false
}
}
return typeof value.provider === 'string' && value.provider.trim().length > 0
Comment thread
cursor[bot] marked this conversation as resolved.
}
// A sim_key chip is platform-filled: the model only marks where the workspace
// API key belongs (it never holds the value) and Sim injects it from the tool
// result, so the tag is valid with or without a `value`. Every other rendered
Expand Down Expand Up @@ -870,6 +909,74 @@ function SecretInputDisplay({ data }: { data: CredentialTagData }) {
)
}

/**
* Inline "set up a service account" control rendered for
* `<credential>{"type":"service_account","provider":"slack"}</credential>`.
*
* Opens `ConnectServiceAccountModal` over the chat rather than linking out to
* the integrations page — the user stays in the conversation that asked for
* the credential, and comes back to it with the credential in hand.
*/
function ServiceAccountConnectDisplay({ data }: { data: CredentialTagData }) {
const { workspaceId } = useParams<{ workspaceId: string }>()
const { canEdit } = useUserPermissionsContext()
const [open, setOpen] = useState(false)

const match = useMemo(
() => (data.provider ? resolveServiceAccountIntegration(data.provider) : null),
[data.provider]
)
const service = useMemo(() => (match ? resolveOAuthServiceForSlug(match.slug) : null), [match])
const target = useServiceAccountConnectTarget({
serviceAccountProviderId: match?.serviceAccountProviderId,
serviceName: match?.serviceName,
serviceIcon: service?.serviceIcon,
})

// A credentialId reconnects (rotates the secret on) that existing service
// account in place rather than creating a new one — the modal keeps its id.
const reconnectCredentialId = data.credentialId
const { data: reconnectCredential } = useWorkspaceCredential(reconnectCredentialId)

// Creating a credential mutates the workspace — hide it from read-only
// members, and honour the provider's own preview gate (custom Slack bots
// ride the slack_v2 flag) so chat can't surface what the integrations page
// deliberately hides.
if (!target || target.hidden || !canEdit || !workspaceId) return null

const label = reconnectCredentialId
? `Reconnect ${reconnectCredential?.displayName ?? target.serviceName}`
: `${target.label} for ${target.serviceName}`

return (
<>
<button
type='button'
onClick={() => setOpen(true)}
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)]'
>
{createElement(target.serviceIcon, { className: 'size-[16px] shrink-0' })}
<span className='flex-1 text-[var(--text-body)] text-sm'>{label}</span>
<ArrowRight className='size-[16px] shrink-0 text-[var(--text-icon)]' />
</button>
{open && (
<Suspense fallback={null}>
<ConnectServiceAccountModal
open={open}
onOpenChange={setOpen}
workspaceId={workspaceId}
serviceAccountProviderId={target.serviceAccountProviderId}
serviceName={target.serviceName}
serviceIcon={target.serviceIcon}
credentialId={reconnectCredentialId}
credentialDisplayName={reconnectCredential?.displayName ?? undefined}
/>
</Suspense>
)}
</>
)
}

function CredentialLinkDisplay({ data }: { data: CredentialTagData }) {
const { canEdit } = useUserPermissionsContext()

Expand Down Expand Up @@ -921,6 +1028,10 @@ function CredentialDisplay({ data }: { data: CredentialTagData }) {
return <CredentialLinkDisplay data={data} />
}

if (data.type === 'service_account') {
return <ServiceAccountConnectDisplay data={data} />
}

if (data.type === 'sim_key') {
// SecretReveal masks itself when there's no value, so a value-less tag (the
// model's placeholder / persisted form) renders masked and a Sim-filled tag
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,33 +6,30 @@ import { ArrowLeft, ArrowRight, Plus } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useQueryState } from 'nuqs'
import { getClientCredentialAccountDescriptor } from '@/lib/credentials/client-credential-accounts/descriptors'
import { getTokenServiceAccountDescriptor } from '@/lib/credentials/token-service-accounts/descriptors'
import {
blockTypeToIconMap,
type Integration,
resolveOAuthServiceForIntegration,
} from '@/lib/integrations'
import { getServiceConfigByProviderId } from '@/lib/oauth'
import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types'
import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal'
import { IntegrationSkillsSection } from '@/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section'
import { connectParam } from '@/app/workspace/[workspaceId]/integrations/[block]/search-params'
import { ConnectServiceAccountModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal'
import {
ConnectServiceAccountModal,
useServiceAccountConnectTarget,
} from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal'
import { IntegrationSection } from '@/app/workspace/[workspaceId]/integrations/components/integration-section'
import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
import { CONNECT_MODE } from '@/app/workspace/[workspaceId]/integrations/connect-route'
import { useScrollRestoration } from '@/app/workspace/[workspaceId]/integrations/hooks/use-scroll-restoration'
import { getBlock } from '@/blocks'
import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay'
import { getTileIconColorClass } from '@/blocks/icon-color'
import { storeCuratedPrompt } from '@/blocks/integration-matcher'
import {
getSuggestedSkillsForBlock,
getTemplatesForBlock,
type ScopedBlockTemplate,
} from '@/blocks/registry'
import { isHiddenUnder, overlayVisibility } from '@/blocks/visibility/context'
import { useWorkspaceCredentials } from '@/hooks/queries/credentials'
import { useOAuthReturnRouter } from '@/hooks/use-oauth-return'

Expand Down Expand Up @@ -78,29 +75,13 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
)
}, [credentials, oauthService])
const [serviceAccountOpen, setServiceAccountOpen] = useState(false)
const isSlackBot = oauthService?.serviceAccountProviderId === SLACK_CUSTOM_BOT_PROVIDER_ID
const blockOverlayVersion = useCustomBlockOverlayVersion()
// Custom Slack bots ride the slack_v2 preview flag: the setup surface stays
// hidden until that block is revealed for this viewer.
const slackBotPreviewHidden = useMemo(() => {
if (!isSlackBot) return false
const v2 = getBlock('slack_v2')
return !v2 || isHiddenUnder(overlayVisibility(), v2)
}, [isSlackBot, blockOverlayVersion])
const hasServiceAccount =
Boolean(oauthService?.serviceAccountProviderId) && !slackBotPreviewHidden
// Vendor-accurate connect label: token-paste and client-credential
// providers use their own noun ("Add API key", "Add server-to-server app");
// only true service-account providers (Google, Atlassian) say
// "Add service account".
const nounDescriptor =
getTokenServiceAccountDescriptor(oauthService?.serviceAccountProviderId) ??
getClientCredentialAccountDescriptor(oauthService?.serviceAccountProviderId)
const serviceAccountConnectLabel = isSlackBot
? 'Set up a custom bot'
: nounDescriptor
? `Add ${nounDescriptor.connectNoun}`
: 'Add service account'
const serviceAccountTarget = useServiceAccountConnectTarget({
serviceAccountProviderId: oauthService?.serviceAccountProviderId,
serviceName: oauthService?.serviceName,
serviceIcon: oauthService?.serviceIcon,
})
const hasServiceAccount = Boolean(serviceAccountTarget) && !serviceAccountTarget?.hidden
const serviceAccountConnectLabel = serviceAccountTarget?.label ?? 'Add service account'
const hasHandledConnectQueryRef = useRef(false)

useEffect(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,7 @@ export {
ConnectServiceAccountModal,
type ServiceAccountProviderId,
} from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal'
export {
type ServiceAccountConnectTarget,
useServiceAccountConnectTarget,
} from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/use-service-account-connect'
Loading
Loading