diff --git a/web/app/device/__tests__/page-terminal.spec.tsx b/web/app/device/__tests__/page-terminal.spec.tsx index 57d749897c..cc8ee84163 100644 --- a/web/app/device/__tests__/page-terminal.spec.tsx +++ b/web/app/device/__tests__/page-terminal.spec.tsx @@ -6,9 +6,10 @@ import DevicePage from '../page' const mockPush = vi.fn() const mockReplace = vi.fn() const mockDeviceLookup = vi.fn() +let mockSearchParams: Record = {} 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) 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() + 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() + 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() + 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() + 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() + 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') + }) +}) diff --git a/web/app/device/page.tsx b/web/app/device/page.tsx index 83def36a75..544bb19274 100644 --- a/web/app/device/page.tsx +++ b/web/app/device/page.tsx @@ -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({ 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() { )} + {view.kind === 'error_sso' && ( +
+
+ +
+

Single sign-on failed

+

{ssoErrorCopy(view.code)}

+ + +
+ )} + {errMsg && (

{errMsg}

)} diff --git a/web/app/device/utils/error-copy.ts b/web/app/device/utils/error-copy.ts index 9360fb167e..d0184dad7b 100644 --- a/web/app/device/utils/error-copy.ts +++ b/web/app/device/utils/error-copy.ts @@ -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 = { + 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 {