'use client' import { useEffect, useState } from 'react' import { usePathname, useRouter, useSearchParams } from '@/next/navigation' import { useQuery } from '@tanstack/react-query' import { systemFeaturesQueryOptions } from '@/service/system-features' import { commonQueryKeys, userProfileQueryOptions } from '@/service/use-common' import { post } from '@/service/base' import type { ICurrentWorkspace } from '@/models/common' import { deviceLookup } from '@/service/device-flow' import CodeInput from './components/code-input' import Chooser from './components/chooser' import AuthorizeAccount from './components/authorize-account' import AuthorizeSSO from './components/authorize-sso' import { isValidUserCode } from './utils/user-code' import { classifyLookupError } from './utils/error-copy' type View = | { kind: 'code_entry' } | { kind: 'chooser'; userCode: string } | { kind: 'authorize_account'; userCode: string } | { kind: 'authorize_sso' } | { kind: 'success' } | { kind: 'error_expired' } | { kind: 'error_rate_limited' } | { kind: 'error_lookup_failed' } export default function DevicePage() { const searchParams = useSearchParams() const router = useRouter() const pathname = usePathname() const urlUserCode = (searchParams.get('user_code') || '').trim().toUpperCase() const ssoVerified = searchParams.get('sso_verified') === '1' const [typed, setTyped] = useState('') const [view, setView] = useState({ kind: 'code_entry' }) const [errMsg, setErrMsg] = useState(null) // Account subject + workspace identity (for the authorize-account screen). // Logged-out is a valid landing state on /device — disable refetch storms // and skip workspace probe until profile resolves (avoids /current + chained // /refresh-token 401 loops while the user is still entering the code). const { data: userResp, isError: profileErr } = useQuery({ ...userProfileQueryOptions(), throwOnError: false, retry: false, refetchOnWindowFocus: false, refetchOnMount: false, }) const account = userResp?.profile const { data: currentWorkspace } = useQuery({ queryKey: commonQueryKeys.currentWorkspace, queryFn: () => post('/workspaces/current'), enabled: !!account && !profileErr, retry: false, refetchOnWindowFocus: false, }) const { data: sys } = useQuery(systemFeaturesQueryOptions()) // Device-flow SSO branch uses external-user (webapp) SSO, not console SSO — // backend mints EXTERNAL_SSO tokens via Enterprise's external ACS. Gate on // webapp_auth.{enabled, allow_sso} + a configured webapp SSO protocol. const ssoAvailable = !!sys?.webapp_auth?.enabled && !!sys?.webapp_auth?.allow_sso && (sys?.webapp_auth?.sso_config?.protocol || '') !== '' // URL-driven view transitions. Only advances while the user is still on // the entry/chooser screens — never clobbers terminal views (success / // error_expired / authorize_*) when userProfile refetches. // After consuming the params, scrub them from the URL so they don't // leak via history / Referer / server logs (RFC 8628 §5.4). useEffect(() => { if (view.kind !== 'code_entry' && view.kind !== 'chooser') return // Post-login bounce: chooser holds the typed code, account just loaded. // The URL was already scrubbed on the first effect run, so urlUserCode // is empty here — advance using the userCode stashed in view state. if (view.kind === 'chooser' && account) { setView({ kind: 'authorize_account', userCode: view.userCode }) return } let consumed = false if (ssoVerified) { setView({ kind: 'authorize_sso' }) consumed = true } else if (urlUserCode && isValidUserCode(urlUserCode)) { if (account) setView({ kind: 'authorize_account', userCode: urlUserCode }) else setView({ kind: 'chooser', userCode: urlUserCode }) consumed = true } if (consumed && (urlUserCode || ssoVerified)) router.replace(pathname) }, [urlUserCode, ssoVerified, account, view, router, pathname]) const onContinue = async () => { if (!isValidUserCode(typed)) return try { const reply = await deviceLookup(typed) if (!reply.valid) { setView({ kind: 'error_expired' }) return } } catch (e) { const outcome = classifyLookupError(e) if (outcome === 'rate_limited') setView({ kind: 'error_rate_limited' }) else if (outcome === 'failed') setView({ kind: 'error_lookup_failed' }) else setView({ kind: 'error_expired' }) return } if (account) setView({ kind: 'authorize_account', userCode: typed }) else setView({ kind: 'chooser', userCode: typed }) } return (
{view.kind === 'code_entry' && (

Authorize Dify CLI

Enter the code shown in your terminal.

)} {view.kind === 'chooser' && (

Sign in to authorize

Code {view.userCode} is valid. Choose how to sign in.

)} {view.kind === 'authorize_account' && ( setView({ kind: 'success' })} onDenied={() => setView({ kind: 'error_expired' })} onError={e => setErrMsg(e)} /> )} {view.kind === 'authorize_sso' && ( setView({ kind: 'success' })} onError={e => setErrMsg(e)} /> )} {view.kind === 'success' && (

You're signed in

Return to your terminal to continue.

)} {view.kind === 'error_expired' && (

This code is no longer valid

The code may have expired or already been used. Run {' '} difyctl auth login {' '} again to get a new one.

)} {view.kind === 'error_rate_limited' && (

Too many attempts

We've received too many requests for this code. Wait a moment and try again.

)} {view.kind === 'error_lookup_failed' && (

Could not verify the code

Something went wrong on our side. Try again in a moment.

)} {errMsg && (

{errMsg}

)}
) }