dify/web/app/account/oauth/authorize/page.tsx
yyh 63ca2b94b5
refactor(web): use generated OAuth query options (#39204)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-17 14:50:56 +00:00

268 lines
9.2 KiB
TypeScript

'use client'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { Button } from '@langgenius/dify-ui/button'
import { toast } from '@langgenius/dify-ui/toast'
import {
RiAccountCircleLine,
RiGlobalLine,
RiInfoCardLine,
RiMailLine,
RiTranslate2,
} from '@remixicon/react'
import { skipToken, useMutation, useQuery } from '@tanstack/react-query'
import * as React from 'react'
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { isLegacyBase401, userProfileQueryOptions } from '@/features/account-profile/client'
import { useRouter, useSearchParams } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
import { useLogout } from '@/service/use-common'
function buildReturnUrl(pathname: string, search: string) {
try {
const base = `${globalThis.location.origin}${pathname}${search}`
return base
} catch {
return pathname + search
}
}
export default function OAuthAuthorize() {
const { t } = useTranslation()
const SCOPE_INFO_MAP: Record<
string,
{ icon: React.ComponentType<{ className?: string }>; label: string }
> = {
'read:name': {
icon: RiInfoCardLine,
label: t(($) => $['scopes.name'], { ns: 'oauth' }),
},
'read:email': {
icon: RiMailLine,
label: t(($) => $['scopes.email'], { ns: 'oauth' }),
},
'read:avatar': {
icon: RiAccountCircleLine,
label: t(($) => $['scopes.avatar'], { ns: 'oauth' }),
},
'read:interface_language': {
icon: RiTranslate2,
label: t(($) => $['scopes.languagePreference'], { ns: 'oauth' }),
},
'read:timezone': {
icon: RiGlobalLine,
label: t(($) => $['scopes.timezone'], { ns: 'oauth' }),
},
}
const router = useRouter()
const language = useLanguage()
const searchParams = useSearchParams()
const client_id = decodeURIComponent(searchParams.get('client_id') || '')
const redirect_uri = decodeURIComponent(searchParams.get('redirect_uri') || '')
const hasOAuthParams = Boolean(client_id && redirect_uri)
// Probe user profile. 401 stays as `error` (legitimate "not logged in" state),
// other errors throw to the nearest error.tsx; jumpTo same-pathname guard in
// service/base.ts prevents a redirect loop here.
const {
data: userProfileResp,
isPending: isProfileLoading,
error: profileError,
} = useQuery({
...userProfileQueryOptions(),
throwOnError: (err) => !isLegacyBase401(err),
})
const isLoggedIn = !!userProfileResp && !profileError
const userProfile = userProfileResp?.profile
const {
data: authAppInfo,
isLoading: isOAuthLoading,
isError,
} = useQuery(
consoleQuery.oauth.provider.post.queryOptions({
input: hasOAuthParams ? { body: { client_id, redirect_uri } } : skipToken,
context: { silent: true },
}),
)
const { mutateAsync: authorize, isPending: authorizing } = useMutation(
consoleQuery.oauth.provider.authorize.post.mutationOptions(),
)
const { mutateAsync: logout } = useLogout()
const hasNotifiedRef = useRef(false)
const localizedAppLabel = authAppInfo?.app_label[language]
const englishAppLabel = authAppInfo?.app_label.en_US
const appLabel =
(typeof localizedAppLabel === 'string' && localizedAppLabel) ||
(typeof englishAppLabel === 'string' && englishAppLabel) ||
t(($) => $.unknownApp, { ns: 'oauth' })
const isLoading = isOAuthLoading || isProfileLoading
const onLoginSwitchClick = async () => {
try {
const returnUrl = buildReturnUrl('/account/oauth/authorize', `?${searchParams.toString()}`)
if (isLoggedIn) await logout()
router.push(`/signin?redirect_url=${encodeURIComponent(returnUrl)}`)
} catch {
router.push('/signin')
}
}
const onAuthorize = async () => {
if (!client_id || !redirect_uri) return
try {
const { code } = await authorize({ body: { client_id } })
const url = new URL(redirect_uri)
url.searchParams.set('code', code)
globalThis.location.href = url.toString()
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error)
toast.error(`${t(($) => $['error.authorizeFailed'], { ns: 'oauth' })}: ${message}`)
}
}
useEffect(() => {
const invalidParams = !client_id || !redirect_uri
if ((invalidParams || isError) && !hasNotifiedRef.current) {
hasNotifiedRef.current = true
toast.error(
invalidParams
? t(($) => $['error.invalidParams'], { ns: 'oauth' })
: t(($) => $['error.authAppInfoFetchFailed'], { ns: 'oauth' }),
{ timeout: 0 },
)
}
}, [client_id, redirect_uri, isError])
if (isLoading) {
return (
<div className="bg-background-default-subtle">
<Loading type="app" />
</div>
)
}
return (
<div className="bg-background-default-subtle">
{authAppInfo?.app_icon && (
<div className="w-max rounded-2xl border-[0.5px] border-components-panel-border bg-text-primary-on-surface p-3 shadow-lg">
<img src={authAppInfo.app_icon} alt="app icon" className="size-10 rounded-sm" />
</div>
)}
<div className={`mt-5 mb-4 flex flex-col gap-2 ${isLoggedIn ? 'pb-2' : ''}`}>
<div className="title-4xl-semi-bold">
{isLoggedIn && (
<div className="text-text-primary">{t(($) => $.connect, { ns: 'oauth' })}</div>
)}
<div className="text-saas-dify-blue-inverted">{appLabel}</div>
{!isLoggedIn && (
<div className="text-text-primary">
{t(($) => $['tips.notLoggedIn'], { ns: 'oauth' })}
</div>
)}
</div>
<div className="body-md-regular text-text-secondary">
{isLoggedIn
? `${appLabel} ${t(($) => $['tips.loggedIn'], { ns: 'oauth' })}`
: t(($) => $['tips.needLogin'], { ns: 'oauth' })}
</div>
</div>
{isLoggedIn && userProfile && (
<div className="flex items-center justify-between rounded-xl bg-background-section-burn-inverted p-3">
<div className="flex items-center gap-2.5">
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="lg" />
<div>
<div className="system-md-semi-bold text-text-secondary">{userProfile.name}</div>
<div className="system-xs-regular text-text-tertiary">{userProfile.email}</div>
</div>
</div>
<Button variant="tertiary" size="small" onClick={onLoginSwitchClick}>
{t(($) => $.switchAccount, { ns: 'oauth' })}
</Button>
</div>
)}
{isLoggedIn && Boolean(authAppInfo?.scope) && (
<div className="mt-2 flex flex-col gap-2.5 rounded-xl bg-background-section-burn-inverted px-[22px] py-5 text-text-secondary">
{authAppInfo!.scope
.split(/\s+/)
.filter(Boolean)
.map((scope: string) => {
const Icon = SCOPE_INFO_MAP[scope]
return (
<div
key={scope}
className="flex items-center gap-2 body-sm-medium text-text-secondary"
>
{Icon ? (
<Icon.icon className="size-4" />
) : (
<RiAccountCircleLine className="size-4" />
)}
{Icon!.label}
</div>
)
})}
</div>
)}
<div className="flex flex-col items-center gap-2 pt-4">
{!isLoggedIn ? (
<Button variant="primary" size="large" className="w-full" onClick={onLoginSwitchClick}>
{t(($) => $.login, { ns: 'oauth' })}
</Button>
) : (
<>
<Button
variant="primary"
size="large"
className="w-full"
onClick={onAuthorize}
disabled={!client_id || !redirect_uri || isError || authorizing}
loading={authorizing}
>
{t(($) => $.continue, { ns: 'oauth' })}
</Button>
<Button size="large" className="w-full" onClick={() => router.push('/apps')}>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</Button>
</>
)}
</div>
<div className="mt-4 py-2">
<svg
xmlns="http://www.w3.org/2000/svg"
width="400"
height="1"
viewBox="0 0 400 1"
fill="none"
>
<path d="M0 0.5H400" stroke="url(#paint0_linear_2_5904)" />
<defs>
<linearGradient
id="paint0_linear_2_5904"
x1="400"
y1="9.49584"
x2="0.000228929"
y2="9.17666"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="white" stop-opacity="0.01" />
<stop offset="0.505" stop-color="#101828" stop-opacity="0.08" />
<stop offset="1" stop-color="white" stop-opacity="0.01" />
</linearGradient>
</defs>
</svg>
</div>
<div className="mt-3 system-xs-regular text-text-tertiary">
{t(($) => $['tips.common'], { ns: 'oauth' })}
</div>
</div>
)
}