Merge fix/device-sso-error-display: display device-flow SSO error

This commit is contained in:
GareArc
2026-05-28 20:18:09 -07:00
3 changed files with 94 additions and 3 deletions

View File

@ -6,9 +6,10 @@ import DevicePage from '../page'
const mockPush = vi.fn()
const mockReplace = vi.fn()
const mockDeviceLookup = vi.fn()
let mockSearchParams: Record<string, string | null> = {}
vi.mock('@/next/navigation', () => ({
useSearchParams: () => ({ get: () => null }),
useSearchParams: () => ({ get: (key: string) => mockSearchParams[key] ?? null }),
useRouter: () => ({ push: mockPush, replace: mockReplace }),
usePathname: () => '/device',
}))
@ -53,6 +54,12 @@ let MockDeviceFlowError: MockDeviceFlowErrorCtor
beforeEach(async () => {
vi.clearAllMocks()
mockSearchParams = {}
// router.replace(pathname) in the real app drops the query string; mirror
// that so useSearchParams reflects the cleared URL on the next render.
mockReplace.mockImplementation(() => {
mockSearchParams = {}
})
mockUseQuery.mockReturnValue({ data: undefined, isError: false } as ReturnType<typeof useQuery>)
const mod = await import('@/service/device-flow') as { DeviceFlowError: MockDeviceFlowErrorCtor }
MockDeviceFlowError = mod.DeviceFlowError
@ -110,3 +117,43 @@ describe('error_lookup_failed terminal state', () => {
expect(screen.queryByText('Could not verify the code')).not.toBeInTheDocument()
})
})
describe('error_sso terminal state from sso_error param', () => {
it('shows "Single sign-on failed" heading when sso_error is present', async () => {
mockSearchParams = { sso_error: 'email_belongs_to_dify_account' }
render(<DevicePage />)
await screen.findByText('Single sign-on failed')
})
it('maps the error code to friendly copy', 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()
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')
expect(mockReplace).not.toHaveBeenCalled()
})
it('ghost button resets to code_entry and clears the URL', async () => {
mockSearchParams = { sso_error: 'email_belongs_to_dify_account' }
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')
})
})

View File

@ -14,7 +14,7 @@ import AuthorizeAccount from './components/authorize-account'
import AuthorizeSSO from './components/authorize-sso'
import Chooser from './components/chooser'
import CodeInput from './components/code-input'
import { classifyLookupError } from './utils/error-copy'
import { classifyLookupError, ssoErrorCopy } from './utils/error-copy'
import { isValidUserCode } from './utils/user-code'
type View
@ -26,6 +26,7 @@ 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()
@ -33,6 +34,7 @@ export default function DevicePage() {
const pathname = usePathname()
const urlUserCode = (searchParams.get('user_code') || '').trim().toUpperCase()
const ssoVerified = searchParams.get('sso_verified') === '1'
const ssoError = searchParams.get('sso_error') || ''
const [typed, setTyped] = useState('')
const [view, setView] = useState<View>({ kind: 'code_entry' })
@ -80,6 +82,14 @@ 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
@ -94,7 +104,7 @@ export default function DevicePage() {
}
if (consumed && (urlUserCode || ssoVerified))
router.replace(pathname)
}, [urlUserCode, ssoVerified, account, view, router, pathname])
}, [urlUserCode, ssoVerified, ssoError, account, view, router, pathname])
const onContinue = async () => {
if (!isValidUserCode(typed))
@ -265,6 +275,28 @@ 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>
)}

View File

@ -30,6 +30,18 @@ export function approveErrorCopy(err: unknown): string {
return DEFAULT_MESSAGE
}
// SSO-branch failures arrive as a `sso_error` query param set by the backend
// (oauth_device_sso sso-complete) when it redirects back to /device.
const SSO_ERROR_COPY: Record<string, string> = {
email_belongs_to_dify_account: 'This identity is linked to a Dify account. Use “Sign in with Dify account” instead.',
}
const DEFAULT_SSO_ERROR_MESSAGE = 'Single sign-on could not be completed. Try again.'
export function ssoErrorCopy(code: string): string {
return SSO_ERROR_COPY[code] ?? DEFAULT_SSO_ERROR_MESSAGE
}
export type LookupOutcome = 'expired' | 'rate_limited' | 'failed'
export function classifyLookupError(err: unknown): LookupOutcome {