Skip to content

Commit fd6fc44

Browse files
committed
improvement(search): clarify setup requirements and focus account connections
1 parent 4356872 commit fd6fc44

34 files changed

Lines changed: 1646 additions & 185 deletions

File tree

apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.test.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,9 @@ const context = {
3838
params: Promise.resolve({ token: 'invitation-token', optionId: 'option-1' }),
3939
}
4040

41-
function request() {
41+
function request(query = '') {
4242
return new NextRequest(
43-
'http://localhost:3000/api/credential-groups/enroll/invitation-token/oauth/option-1'
43+
`http://localhost:3000/api/credential-groups/enroll/invitation-token/oauth/option-1${query}`
4444
)
4545
}
4646

@@ -69,6 +69,41 @@ describe('credential group OAuth start route', () => {
6969
})
7070
})
7171

72+
it('forwards only the closed Search return context to the authorized operation', async () => {
73+
await GET(request('?returnTo=search'), context)
74+
expect(mocks.startOAuth).toHaveBeenCalledWith(
75+
expect.objectContaining({
76+
principal,
77+
input: { invitationToken: 'invitation-token', optionId: 'option-1', returnTo: 'search' },
78+
})
79+
)
80+
mocks.startOAuth.mockClear()
81+
const response = await GET(request('?returnTo=https://external.test'), context)
82+
expect(response.status).toBe(400)
83+
expect(mocks.startOAuth).not.toHaveBeenCalled()
84+
})
85+
86+
it.each(['ip', 'enrollment', 'unavailable', 'configuration'])(
87+
'preserves exact Search focus after %s failure',
88+
async (failure) => {
89+
if (failure === 'ip')
90+
mocks.ipRateLimit.mockResolvedValue(NextResponse.json({}, { status: 429 }))
91+
if (failure === 'enrollment')
92+
mocks.enrollmentRateLimit.mockResolvedValue(NextResponse.json({}, { status: 429 }))
93+
if (failure === 'unavailable') mocks.authenticate.mockResolvedValue(null)
94+
if (failure === 'configuration')
95+
mocks.startOAuth.mockRejectedValue(new Error('Unavailable configuration'))
96+
const response = await GET(request('?returnTo=search'), context)
97+
const location = new URL(response.headers.get('location')!, 'http://localhost')
98+
expect(location.pathname).toBe('/credential-groups/enroll/invitation-token')
99+
expect(location.searchParams.get('optionId')).toBe('option-1')
100+
expect(location.searchParams.get('returnTo')).toBe('search')
101+
expect(location.searchParams.get('oauth')).toBe(
102+
failure === 'ip' || failure === 'enrollment' ? 'rate_limited' : 'unavailable'
103+
)
104+
}
105+
)
106+
72107
it('returns an unavailable enrollment to its public page', async () => {
73108
mocks.authenticate.mockResolvedValue(null)
74109

apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,25 +29,27 @@ export const GET = withRouteHandler(
2929
const parsed = await parseRequest(startCredentialGroupOAuthContract, request, context)
3030
if (!parsed.success) return limited ?? parsed.response
3131
const { token, optionId } = parsed.data.params
32+
const { returnTo } = parsed.data.query
33+
const focus: Record<string, string> = returnTo ? { optionId, returnTo } : {}
3234
if (limited) {
33-
return createCredentialGroupEnrollmentRedirect(token, { oauth: 'rate_limited' })
35+
return createCredentialGroupEnrollmentRedirect(token, { ...focus, oauth: 'rate_limited' })
3436
}
3537
const principal = await authenticateCredentialGroupEnrollment(token)
3638
if (!principal) {
37-
return createCredentialGroupEnrollmentRedirect(token, { oauth: 'unavailable' })
39+
return createCredentialGroupEnrollmentRedirect(token, { ...focus, oauth: 'unavailable' })
3840
}
3941

4042
const enrollmentLimited = await enforceCredentialGroupEnrollmentOAuthRateLimit(
4143
principal.enrollmentId
4244
)
4345
if (enrollmentLimited) {
44-
return createCredentialGroupEnrollmentRedirect(token, { oauth: 'rate_limited' })
46+
return createCredentialGroupEnrollmentRedirect(token, { ...focus, oauth: 'rate_limited' })
4547
}
4648

4749
try {
4850
const { authorizationUrl } = await startPublicCredentialGroupOAuth.execute({
4951
principal,
50-
input: { invitationToken: token, optionId },
52+
input: { invitationToken: token, optionId, ...(returnTo ? { returnTo } : {}) },
5153
request,
5254
})
5355
const response = NextResponse.redirect(authorizationUrl)
@@ -59,6 +61,7 @@ export const GET = withRouteHandler(
5961
error: getErrorMessage(error),
6062
})
6163
return createCredentialGroupEnrollmentRedirect(token, {
64+
...focus,
6265
oauth:
6366
error instanceof CredentialGroupOAuthError && error.statusCode === 409
6467
? 'configuration_changed'

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,13 @@ export async function handleCredentialGroupOAuthCallback({
5353
{ status: 400, headers: { 'Cache-Control': 'no-store' } }
5454
)
5555
}
56+
const focus: Record<string, string> = attempt.returnTo
57+
? { optionId: attempt.optionId, returnTo: attempt.returnTo }
58+
: {}
5659
const failureRedirect = (oauth: CredentialGroupOAuthFailure) =>
5760
attempt.completionRedirect
5861
? createCredentialGroupCompletionRedirect(oauth)
59-
: createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { oauth })
62+
: createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { ...focus, oauth })
6063
if (limited) {
6164
return failureRedirect('rate_limited')
6265
}
@@ -78,6 +81,7 @@ export async function handleCredentialGroupOAuthCallback({
7881
return attempt.completionRedirect
7982
? createCredentialGroupCompletionRedirect()
8083
: createCredentialGroupEnrollmentRedirect(attempt.invitationToken, {
84+
...focus,
8185
connected: attempt.optionId,
8286
})
8387
} catch (error) {

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

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,56 @@ describe('credential group OAuth callback', () => {
8080
)
8181
})
8282

83+
it('restores the exact focused option after a successful Search connection', async () => {
84+
mocks.consumeAttempt.mockResolvedValue({ ...attempt, optionId: 'site-two', returnTo: 'search' })
85+
const response = await GET(request('state=state-1&code=code-1'), context)
86+
expect(response.headers.get('location')).toBe(
87+
'/credential-groups/enroll/invitation-token?optionId=site-two&returnTo=search&connected=site-two'
88+
)
89+
expect(mocks.completeOAuth).toHaveBeenCalledWith(
90+
expect.objectContaining({
91+
principal,
92+
input: expect.objectContaining({
93+
attempt: expect.objectContaining({ optionId: 'site-two' }),
94+
}),
95+
})
96+
)
97+
})
98+
99+
it.each([
100+
[new CredentialGroupInvitationUnavailableError(), 'unavailable'],
101+
[new CredentialGroupOAuthError('Sign in with your own account', 403), 'account_mismatch'],
102+
[new CredentialGroupOAuthError('Missing scopes', 403), 'permissions_required'],
103+
[new CredentialGroupOAuthError('Changed settings', 409), 'configuration_changed'],
104+
[new Error('Provider failed'), 'failed'],
105+
])('retains Search focus after a rejected provider exchange: %s', async (error, status) => {
106+
mocks.consumeAttempt.mockResolvedValue({ ...attempt, returnTo: 'search' })
107+
mocks.completeOAuth.mockRejectedValueOnce(error)
108+
const response = await GET(request('state=state-1&code=code-1'), context)
109+
expect(response.headers.get('location')).toBe(
110+
`/credential-groups/enroll/invitation-token?optionId=option-1&returnTo=search&oauth=${status}`
111+
)
112+
})
113+
114+
it.each(['denied', 'rate_limited'])(
115+
'retains Search focus without exchanging after %s',
116+
async (status) => {
117+
mocks.consumeAttempt.mockResolvedValue({ ...attempt, returnTo: 'search' })
118+
if (status === 'rate_limited')
119+
mocks.rateLimit.mockResolvedValue(NextResponse.json({}, { status: 429 }))
120+
const response = await GET(
121+
request(
122+
status === 'denied' ? 'state=state-1&error=access_denied' : 'state=state-1&code=code-1'
123+
),
124+
context
125+
)
126+
expect(response.headers.get('location')).toBe(
127+
`/credential-groups/enroll/invitation-token?optionId=option-1&returnTo=search&oauth=${status}`
128+
)
129+
expect(mocks.completeOAuth).not.toHaveBeenCalled()
130+
}
131+
)
132+
83133
it('rejects standard providers on the custom callback route', async () => {
84134
const response = await GET(
85135
new NextRequest(

apps/sim/app/credential-groups/enroll/[token]/oauth-reconnect-link.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
'use client'
22

3-
import { chipVariants } from '@sim/emcn'
3+
import { type ChipLinkProps, chipVariants } from '@sim/emcn'
44

5-
interface OAuthConnectLinkProps {
5+
interface OAuthConnectLinkProps extends Pick<ChipLinkProps, 'variant'> {
66
href: string
77
reconnect?: boolean
88
}
99

10-
export function OAuthConnectLink({ href, reconnect = false }: OAuthConnectLinkProps) {
10+
export function OAuthConnectLink({ href, reconnect = false, variant }: OAuthConnectLinkProps) {
1111
return (
12-
<a href={href} className={chipVariants()}>
12+
<a href={href} className={chipVariants({ variant })}>
1313
{reconnect ? 'Reconnect' : 'Connect'}
1414
</a>
1515
)

0 commit comments

Comments
 (0)