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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -49,29 +49,28 @@ 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)

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: {},
request: enrollmentRequest,
})
})

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'
)
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -36,9 +39,6 @@ export const POST = withRouteHandler(
if (completed === null) {
return createCredentialGroupEnrollmentRedirect(token, { oauth: 'unavailable' })
}
return createCredentialGroupEnrollmentRedirect(
token,
completed ? { submitted: '1' } : { oauth: 'incomplete' }
)
return createCredentialGroupCompletionRedirect()
}
)
18 changes: 16 additions & 2 deletions apps/sim/app/api/credential-groups/enrollment-redirect.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>
Expand All @@ -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,
},
})
}
Original file line number Diff line number Diff line change
@@ -1,27 +1,27 @@
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 revoke credential group enrollment'
'Failed to delete person from credential group'
),
mapInput: ({ params }) => ({
assertedWorkspaceId: params.id,
credentialGroupId: params.groupId,
enrollmentId: params.enrollmentId,
}),
useCase: revokeCredentialGroupEnrollmentSettings,
useCase: deleteCredentialGroupEnrollmentSettings,
})
18 changes: 18 additions & 0 deletions apps/sim/app/credential-groups/complete/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<AuthShell>
<AuthHeader
title='Accounts connected'
description='Your accounts are ready to use — you can close this tab.'
/>
</AuthShell>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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(<OAuthConnectLink href='/oauth/start' reconnect />))

const link = container.querySelector('a')
expect(link?.textContent).toBe('Reconnect')
expect(link?.getAttribute('href')).toBe('/oauth/start')
act(() => root.unmount())
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<a href={href} className={chipVariants()}>
Connect
{reconnect ? 'Reconnect' : 'Connect'}
</a>
)
}
47 changes: 18 additions & 29 deletions apps/sim/app/credential-groups/enroll/[token]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -109,7 +108,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]
Expand All @@ -118,22 +116,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 allConnected =
activeOptions.length > 0 &&
activeOptions.every(
(option) => option.connections.length === 1 && option.connections[0]?.status === 'connected'
)

const notification = connectedOptionId
? {
message: `${connectedOption ? getCredentialGroupProviderService(connectedOption.provider).name : 'Account'} connected successfully.`,
variant: 'success' as const,
}
: oauthMessage
? { message: oauthMessage, variant: 'error' as const }
: null
return (
<PageShell>
{notification && (
Expand Down Expand Up @@ -167,24 +157,23 @@ export default async function CredentialGroupEnrollmentPage({
trailing={
<OAuthConnectLink
href={`/api/credential-groups/enroll/${token}/oauth/${option.id}`}
reconnect={Boolean(connection)}
/>
}
/>
)
})}
</div>
</SettingsSection>
{(allConnected || enrollment.status === 'completed') && (
<form
action={`/api/credential-groups/enroll/${token}/complete`}
method='post'
className='mt-6 flex justify-end'
>
<Chip type='submit' variant='primary' disabled={enrollment.status === 'completed'}>
{enrollment.status === 'completed' ? 'Submitted' : 'Submit'}
</Chip>
</form>
)}
<form
action={`/api/credential-groups/enroll/${token}/complete`}
method='post'
className='mt-6 flex justify-end'
>
<Chip type='submit' variant='primary' disabled={enrollment.status === 'completed'}>
{enrollment.status === 'completed' ? 'Submitted' : 'Submit'}
</Chip>
</form>
</div>
</PageShell>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,29 @@ 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')

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 {
Expand Down Expand Up @@ -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])
Expand Down Expand Up @@ -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 (
<div className='space-y-4'>
Expand Down Expand Up @@ -310,7 +324,7 @@ function StepConfigure({
{allSelected && (
<p className='text-[var(--text-muted)] text-caption'>
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.
</p>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,14 +69,14 @@ describe('unified settings navigation', () => {
expect(idsForSection('workspace')).toEqual([
'teammates',
'secrets',
'credential-groups',
'mcp',
'custom-tools',
'byok',
'inbox',
'workflow-mcp-servers',
'apikeys',
'sandboxes',
'credential-groups',
'recently-deleted',
])
expect(idsForSection('organization')).toEqual([
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -568,6 +575,7 @@ const SubBlockRow = memo(function SubBlockRow({
const hydratedName =
credentialName ||
dropdownLabel ||
dynamicOptionDisplayName ||
variablesDisplayValue ||
filterDisplayValue ||
toolsDisplayValue ||
Expand Down
Loading
Loading