mirror of
https://github.com/langgenius/dify.git
synced 2026-05-11 23:18:39 +08:00
When an unauthenticated user submits a user_code, the chooser view holds the typed code and redirects to /signin. After login, the page re-mounts on /device with no URL params (already scrubbed on the first render) and account loaded — but the existing useEffect path only advanced when ssoVerified or urlUserCode was present. Add an early branch: if view is chooser and account just loaded, advance to authorize_account using the userCode stashed in view state. Also widen the effect deps to view (not view.kind) so the nested userCode reads stay current.
216 lines
8.4 KiB
TypeScript
216 lines
8.4 KiB
TypeScript
'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<View>({ kind: 'code_entry' })
|
|
const [errMsg, setErrMsg] = useState<string | null>(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<ICurrentWorkspace>({
|
|
queryKey: commonQueryKeys.currentWorkspace,
|
|
queryFn: () => post<ICurrentWorkspace>('/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 (
|
|
<main className="mx-auto flex min-h-screen max-w-lg flex-col items-center justify-center px-6 py-10">
|
|
<div className="w-full rounded-xl border border-components-panel-border bg-components-panel-bg p-8 shadow-sm">
|
|
{view.kind === 'code_entry' && (
|
|
<div className="flex flex-col gap-5">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold text-text-primary">Authorize Dify CLI</h1>
|
|
<p className="mt-2 text-sm text-text-secondary">
|
|
Enter the code shown in your terminal.
|
|
</p>
|
|
</div>
|
|
<CodeInput value={typed} onChange={setTyped} autoFocus />
|
|
<button
|
|
onClick={onContinue}
|
|
disabled={!isValidUserCode(typed)}
|
|
className="rounded-lg bg-components-button-primary-bg px-4 py-3 text-components-button-primary-text font-medium hover:bg-components-button-primary-bg-hover disabled:opacity-50"
|
|
>
|
|
Continue
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{view.kind === 'chooser' && (
|
|
<div className="flex flex-col gap-5">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold text-text-primary">Sign in to authorize</h1>
|
|
<p className="mt-2 text-sm text-text-secondary">
|
|
Code <span className="font-mono">{view.userCode}</span> is valid. Choose how to sign in.
|
|
</p>
|
|
</div>
|
|
<Chooser userCode={view.userCode} ssoAvailable={ssoAvailable} />
|
|
</div>
|
|
)}
|
|
|
|
{view.kind === 'authorize_account' && (
|
|
<AuthorizeAccount
|
|
userCode={view.userCode}
|
|
accountEmail={account?.email}
|
|
defaultWorkspace={currentWorkspace?.name}
|
|
onApproved={() => setView({ kind: 'success' })}
|
|
onDenied={() => setView({ kind: 'error_expired' })}
|
|
onError={e => setErrMsg(e)}
|
|
/>
|
|
)}
|
|
|
|
{view.kind === 'authorize_sso' && (
|
|
<AuthorizeSSO
|
|
onApproved={() => setView({ kind: 'success' })}
|
|
onError={e => setErrMsg(e)}
|
|
/>
|
|
)}
|
|
|
|
{view.kind === 'success' && (
|
|
<div>
|
|
<h1 className="text-2xl font-semibold text-text-primary">You're signed in</h1>
|
|
<p className="mt-2 text-sm text-text-secondary">Return to your terminal to continue.</p>
|
|
</div>
|
|
)}
|
|
|
|
{view.kind === 'error_expired' && (
|
|
<div>
|
|
<h1 className="text-2xl font-semibold text-text-primary">This code is no longer valid</h1>
|
|
<p className="mt-2 text-sm text-text-secondary">
|
|
The code may have expired or already been used. Run
|
|
{' '}
|
|
<code className="rounded bg-components-panel-bg px-1">difyctl auth login</code>
|
|
{' '}
|
|
again to get a new one.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{view.kind === 'error_rate_limited' && (
|
|
<div>
|
|
<h1 className="text-2xl font-semibold text-text-primary">Too many attempts</h1>
|
|
<p className="mt-2 text-sm text-text-secondary">
|
|
We've received too many requests for this code. Wait a moment and try again.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{view.kind === 'error_lookup_failed' && (
|
|
<div>
|
|
<h1 className="text-2xl font-semibold text-text-primary">Could not verify the code</h1>
|
|
<p className="mt-2 text-sm text-text-secondary">
|
|
Something went wrong on our side. Try again in a moment.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{errMsg && (
|
|
<p className="mt-4 text-sm text-text-destructive">{errMsg}</p>
|
|
)}
|
|
</div>
|
|
</main>
|
|
)
|
|
}
|