mirror of
https://github.com/langgenius/dify.git
synced 2026-06-06 08:00:00 +08:00
fix(device): surface SSO errors on /device and fix CLI null-account crash on external-SSO login (#36781)
This commit is contained in:
@@ -40,7 +40,7 @@ export type PollSuccess = {
|
||||
subject_type?: string
|
||||
subject_email?: string
|
||||
subject_issuer?: string
|
||||
account?: PollAccount
|
||||
account?: PollAccount | null
|
||||
workspaces?: readonly PollWorkspace[]
|
||||
default_workspace_id?: string
|
||||
token_id?: string
|
||||
|
||||
@@ -106,6 +106,8 @@ describe('runLogin', () => {
|
||||
expect(bundle.account).toBeUndefined()
|
||||
expect(bundle.external_subject?.email).toBe('sso@dify.ai')
|
||||
expect(bundle.external_subject?.issuer).toBe('https://issuer.example')
|
||||
const stored = await store.get(bundle.current_host, 'sso@dify.ai')
|
||||
expect(stored).toBe('dfoe_test')
|
||||
expect(io.outBuf()).toContain('external SSO')
|
||||
expect(io.outBuf()).toContain('sso@dify.ai')
|
||||
})
|
||||
|
||||
@@ -99,7 +99,7 @@ function renderCodePrompt(w: NodeJS.WritableStream, cs: ReturnType<typeof colorS
|
||||
|
||||
function renderLoggedIn(w: NodeJS.WritableStream, cs: ReturnType<typeof colorScheme>, host: string, s: PollSuccess): void {
|
||||
const display = bareHost(host)
|
||||
if (s.account !== undefined && s.account.email !== '') {
|
||||
if (s.account && s.account.email !== '') {
|
||||
w.write(`${cs.successIcon()} Logged in to ${display} as ${cs.bold(s.account.email)} (${s.account.name})\n`)
|
||||
const ws = findDefaultWorkspace(s)
|
||||
if (ws !== undefined)
|
||||
@@ -139,11 +139,11 @@ function bundleFromSuccess(host: string, s: PollSuccess, mode: StorageMode): Hos
|
||||
token_id: s.token_id,
|
||||
tokens: { bearer: s.token },
|
||||
}
|
||||
if (s.account !== undefined) {
|
||||
if (s.account) {
|
||||
bundle.account = { id: s.account.id, email: s.account.email, name: s.account.name }
|
||||
}
|
||||
if (s.subject_email !== undefined && s.subject_email !== ''
|
||||
&& (s.account === undefined || s.account.id === '')) {
|
||||
&& (!s.account || s.account.id === '')) {
|
||||
bundle.external_subject = {
|
||||
email: s.subject_email,
|
||||
issuer: s.subject_issuer ?? '',
|
||||
|
||||
+3
@@ -356,6 +356,9 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
|
||||
subject_type: 'external_sso',
|
||||
subject_email: 'sso@dify.ai',
|
||||
subject_issuer: 'https://issuer.example',
|
||||
account: null,
|
||||
workspaces: [],
|
||||
default_workspace_id: null,
|
||||
token_id: 'tok-sso-1',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,41 @@ describe('error_lookup_failed terminal state', () => {
|
||||
expect(screen.queryByText('Could not verify the code')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
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 />)
|
||||
expect(await screen.findByText(SSO_BANNER_COPY)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
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(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 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(SSO_BANNER_COPY)
|
||||
expect(mockReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows no banner when sso_error is absent', () => {
|
||||
render(<DevicePage />)
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
expect(screen.queryByText(SSO_BANNER_COPY)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
@@ -33,6 +33,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' })
|
||||
@@ -125,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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user