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
9 changes: 6 additions & 3 deletions apps/sim/app/account/settings/[section]/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ describe('account settings legacy links', () => {
)
})

it('still rejects unknown sections', async () => {
await expect(AccountSettingsSectionPage(pageProps('unknown'))).rejects.toThrow('NEXT_NOT_FOUND')
})
it.each(['unknown', 'connected-accounts'])(
'rejects unavailable sections: %s',
async (section) => {
await expect(AccountSettingsSectionPage(pageProps(section))).rejects.toThrow('NEXT_NOT_FOUND')
}
)
})
19 changes: 7 additions & 12 deletions apps/sim/app/api/credential-groups/enrollment-redirect.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { NextResponse } from 'next/server'
import type { CredentialGroupOAuthFailure } from '@/lib/credential-groups/oauth-completion'

const NO_STORE_REDIRECT_HEADERS = {
'Cache-Control': 'no-store',
Expand All @@ -20,23 +21,17 @@ export function createCredentialGroupEnrollmentRedirect(
})
}

export type CredentialGroupOAuthFailure =
| 'expired'
| 'denied'
| 'account_mismatch'
| 'permissions_required'
| 'configuration_changed'
| 'rate_limited'
| 'unavailable'
| 'failed'

export function createCredentialGroupCompletionRedirect(
oauth?: CredentialGroupOAuthFailure
oauth?: CredentialGroupOAuthFailure,
completionId?: string
): NextResponse {
const query = new URLSearchParams()
if (oauth) query.set('oauth', oauth)
if (completionId) query.set('completionId', completionId)
return new NextResponse(null, {
status: 303,
headers: {
Location: `/credential-groups/complete${oauth ? `?oauth=${oauth}` : ''}`,
Location: `/credential-groups/complete${query.size ? `?${query}` : ''}`,
...NO_STORE_REDIRECT_HEADERS,
},
})
Expand Down
6 changes: 3 additions & 3 deletions apps/sim/app/api/credential-groups/oauth-callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ import type { CredentialGroupOAuthCallbackQuery } from '@/lib/api/contracts/cred
import { credentialGroupOAuthAttemptPrincipal } from '@/lib/credential-groups/application/enrollment-auth'
import { completePublicCredentialGroupOAuth } from '@/lib/credential-groups/application/public-enrollment'
import { CredentialGroupOAuthStateVersionError } from '@/lib/credential-groups/oauth-attempt-version'
import type { CredentialGroupOAuthFailure } from '@/lib/credential-groups/oauth-completion'
import { consumeCredentialGroupOAuthAttempt } from '@/lib/credential-groups/oauth-state'
import {
CredentialGroupInvitationUnavailableError,
CredentialGroupOAuthError,
} from '@/lib/credential-groups/provider-adapter'
import type { CredentialGroupProvider } from '@/lib/credential-groups/providers'
import {
type CredentialGroupOAuthFailure,
createCredentialGroupCompletionRedirect,
createCredentialGroupEnrollmentRedirect,
} from '@/app/api/credential-groups/enrollment-redirect'
Expand Down Expand Up @@ -54,7 +54,7 @@ export async function handleCredentialGroupOAuthCallback({
: {}
const failureRedirect = (oauth: CredentialGroupOAuthFailure) =>
attempt.completionRedirect
? createCredentialGroupCompletionRedirect(oauth)
? createCredentialGroupCompletionRedirect(oauth, attempt.completionId)
: createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { ...focus, oauth })
if (limited) {
return failureRedirect('rate_limited')
Expand All @@ -74,7 +74,7 @@ export async function handleCredentialGroupOAuthCallback({
request,
})
return attempt.completionRedirect
? createCredentialGroupCompletionRedirect()
? createCredentialGroupCompletionRedirect(undefined, attempt.completionId)
: createCredentialGroupEnrollmentRedirect(attempt.invitationToken, {
...focus,
connected: attempt.optionId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,22 @@ function request(query: string) {
}

describe('credential group OAuth callback', () => {
it.each([
['code=code-1', undefined],
['error=access_denied', 'denied'],
])(
'correlates direct OAuth completion without returning to enrollment: %s',
async (query, failure) => {
const completionId = '550e8400-e29b-41d4-a716-446655440000'
mocks.consumeAttempt.mockResolvedValue({ ...attempt, completionRedirect: true, completionId })
const response = await GET(request(`state=state-1&${query}`), context)
const location = new URL(response.headers.get('location')!, 'https://sim.test')
expect(response.status).toBe(303)
expect(location.pathname).toBe('/credential-groups/complete')
expect(location.searchParams.get('completionId')).toBe(completionId)
expect(location.searchParams.get('oauth')).toBe(failure ?? null)
}
)
beforeEach(() => {
vi.clearAllMocks()
mocks.rateLimit.mockResolvedValue(null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@ export const POST = defineInternalJsonRoute({
reason:
'A member connecting their own account by hand; each call only re-issues their own invitation',
}),
errorPolicy: internalKnowledgeErrorPolicies.connectors,
mapInput: ({ params }) => ({
errorPolicy: internalKnowledgeErrorPolicies.connectAccount,
mapInput: ({ params, query }) => ({
connectorId: params.connectorId,
knowledgeBaseId: params.id,
oauthCompletionId: query.oauthCompletionId,
}),
useCase: startKnowledgeConnectorMemberEnrollment,
present: ({ url }) => ({ success: true as const, data: { url } }),
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/app/api/knowledge/sim-search/connect/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export const POST = defineInternalJsonRoute({
auth: internalSessionAuth,
operation: knowledgeOperations.simSearchConnect,
rateLimit: internalRateLimits.none({ reason: 'One click per source; mints a single-use link' }),
errorPolicy: internalKnowledgeErrorPolicies.connectors,
errorPolicy: internalKnowledgeErrorPolicies.connectAccount,
mapInput: ({ body }) => body,
useCase: connectSimSearchConnector,
present: (result) => ({ success: true as const, data: result }),
Expand Down
10 changes: 8 additions & 2 deletions apps/sim/app/api/knowledge/sim-search/sources/route.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
/** @vitest-environment node */
import { authMockFns, createMockRequest } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type {
OrganizationSearchProviderSummary,
SearchSourceSummary,
} from '@/lib/api/contracts/knowledge/connectors'

const mocks = vi.hoisted(() => ({
execute: vi.fn(),
Expand Down Expand Up @@ -58,9 +62,10 @@ const source = {
viewerDocumentCount: 0,
viewerFailedDocumentCount: 0,
viewerEmailVerified: true,
viewerAccounts: [],
connectionRequired: false,
viewerMembership: null,
}
} satisfies SearchSourceSummary

beforeEach(() => {
vi.clearAllMocks()
Expand Down Expand Up @@ -266,8 +271,9 @@ describe('organization administration overview boundary', () => {
sourceCount: 1,
approved: true,
status: 'waiting_for_connections',
issue: null,
isSyncing: false,
}
} satisfies OrganizationSearchProviderSummary
mocks.adminOverview.mockResolvedValue({
providers: [{ ...provider, privateAccount: 'private' }],
documentNames: ['private'],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/** @vitest-environment jsdom */
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { CredentialGroupCompletionHandoff } from '@/app/credential-groups/complete/completion-handoff'

afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})

describe('credential group OAuth completion', () => {
it.each([undefined, 'denied', 'configuration_changed'] as const)(
'publishes %s to only its initiating tab and closes',
(failure) => {
const postMessage = vi.fn()
const closeChannel = vi.fn()
const names: string[] = []
vi.stubGlobal(
'BroadcastChannel',
class {
postMessage = postMessage
close = closeChannel
constructor(name: string) {
names.push(name)
}
}
)
const closeWindow = vi.spyOn(window, 'close').mockImplementation(() => {})
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
const container = document.createElement('div')
const root = createRoot(container)
const completionId = '550e8400-e29b-41d4-a716-446655440000'
try {
act(() =>
root.render(
<CredentialGroupCompletionHandoff completionId={completionId} failure={failure} />
)
)
expect(names).toEqual([`sim:credential-group-oauth:${completionId}`])
expect(postMessage).toHaveBeenCalledExactlyOnceWith(failure ?? 'connected')
expect(closeChannel).toHaveBeenCalledOnce()
expect(closeWindow).toHaveBeenCalledOnce()
} finally {
act(() => root.unmount())
}
}
)
})
26 changes: 26 additions & 0 deletions apps/sim/app/credential-groups/complete/completion-handoff.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
'use client'

import { useEffect } from 'react'
import {
type CredentialGroupOAuthFailure,
credentialGroupOAuthCompletionChannel,
} from '@/lib/credential-groups/oauth-completion'

interface CredentialGroupCompletionHandoffProps {
completionId: string
failure?: CredentialGroupOAuthFailure
}

/** Notifies the originating tab even when provider navigation has removed window.opener. */
export function CredentialGroupCompletionHandoff({
completionId,
failure,
}: CredentialGroupCompletionHandoffProps) {
useEffect(() => {
const channel = new BroadcastChannel(credentialGroupOAuthCompletionChannel(completionId))
channel.postMessage(failure ?? 'connected')
channel.close()
window.close()
}, [completionId, failure])
return null
}
31 changes: 14 additions & 17 deletions apps/sim/app/credential-groups/complete/page.tsx
Original file line number Diff line number Diff line change
@@ -1,36 +1,33 @@
import { ChipLink } from '@sim/emcn'
import { isValidUuid } from '@sim/utils/id'
import type { Metadata } from 'next'
import {
CREDENTIAL_GROUP_OAUTH_FAILURE_MESSAGES,
isCredentialGroupOAuthFailure,
} from '@/lib/credential-groups/oauth-completion'
import { APP_ENTRY_PATH } from '@/lib/navigation/paths'
import { AuthHeader, AuthShell } from '@/app/(auth)/components'
import { CredentialGroupCompletionHandoff } from '@/app/credential-groups/complete/completion-handoff'

export const metadata: Metadata = {
title: 'Accounts connected',
robots: { index: false, follow: false },
}

const OAUTH_FAILURE_MESSAGES = {
expired: 'This connection attempt expired. Open Sim and start connecting your account again.',
denied: 'Authorization was canceled. Open Sim to try again.',
account_mismatch: 'Choose the account matching your Sim email address.',
permissions_required: 'All requested permissions are required to connect this account.',
configuration_changed: 'The connection settings changed. Open Sim to try again.',
rate_limited: 'Too many authorization attempts. Wait a few minutes and try again.',
unavailable: 'This connection is unavailable. Open Sim to try again.',
failed: 'Account authorization did not complete. Open Sim to try again.',
} as const

export default async function CredentialGroupCompletePage({
searchParams,
}: {
searchParams: Promise<{ oauth?: string | string[] }>
searchParams: Promise<{ oauth?: string | string[]; completionId?: string | string[] }>
}) {
const { oauth } = await searchParams
const error =
typeof oauth === 'string' && Object.hasOwn(OAUTH_FAILURE_MESSAGES, oauth)
? OAUTH_FAILURE_MESSAGES[oauth as keyof typeof OAUTH_FAILURE_MESSAGES]
: undefined
const { oauth, completionId } = await searchParams
const failure =
oauth === undefined ? undefined : isCredentialGroupOAuthFailure(oauth) ? oauth : 'failed'
const error = failure ? CREDENTIAL_GROUP_OAUTH_FAILURE_MESSAGES[failure] : undefined
return (
<AuthShell>
{typeof completionId === 'string' && isValidUuid(completionId) && (
<CredentialGroupCompletionHandoff completionId={completionId} failure={failure} />
)}
<AuthHeader
title={error ? 'Account not connected' : 'Accounts connected'}
description={error ?? 'Your accounts are ready to use — you can close this tab.'}
Expand Down
10 changes: 5 additions & 5 deletions apps/sim/app/credential-groups/enroll/[token]/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,17 +148,17 @@ describe('focused Search enrollment', () => {
expect(mocks.read).toHaveBeenCalledWith({ principal, input: {} })
})

it('keeps account-settings reconnect focused and returns to account settings', async () => {
it('keeps existing account reconnect links focused and returns to Sim', async () => {
await render({ returnTo: 'accounts', optionId: 'site-two' })
expect(oauthLinks().map((link) => link.getAttribute('href'))).toEqual([
'/api/credential-groups/enroll/invitation/oauth/site-two?returnTo=accounts',
])
expect(document.querySelector('form')).toBeNull()
expect(
Array.from(document.querySelectorAll('a'))
.find((link) => link.textContent === 'Your connected accounts')
.find((link) => link.textContent === 'Open Sim')
?.getAttribute('href')
).toBe('/account/settings/connected-accounts')
).toBe('/home')
})

it('lets an account owner deliberately reconnect an active grant before reporting completion', async () => {
Expand Down Expand Up @@ -189,12 +189,12 @@ describe('focused Search enrollment', () => {
})
mocks.read.mockResolvedValue({ enrollment, canSearch })
await render({ returnTo: 'search', optionId: 'site-two' })
const label = canSearch ? 'Return to Search' : 'Your connected accounts'
const label = canSearch ? 'Return to Search' : 'Open Sim'
expect(
Array.from(document.querySelectorAll('a'))
.find((link) => link.textContent === label)
?.getAttribute('href')
).toBe(canSearch ? '/o/canonical-org/search' : '/account/settings/connected-accounts')
).toBe(canSearch ? '/o/canonical-org/search' : '/home')
}
)

Expand Down
7 changes: 2 additions & 5 deletions apps/sim/app/credential-groups/enroll/[token]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { Chip, ChipLink } from '@sim/emcn'
import type { Metadata } from 'next'
import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import { getAccountSettingsHref } from '@/components/settings/navigation'
import { getSession } from '@/lib/auth'
import { asOrchestrationError } from '@/lib/core/orchestration/types'
import type { ResourceOwner } from '@/lib/core/resource-scope'
Expand Down Expand Up @@ -184,10 +183,8 @@ export default async function CredentialGroupEnrollmentPage({
const canReturnToSearch =
returnToSearch &&
('canSearch' in enrollmentResult ? enrollmentResult.canSearch : !principal.organizationId)
const returnHref = canReturnToSearch
? searchReturnPath(principal)
: getAccountSettingsHref('connected-accounts')
const returnLabel = canReturnToSearch ? 'Return to Search' : 'Your connected accounts'
const returnHref = canReturnToSearch ? searchReturnPath(principal) : APP_ENTRY_PATH
const returnLabel = canReturnToSearch ? 'Return to Search' : 'Open Sim'
if (!enrollment)
return <UnavailableSearchConnection returnHref={returnHref} returnLabel={returnLabel} />

Expand Down
Loading
Loading