fix(device): show sso_error as inline banner on code-entry page

Replace the standalone error_sso terminal view with an inline banner
derived directly from the sso_error query param on the code-entry
screen. The banner is pure-rendered from the URL (no effect, no extra
state), so it survives re-render/remount and the error is shown on the
main page instead of a separate view.
This commit is contained in:
GareArc
2026-05-28 21:14:49 -07:00
parent 1fd740cbbe
commit 7bc391a0fc
2 changed files with 25 additions and 52 deletions

View File

@ -118,42 +118,40 @@ describe('error_lookup_failed terminal state', () => {
})
})
describe('error_sso terminal state from sso_error param', () => {
it('shows "Single sign-on failed" heading when sso_error is present', async () => {
describe('sso_error inline banner on the code-entry page', () => {
const SSO_BANNER_COPY = /identity is linked to a Dify account/i
it('shows the error banner with friendly copy when sso_error is present', async () => {
mockSearchParams = { sso_error: 'email_belongs_to_dify_account' }
render(<DevicePage />)
await screen.findByText('Single sign-on failed')
expect(await screen.findByText(SSO_BANNER_COPY)).toBeInTheDocument()
})
it('maps the error code to friendly copy', async () => {
it('keeps the code-entry screen visible (error on main page, not a separate view)', async () => {
mockSearchParams = { sso_error: 'email_belongs_to_dify_account' }
render(<DevicePage />)
await screen.findByText('Single sign-on failed')
expect(screen.getByText(/Dify account/i)).toBeInTheDocument()
await screen.findByText(SSO_BANNER_COPY)
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Continue/i })).toBeInTheDocument()
})
it('does not surface the raw backend error code', async () => {
mockSearchParams = { sso_error: 'email_belongs_to_dify_account' }
render(<DevicePage />)
await screen.findByText(SSO_BANNER_COPY)
expect(screen.queryByText('email_belongs_to_dify_account')).not.toBeInTheDocument()
})
it('does not silently bounce to the code-entry screen', async () => {
mockSearchParams = { sso_error: 'email_belongs_to_dify_account' }
render(<DevicePage />)
await screen.findByText('Single sign-on failed')
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
it('does not scrub the param on mount (regression: error was wiped by router.replace)', async () => {
mockSearchParams = { sso_error: 'email_belongs_to_dify_account' }
render(<DevicePage />)
await screen.findByText('Single sign-on failed')
await screen.findByText(SSO_BANNER_COPY)
expect(mockReplace).not.toHaveBeenCalled()
})
it('ghost button resets to code_entry and clears the URL', async () => {
mockSearchParams = { sso_error: 'email_belongs_to_dify_account' }
it('shows no banner when sso_error is absent', () => {
render(<DevicePage />)
await screen.findByText('Single sign-on failed')
fireEvent.click(screen.getByRole('button', { name: /Try a different code/i }))
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.queryByText('Single sign-on failed')).not.toBeInTheDocument()
expect(mockReplace).toHaveBeenCalledWith('/device')
expect(screen.queryByText(SSO_BANNER_COPY)).not.toBeInTheDocument()
})
})

View File

@ -26,7 +26,6 @@ type View
| { kind: 'error_expired' }
| { kind: 'error_rate_limited' }
| { kind: 'error_lookup_failed' }
| { kind: 'error_sso', code: string }
export default function DevicePage() {
const searchParams = useSearchParams()
@ -82,14 +81,6 @@ export default function DevicePage() {
setView({ kind: 'authorize_account', userCode: view.userCode }) // eslint-disable-line react/set-state-in-effect
return
}
// sso_error is a non-sensitive backend error code. Drive a stable terminal
// view from it and leave it in the URL — scrubbing it here (router.replace)
// remounts the page in the same tick and wipes the just-set state, so the
// error never renders. The top guard stops re-runs from clobbering it.
if (ssoError) {
setView({ kind: 'error_sso', code: ssoError }) // eslint-disable-line react/set-state-in-effect
return
}
let consumed = false
if (ssoVerified) {
setView({ kind: 'authorize_sso' }) // eslint-disable-line react/set-state-in-effect
@ -104,7 +95,7 @@ export default function DevicePage() {
}
if (consumed && (urlUserCode || ssoVerified))
router.replace(pathname)
}, [urlUserCode, ssoVerified, ssoError, account, view, router, pathname])
}, [urlUserCode, ssoVerified, account, view, router, pathname])
const onContinue = async () => {
if (!isValidUserCode(typed))
@ -135,6 +126,12 @@ export default function DevicePage() {
<>
{view.kind === 'code_entry' && (
<div className="flex flex-col gap-5">
{ssoError && (
<div className="flex items-start gap-2 rounded-lg bg-state-destructive-hover p-3">
<span className="mt-0.5 i-ri-close-circle-line h-4 w-4 shrink-0 text-util-colors-red-red-600" />
<p className="text-sm text-text-destructive">{ssoErrorCopy(ssoError)}</p>
</div>
)}
<div>
<h1 className="text-2xl font-semibold text-text-primary">Authorize Dify CLI</h1>
<p className="mt-2 text-sm text-text-secondary">
@ -275,28 +272,6 @@ export default function DevicePage() {
</div>
)}
{view.kind === 'error_sso' && (
<div className="flex flex-col gap-1">
<div className="mb-2.5 flex h-[38px] w-[38px] items-center justify-center rounded-full bg-state-destructive-hover">
<span className="i-ri-close-circle-line h-[18px] w-[18px] text-util-colors-red-red-600" />
</div>
<h1 className="text-xl font-semibold text-text-primary">Single sign-on failed</h1>
<p className="text-sm text-text-secondary">{ssoErrorCopy(view.code)}</p>
<Divider className="my-3" />
<Button
variant="ghost"
className="w-full"
onClick={() => {
router.replace(pathname)
setView({ kind: 'code_entry' })
setErrMsg(null)
}}
>
Try a different code
</Button>
</div>
)}
{errMsg && (
<p className="mt-4 text-sm text-text-destructive">{errMsg}</p>
)}