Skip to content

Commit 8f687a3

Browse files
author
Vikhyath Mondreti
committed
feat(assistant): connect personal accounts in chat and reuse desktop tools
1 parent a6616d5 commit 8f687a3

83 files changed

Lines changed: 25759 additions & 453 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/sim/app/api/credential-groups/enrollment-redirect.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,22 @@ export function createCredentialGroupEnrollmentRedirect(
2020
})
2121
}
2222

23-
export function createCredentialGroupCompletionRedirect(): NextResponse {
23+
export type CredentialGroupOAuthFailure =
24+
| 'denied'
25+
| 'account_mismatch'
26+
| 'permissions_required'
27+
| 'configuration_changed'
28+
| 'rate_limited'
29+
| 'unavailable'
30+
| 'failed'
31+
32+
export function createCredentialGroupCompletionRedirect(
33+
oauth?: CredentialGroupOAuthFailure
34+
): NextResponse {
2435
return new NextResponse(null, {
2536
status: 303,
2637
headers: {
27-
Location: '/credential-groups/complete',
38+
Location: `/credential-groups/complete${oauth ? `?oauth=${oauth}` : ''}`,
2839
...NO_STORE_REDIRECT_HEADERS,
2940
},
3041
})

‎apps/sim/app/api/credential-groups/oauth-callback.ts‎

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,19 @@ import { getErrorMessage } from '@sim/utils/errors'
33
import type { NextRequest } from 'next/server'
44
import { NextResponse } from 'next/server'
55
import type { CredentialGroupOAuthCallbackQuery } from '@/lib/api/contracts/credential-groups'
6-
import { authenticateCredentialGroupEnrollment } from '@/lib/credential-groups/application/enrollment-auth'
6+
import { credentialGroupOAuthAttemptPrincipal } from '@/lib/credential-groups/application/enrollment-auth'
77
import { completePublicCredentialGroupOAuth } from '@/lib/credential-groups/application/public-enrollment'
88
import { consumeCredentialGroupOAuthAttempt } from '@/lib/credential-groups/oauth-state'
99
import {
1010
CredentialGroupInvitationUnavailableError,
1111
CredentialGroupOAuthError,
1212
} from '@/lib/credential-groups/provider-adapter'
1313
import type { CredentialGroupProvider } from '@/lib/credential-groups/providers'
14-
import { createCredentialGroupEnrollmentRedirect } from '@/app/api/credential-groups/enrollment-redirect'
14+
import {
15+
type CredentialGroupOAuthFailure,
16+
createCredentialGroupCompletionRedirect,
17+
createCredentialGroupEnrollmentRedirect,
18+
} from '@/app/api/credential-groups/enrollment-redirect'
1519

1620
const logger = createLogger('CredentialGroupOAuthCallbackAPI')
1721

@@ -49,34 +53,33 @@ export async function handleCredentialGroupOAuthCallback({
4953
{ status: 400, headers: { 'Cache-Control': 'no-store' } }
5054
)
5155
}
56+
const failureRedirect = (oauth: CredentialGroupOAuthFailure) =>
57+
attempt.completionRedirect
58+
? createCredentialGroupCompletionRedirect(oauth)
59+
: createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { oauth })
5260
if (limited) {
53-
return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, {
54-
oauth: 'rate_limited',
55-
})
61+
return failureRedirect('rate_limited')
5662
}
5763
if (providerError) {
58-
return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { oauth: 'denied' })
64+
return failureRedirect('denied')
5965
}
6066
if (!code) {
61-
return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { oauth: 'failed' })
67+
return failureRedirect('failed')
6268
}
6369

64-
const principal = await authenticateCredentialGroupEnrollment(attempt.invitationToken)
65-
if (!principal) {
66-
return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, {
67-
oauth: 'unavailable',
68-
})
69-
}
70+
const principal = credentialGroupOAuthAttemptPrincipal(attempt)
7071

7172
try {
7273
await completePublicCredentialGroupOAuth.execute({
7374
principal,
7475
input: { attempt, code },
7576
request,
7677
})
77-
return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, {
78-
connected: attempt.optionId,
79-
})
78+
return attempt.completionRedirect
79+
? createCredentialGroupCompletionRedirect()
80+
: createCredentialGroupEnrollmentRedirect(attempt.invitationToken, {
81+
connected: attempt.optionId,
82+
})
8083
} catch (error) {
8184
logger.error('Managed OAuth authorization failed', {
8285
provider,
@@ -92,6 +95,6 @@ export async function handleCredentialGroupOAuthCallback({
9295
: error instanceof CredentialGroupOAuthError && error.statusCode === 409
9396
? 'configuration_changed'
9497
: 'failed'
95-
return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { oauth: status })
98+
return failureRedirect(status)
9699
}
97100
}

‎apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.test.ts‎

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const mocks = vi.hoisted(() => ({
1212
}))
1313

1414
vi.mock('@/lib/credential-groups/application/enrollment-auth', () => ({
15-
authenticateCredentialGroupEnrollment: mocks.authenticate,
15+
credentialGroupOAuthAttemptPrincipal: mocks.authenticate,
1616
}))
1717

1818
vi.mock('@/lib/credential-groups/application/public-enrollment', () => ({
@@ -27,7 +27,10 @@ vi.mock('@/lib/credential-groups/rate-limit', () => ({
2727
enforcePublicCredentialGroupIpRateLimit: mocks.rateLimit,
2828
}))
2929

30-
import { CredentialGroupInvitationUnavailableError } from '@/lib/credential-groups/provider-adapter'
30+
import {
31+
CredentialGroupInvitationUnavailableError,
32+
CredentialGroupOAuthError,
33+
} from '@/lib/credential-groups/provider-adapter'
3134
import { GET } from '@/app/api/credential-groups/oauth/[provider]/callback/route'
3235

3336
const principal = {
@@ -56,7 +59,7 @@ describe('credential group OAuth callback', () => {
5659
vi.clearAllMocks()
5760
mocks.rateLimit.mockResolvedValue(null)
5861
mocks.consumeAttempt.mockResolvedValue(attempt)
59-
mocks.authenticate.mockResolvedValue(principal)
62+
mocks.authenticate.mockReturnValue(principal)
6063
mocks.completeOAuth.mockResolvedValue({ connectedOptionId: 'option-1' })
6164
})
6265

@@ -65,6 +68,7 @@ describe('credential group OAuth callback', () => {
6568
const response = await GET(callbackRequest, context)
6669

6770
expect(mocks.consumeAttempt).toHaveBeenCalledWith('state-1')
71+
expect(mocks.authenticate).toHaveBeenCalledWith(attempt)
6872
expect(mocks.completeOAuth).toHaveBeenCalledWith({
6973
principal,
7074
input: { attempt, code: 'code-1' },
@@ -113,15 +117,15 @@ describe('credential group OAuth callback', () => {
113117
})
114118

115119
it('returns an unavailable enrollment redirect when the invitation was revoked in flight', async () => {
116-
mocks.authenticate.mockResolvedValue(null)
120+
mocks.completeOAuth.mockRejectedValueOnce(new CredentialGroupInvitationUnavailableError())
117121

118122
const response = await GET(request('state=state-1&code=code-1'), context)
119123

120124
expect(response.status).toBe(307)
121125
expect(response.headers.get('location')).toBe(
122126
'/credential-groups/enroll/invitation-token?oauth=unavailable'
123127
)
124-
expect(mocks.completeOAuth).not.toHaveBeenCalled()
128+
expect(mocks.completeOAuth).toHaveBeenCalledOnce()
125129
})
126130

127131
it('returns an unavailable enrollment redirect when the invitation is revoked during exchange', async () => {
@@ -150,4 +154,49 @@ describe('credential group OAuth callback', () => {
150154
expect(mocks.authenticate).not.toHaveBeenCalled()
151155
expect(mocks.completeOAuth).not.toHaveBeenCalled()
152156
})
157+
158+
it('returns personal connections to the fixed completion page after invitation rotation', async () => {
159+
mocks.consumeAttempt.mockResolvedValue({ ...attempt, completionRedirect: true })
160+
const response = await GET(request('state=state-1&code=code-1'), context)
161+
expect(response.status).toBe(303)
162+
expect(response.headers.get('location')).toBe('/credential-groups/complete')
163+
expect(response.headers.get('cache-control')).toBe('no-store')
164+
expect(response.headers.get('referrer-policy')).toBe('no-referrer')
165+
})
166+
167+
it.each([['error=access_denied', 'denied']])(
168+
'shows personal callback failure without reopening a stale invitation: %s',
169+
async (query, status) => {
170+
mocks.consumeAttempt.mockResolvedValue({ ...attempt, completionRedirect: true })
171+
const response = await GET(request(`state=state-1&${query}`), context)
172+
expect(response.status).toBe(303)
173+
expect(response.headers.get('location')).toBe(`/credential-groups/complete?oauth=${status}`)
174+
expect(mocks.completeOAuth).not.toHaveBeenCalled()
175+
}
176+
)
177+
178+
it.each([
179+
[new CredentialGroupInvitationUnavailableError(), 'unavailable'],
180+
[new CredentialGroupOAuthError('Sign in with your own account', 403), 'account_mismatch'],
181+
[new CredentialGroupOAuthError('Missing scopes', 403), 'permissions_required'],
182+
[new CredentialGroupOAuthError('Changed settings', 409), 'configuration_changed'],
183+
[new Error('Provider failed'), 'failed'],
184+
])('shows failed personal authorization on the completion page', async (error, status) => {
185+
mocks.consumeAttempt.mockResolvedValue({ ...attempt, completionRedirect: true })
186+
mocks.completeOAuth.mockRejectedValueOnce(error)
187+
const response = await GET(request('state=state-1&code=code-1'), context)
188+
expect(response.status).toBe(303)
189+
expect(response.headers.get('location')).toBe(`/credential-groups/complete?oauth=${status}`)
190+
})
191+
192+
it('shows rate limits on the personal completion page without exchanging', async () => {
193+
mocks.consumeAttempt.mockResolvedValue({ ...attempt, completionRedirect: true })
194+
mocks.rateLimit.mockResolvedValue(
195+
NextResponse.json({ error: 'Too many requests' }, { status: 429 })
196+
)
197+
const response = await GET(request('state=state-1&code=code-1'), context)
198+
expect(response.status).toBe(303)
199+
expect(response.headers.get('location')).toBe('/credential-groups/complete?oauth=rate_limited')
200+
expect(mocks.completeOAuth).not.toHaveBeenCalled()
201+
})
153202
})
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { startPersonalCredentialConnectionContract } from '@/lib/api/contracts/credentials'
2+
import {
3+
defineInternalJsonRoute,
4+
internalRateLimits,
5+
internalSessionAuth,
6+
} from '@/lib/api/server/routes'
7+
import { internalPersonalCredentialConnectionErrorPolicy } from '@/lib/credentials/api/route-policies'
8+
import { credentialOperations } from '@/lib/credentials/application/operations'
9+
import { startPersonalCredentialConnection } from '@/lib/credentials/application/personal-connection'
10+
11+
export const POST = defineInternalJsonRoute({
12+
contract: startPersonalCredentialConnectionContract,
13+
auth: internalSessionAuth,
14+
operation: credentialOperations.startPersonalConnection,
15+
rateLimit: internalRateLimits.user({ bucketName: 'credentials.personal.connect' }),
16+
errorPolicy: internalPersonalCredentialConnectionErrorPolicy,
17+
mapInput: ({ body }) => body,
18+
useCase: startPersonalCredentialConnection,
19+
present: (result) => result,
20+
})
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { listPersonalCredentialsContract } from '@/lib/api/contracts/credentials'
2+
import {
3+
defineInternalJsonRoute,
4+
internalRateLimits,
5+
internalSessionAuth,
6+
} from '@/lib/api/server/routes'
7+
import { internalCredentialErrorPolicy } from '@/lib/credentials/api/route-policies'
8+
import { credentialOperations } from '@/lib/credentials/application/operations'
9+
import { listPersonalCredentials } from '@/lib/credentials/application/personal-credentials'
10+
11+
export const GET = defineInternalJsonRoute({
12+
contract: listPersonalCredentialsContract,
13+
auth: internalSessionAuth,
14+
operation: credentialOperations.listPersonal,
15+
rateLimit: internalRateLimits.user({ bucketName: 'credentials.personal.list' }),
16+
errorPolicy: internalCredentialErrorPolicy,
17+
mapInput: ({ query }) => query,
18+
useCase: listPersonalCredentials,
19+
present: ({ credentials }) => ({
20+
credentials: credentials.map((entry) => ({
21+
...entry,
22+
updatedAt: entry.updatedAt.toISOString(),
23+
connectedAt: entry.connectedAt.toISOString(),
24+
})),
25+
}),
26+
})

‎apps/sim/app/api/mcp/oauth/callback/route.test.ts‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ vi.mock('@/lib/mcp/service', () => ({
3030
mcpService: { discoverServerTools: mockDiscoverServerTools },
3131
}))
3232
vi.mock('@/lib/credential-groups/application/enrollment-auth', () => ({
33-
authenticateCredentialGroupEnrollment: mockAuthenticateEnrollment,
33+
credentialGroupOAuthAttemptPrincipal: mockAuthenticateEnrollment,
3434
}))
3535
vi.mock('@/lib/credential-groups/application/public-enrollment', () => ({
3636
completePublicCredentialGroupMcpOAuth: { execute: mockCompleteManagedMcpOAuth },
@@ -68,14 +68,16 @@ describe('MCP OAuth callback route', () => {
6868
mockDiscoverServerTools.mockResolvedValue(undefined)
6969
mockConsumeManagedAttempt.mockResolvedValue({
7070
state: 'mcp_cg_state-1',
71+
workspaceId: 'workspace-1',
72+
email: 'invitee@example.com',
7173
enrollmentId: 'enrollment-1',
7274
credentialGroupId: 'group-1',
7375
mcpServerId: 'server-1',
7476
codeVerifier: 'code-verifier',
7577
invitationToken: 'invitation-token',
7678
createdAt: Date.now(),
7779
})
78-
mockAuthenticateEnrollment.mockResolvedValue({
80+
mockAuthenticateEnrollment.mockReturnValue({
7981
kind: 'credential_group_enrollment',
8082
workspaceId: 'workspace-1',
8183
credentialGroupId: 'group-1',
@@ -159,7 +161,13 @@ describe('MCP OAuth callback route', () => {
159161

160162
expect(mockEnforceCallbackRateLimit).toHaveBeenCalledWith(request, 'oauth-callback')
161163
expect(mockConsumeManagedAttempt).toHaveBeenCalledWith('mcp_cg_state-1')
162-
expect(mockAuthenticateEnrollment).toHaveBeenCalledWith('invitation-token')
164+
expect(mockAuthenticateEnrollment).toHaveBeenCalledWith(
165+
expect.objectContaining({
166+
workspaceId: 'workspace-1',
167+
email: 'invitee@example.com',
168+
invitationToken: 'invitation-token',
169+
})
170+
)
163171
expect(mockCompleteManagedMcpOAuth).toHaveBeenCalledWith(
164172
expect.objectContaining({
165173
input: expect.objectContaining({

‎apps/sim/app/api/mcp/oauth/callback/route.ts‎

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { mcpOauthCallbackContract } from '@/lib/api/contracts/mcp'
99
import { parseRequest } from '@/lib/api/server'
1010
import { getSession } from '@/lib/auth'
1111
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
12-
import { authenticateCredentialGroupEnrollment } from '@/lib/credential-groups/application/enrollment-auth'
12+
import { credentialGroupOAuthAttemptPrincipal } from '@/lib/credential-groups/application/enrollment-auth'
1313
import { completePublicCredentialGroupMcpOAuth } from '@/lib/credential-groups/application/public-enrollment'
1414
import {
1515
consumeCredentialGroupMcpOAuthAttempt,
@@ -97,12 +97,7 @@ async function completeManagedMcpCallback(params: {
9797
})
9898
}
9999
try {
100-
const principal = await authenticateCredentialGroupEnrollment(attempt.invitationToken)
101-
if (!principal) {
102-
return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, {
103-
oauth: 'unavailable',
104-
})
105-
}
100+
const principal = credentialGroupOAuthAttemptPrincipal(attempt)
106101
const result = await completePublicCredentialGroupMcpOAuth.execute({
107102
principal,
108103
input: { attempt, code: params.code },

‎apps/sim/app/credential-groups/complete/page.tsx‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,31 @@ export const metadata: Metadata = {
66
robots: { index: false, follow: false },
77
}
88

9-
export default function CredentialGroupCompletePage() {
9+
const OAUTH_FAILURE_MESSAGES = {
10+
denied: 'Authorization was canceled. Return to the chat to try again.',
11+
account_mismatch: 'Choose the account matching your Sim email address.',
12+
permissions_required: 'All requested permissions are required to connect this account.',
13+
configuration_changed: 'The connection settings changed. Return to the chat to try again.',
14+
rate_limited: 'Too many authorization attempts. Wait a few minutes and try again.',
15+
unavailable: 'This connection is unavailable. Return to the chat to try again.',
16+
failed: 'Account authorization did not complete. Return to the chat to try again.',
17+
} as const
18+
19+
export default async function CredentialGroupCompletePage({
20+
searchParams,
21+
}: {
22+
searchParams: Promise<{ oauth?: string | string[] }>
23+
}) {
24+
const { oauth } = await searchParams
25+
const error =
26+
typeof oauth === 'string' && Object.hasOwn(OAUTH_FAILURE_MESSAGES, oauth)
27+
? OAUTH_FAILURE_MESSAGES[oauth as keyof typeof OAUTH_FAILURE_MESSAGES]
28+
: undefined
1029
return (
1130
<AuthShell>
1231
<AuthHeader
13-
title='Accounts connected'
14-
description='Your accounts are ready to use — you can close this tab.'
32+
title={error ? 'Account not connected' : 'Accounts connected'}
33+
description={error ?? 'Your accounts are ready to use — you can close this tab.'}
1534
/>
1635
</AuthShell>
1736
)

‎apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,7 @@ const MARKDOWN_COMPONENTS = {
455455
interface ChatContentProps {
456456
content: string
457457
messageId?: string
458+
requestMode?: 'agent' | 'assistant'
458459
isStreaming?: boolean
459460
/** Transcript-derived answers for this message's question card (renders the recap). */
460461
questionAnswers?: string[]
@@ -478,6 +479,7 @@ interface ChatContentProps {
478479
function ChatContentInner({
479480
content,
480481
messageId,
482+
requestMode,
481483
isStreaming = false,
482484
questionAnswers,
483485
credentialSubmission,
@@ -725,6 +727,7 @@ function ChatContentInner({
725727
questionAnswers={questionAnswers}
726728
credentialSubmission={credentialSubmission}
727729
credentialAbandoned={credentialAbandoned}
730+
requestMode={requestMode}
728731
onOptionSelect={onOptionSelect}
729732
onQuestionDismiss={onQuestionDismiss}
730733
/>

0 commit comments

Comments
 (0)