Skip to content

Commit f2fbd76

Browse files
committed
fix(auth): unify CLI completion screens and simplify consent
1 parent 7f68209 commit f2fbd76

7 files changed

Lines changed: 53 additions & 40 deletions

File tree

apps/sim/app/(auth)/components/auth-header.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { ReactNode } from 'react'
22

33
interface AuthHeaderProps {
44
title: string
5-
description: ReactNode
5+
description?: ReactNode
66
}
77

88
/**
@@ -15,7 +15,9 @@ export function AuthHeader({ title, description }: AuthHeaderProps) {
1515
return (
1616
<div className='space-y-1 text-center'>
1717
<h1 className='text-balance text-[32px] text-[var(--text-primary)] leading-[1.2]'>{title}</h1>
18-
<p className='text-[var(--text-muted)] text-base leading-[1.5]'>{description}</p>
18+
{description != null && (
19+
<p className='text-[var(--text-muted)] text-base leading-[1.5]'>{description}</p>
20+
)}
1921
</div>
2022
)
2123
}

apps/sim/app/(auth)/oauth/consent/consent-view.tsx

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -136,11 +136,7 @@ export function OAuthConsentView({
136136
<div className='space-y-6'>
137137
<AuthHeader
138138
title={`Authorize ${appName}`}
139-
description={
140-
isCli
141-
? 'Only continue if you started this from the Sim CLI in your terminal.'
142-
: 'Only continue if you started this yourself.'
143-
}
139+
description={isCli ? undefined : 'Only continue if you started this yourself.'}
144140
/>
145141
<div className='space-y-4'>
146142
{scopes.length > 0 && (
Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
import { AuthHeader } from '@/app/(auth)/components'
2+
import type { CliAuthDoneStatus } from '@/app/cli/auth/done/search-params'
23

3-
/**
4-
* Where the CLI's listener sends the browser once it has the authorization
5-
* code. Static by design: the key is minted server-side during the CLI's
6-
* exchange and never passes through this page.
7-
*/
8-
export function CliAuthDoneView() {
4+
interface CliAuthDoneViewProps {
5+
status: CliAuthDoneStatus
6+
}
7+
8+
export function CliAuthDoneView({ status }: CliAuthDoneViewProps) {
99
return (
1010
<AuthHeader
11-
title='Approved'
12-
description='Your terminal is finishing up — you can close this tab.'
11+
title={status === 'cancelled' ? 'Sign-in cancelled' : 'Approved'}
12+
description={
13+
status === 'cancelled'
14+
? 'You can close this tab and return to your terminal.'
15+
: 'Your terminal is finishing up — you can close this tab.'
16+
}
1317
/>
1418
)
1519
}
Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,28 @@
11
import type { Metadata } from 'next'
2+
import type { SearchParams } from 'nuqs/server'
23
import { AuthShell } from '@/app/(auth)/components'
34
import { CliAuthDoneView } from '@/app/cli/auth/done/cli-auth-done-view'
5+
import { cliAuthDoneSearchParamsCache } from '@/app/cli/auth/done/search-params'
46

57
export const metadata: Metadata = {
6-
title: 'Terminal connected',
8+
title: 'Terminal sign-in',
79
robots: { index: false, follow: false },
810
}
911

1012
/**
11-
* The CLI's loopback listener redirects here, so the flow ends on Sim's own
12-
* chrome instead of a page served by the wizard. Public and sessionless on
13-
* purpose — it renders a static confirmation and never touches the API.
13+
* Sessionless completion for OAuth and pairing flows. The status is informational
14+
* and never exchanges credentials or changes authorization.
1415
*/
15-
export default function CliAuthDonePage() {
16+
export default async function CliAuthDonePage({
17+
searchParams,
18+
}: {
19+
searchParams: Promise<SearchParams>
20+
}) {
21+
const { status } = await cliAuthDoneSearchParamsCache.parse(searchParams)
22+
1623
return (
1724
<AuthShell>
18-
<CliAuthDoneView />
25+
<CliAuthDoneView status={status} />
1926
</AuthShell>
2027
)
2128
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { createSearchParamsCache, parseAsStringLiteral } from 'nuqs/server'
2+
3+
const CLI_AUTH_DONE_STATUSES = ['approved', 'cancelled'] as const
4+
5+
export type CliAuthDoneStatus = (typeof CLI_AUTH_DONE_STATUSES)[number]
6+
7+
/** Informational only; an omitted status preserves existing pairing callbacks. */
8+
export const cliAuthDoneSearchParamsCache = createSearchParamsCache({
9+
status: parseAsStringLiteral(CLI_AUTH_DONE_STATUSES).withDefault('approved'),
10+
})

packages/sim-cli/src/auth/oauth-flow.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,15 +283,21 @@ describe('loginWithBrowser', () => {
283283
})
284284

285285
it('reports a declined consent as a cancellation, not a server failure', async () => {
286-
const { login, fetchMock } = await completeInBrowser((_params, state) => ({
286+
const { login, fetchMock, callback } = await completeInBrowser((_params, state) => ({
287287
error: 'access_denied',
288+
error_description: 'Do not forward provider data to the completion page',
288289
state,
289290
}))
290291

291292
const failure = await login.catch((error) => error)
292293
expect(failure).toBeInstanceOf(SimApiError)
293294
expect(failure.message).toBe('Sign-in was declined in the browser.')
294295
expect(fetchMock).not.toHaveBeenCalled()
296+
const response = await callback
297+
expect(response.statusCode).toBe(302)
298+
expect(response.headers.location).toBe(`${ENDPOINT}/cli/auth/done?status=cancelled`)
299+
expect(response.headers['referrer-policy']).toBe('no-referrer')
300+
expect(response.headers['cache-control']).toBe('no-store')
295301
})
296302

297303
it('gives up after the timeout with the browserless fallback named', async () => {

packages/sim-cli/src/auth/oauth-flow.ts

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -394,13 +394,6 @@ export async function revokeToken(endpoint: string, token: string): Promise<void
394394
}
395395
}
396396

397-
const PAGE_STYLE =
398-
'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0;color:#111;background:#fff'
399-
400-
function callbackPage(title: string, body: string): string {
401-
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${title}</title></head><body style="${PAGE_STYLE}"><main style="text-align:center;max-width:28rem;padding:2rem"><h1 style="font-weight:400;font-size:1.5rem;margin:0 0 .5rem">${title}</h1><p style="margin:0;color:#666">${body}</p></main></body></html>`
402-
}
403-
404397
interface LoopbackResult {
405398
code: string
406399
}
@@ -468,22 +461,17 @@ function listenForCallback(
468461
*/
469462
if (state !== expectedState) {
470463
response
471-
.writeHead(400, { 'content-type': 'text/html; charset=utf-8' })
464+
.writeHead(400, { 'content-type': 'text/plain; charset=utf-8' })
472465
.end(
473-
callbackPage(
474-
'Sign-in mismatch',
475-
'This response did not come from the sign-in this terminal started. Return to your terminal.'
476-
)
466+
'Sign-in mismatch. This response did not come from the sign-in this terminal started. Return to your terminal.'
477467
)
478468
return
479469
}
480470
if (error || !code) {
481471
const description = url.searchParams.get('error_description') ?? undefined
482-
response
483-
.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
484-
.end(
485-
callbackPage('Sign-in cancelled', 'You can close this tab and return to your terminal.')
486-
)
472+
const cancelledUrl = new URL(completionUrl)
473+
cancelledUrl.searchParams.set('status', 'cancelled')
474+
response.writeHead(302, { location: cancelledUrl.toString() }).end()
487475
finish({
488476
ok: false,
489477
error:
@@ -506,7 +494,7 @@ function listenForCallback(
506494

507495
export interface BrowserLoginOptions {
508496
scopes: readonly string[]
509-
/** Pin the loopback port, for a container that forwards a fixed one. */
497+
/** Pin the loopback port when an SSH tunnel forwards that same port. */
510498
callbackPort?: number
511499
/** Called with the authorize URL once the listener is up, before waiting. */
512500
onAuthorizeUrl: (url: string) => void

0 commit comments

Comments
 (0)