From 63ca2b94b5871d7fc338334e70930500006b99b3 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Fri, 17 Jul 2026 22:50:56 +0800 Subject: [PATCH] refactor(web): use generated OAuth query options (#39204) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- oxlint-suppressions.json | 10 -- .../oauth/authorize/__tests__/page.spec.tsx | 113 ++++++++++++++++++ web/app/account/oauth/authorize/page.tsx | 37 ++++-- web/service/use-oauth.ts | 35 ------ 4 files changed, 137 insertions(+), 58 deletions(-) create mode 100644 web/app/account/oauth/authorize/__tests__/page.spec.tsx delete mode 100644 web/service/use-oauth.ts diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index e7d17b20f9e..a236d235f8a 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -261,11 +261,6 @@ "count": 1 } }, - "web/app/account/oauth/authorize/page.tsx": { - "typescript/no-explicit-any": { - "count": 1 - } - }, "web/app/components/app-sidebar/app-info/app-info-modals.tsx": { "jsx_a11y/label-has-associated-control": { "count": 1 @@ -6702,11 +6697,6 @@ "count": 1 } }, - "web/service/use-oauth.ts": { - "no-restricted-imports": { - "count": 1 - } - }, "web/service/use-pipeline.ts": { "@tanstack/query/exhaustive-deps": { "count": 1 diff --git a/web/app/account/oauth/authorize/__tests__/page.spec.tsx b/web/app/account/oauth/authorize/__tests__/page.spec.tsx new file mode 100644 index 00000000000..003673b0225 --- /dev/null +++ b/web/app/account/oauth/authorize/__tests__/page.spec.tsx @@ -0,0 +1,113 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import OAuthAuthorize from '../page' + +const mocks = vi.hoisted(() => ({ + push: vi.fn(), + request: vi.fn(), + searchParams: new URLSearchParams(), +})) + +vi.mock('@/next/navigation', () => ({ + useRouter: () => ({ push: mocks.push }), + useSearchParams: () => mocks.searchParams, +})) + +vi.mock('@/service/base', () => ({ + get: vi.fn( + async () => + new Response( + JSON.stringify({ + avatar_url: null, + email: 'user@example.com', + name: 'Test User', + }), + { status: 200 }, + ), + ), + post: vi.fn(), + request: (...args: unknown[]) => mocks.request(...args), + sseGeneratorPost: vi.fn(), +})) + +function renderPage() { + const queryClient = new QueryClient({ + defaultOptions: { + mutations: { retry: false }, + queries: { retry: false }, + }, + }) + + return render( + + + , + ) +} + +function jsonResponse(body: unknown) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) +} + +function findRequest(path: string) { + return mocks.request.mock.calls.find(([url]) => String(url).endsWith(path)) +} + +describe('OAuthAuthorize', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.searchParams = new URLSearchParams({ + client_id: 'client-1', + redirect_uri: 'https://client.example.com/callback?state=state-1', + }) + mocks.request.mockImplementation(async (url: string) => { + if (url.endsWith('/oauth/provider/authorize')) return jsonResponse({ code: 'oauth-code' }) + if (url.endsWith('/oauth/provider')) { + return jsonResponse({ + app_icon: '', + app_label: { en_US: 'Test OAuth App' }, + scope: '', + }) + } + throw new Error(`Unexpected request: ${url}`) + }) + vi.stubGlobal('location', { + href: 'https://dify.test/account/oauth/authorize', + origin: 'https://dify.test', + }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('authorizes the displayed app and redirects with the returned code', async () => { + const user = userEvent.setup() + renderPage() + + expect((await screen.findAllByText('Test OAuth App')).length).toBeGreaterThan(0) + const providerRequest = findRequest('/oauth/provider') + const providerTransportRequest = providerRequest?.[2]?.request as Request + await expect(providerTransportRequest.clone().json()).resolves.toEqual({ + client_id: 'client-1', + redirect_uri: 'https://client.example.com/callback?state=state-1', + }) + + await user.click(screen.getByRole('button', { name: /continue/i })) + + await waitFor(() => expect(findRequest('/oauth/provider/authorize')).toBeDefined()) + const authorizeRequest = findRequest('/oauth/provider/authorize') + const transportRequest = authorizeRequest?.[2]?.request as Request + await expect(transportRequest.clone().json()).resolves.toEqual({ client_id: 'client-1' }) + await waitFor(() => + expect(globalThis.location.href).toBe( + 'https://client.example.com/callback?state=state-1&code=oauth-code', + ), + ) + }) +}) diff --git a/web/app/account/oauth/authorize/page.tsx b/web/app/account/oauth/authorize/page.tsx index 5d2c5ab45b4..a927e318bc8 100644 --- a/web/app/account/oauth/authorize/page.tsx +++ b/web/app/account/oauth/authorize/page.tsx @@ -10,7 +10,7 @@ import { RiMailLine, RiTranslate2, } from '@remixicon/react' -import { useQuery } from '@tanstack/react-query' +import { skipToken, useMutation, useQuery } from '@tanstack/react-query' import * as React from 'react' import { useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' @@ -18,8 +18,8 @@ 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' -import { useAuthorizeOAuthApp, useOAuthAppInfo } from '@/service/use-oauth' function buildReturnUrl(pathname: string, search: string) { try { @@ -64,6 +64,7 @@ export default function OAuthAuthorize() { 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. @@ -81,10 +82,23 @@ export default function OAuthAuthorize() { data: authAppInfo, isLoading: isOAuthLoading, isError, - } = useOAuthAppInfo(client_id, redirect_uri) - const { mutateAsync: authorize, isPending: authorizing } = useAuthorizeOAuthApp() + } = 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 () => { @@ -100,12 +114,13 @@ export default function OAuthAuthorize() { const onAuthorize = async () => { if (!client_id || !redirect_uri) return try { - const { code } = await authorize({ client_id }) + const { code } = await authorize({ body: { client_id } }) const url = new URL(redirect_uri) url.searchParams.set('code', code) globalThis.location.href = url.toString() - } catch (err: any) { - toast.error(`${t(($) => $['error.authorizeFailed'], { ns: 'oauth' })}: ${err.message}`) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error) + toast.error(`${t(($) => $['error.authorizeFailed'], { ns: 'oauth' })}: ${message}`) } } @@ -143,11 +158,7 @@ export default function OAuthAuthorize() { {isLoggedIn && (
{t(($) => $.connect, { ns: 'oauth' })}
)} -
- {authAppInfo?.app_label[language] || - authAppInfo?.app_label?.en_US || - t(($) => $.unknownApp, { ns: 'oauth' })} -
+
{appLabel}
{!isLoggedIn && (
{t(($) => $['tips.notLoggedIn'], { ns: 'oauth' })} @@ -156,7 +167,7 @@ export default function OAuthAuthorize() {
{isLoggedIn - ? `${authAppInfo?.app_label[language] || authAppInfo?.app_label?.en_US || t(($) => $.unknownApp, { ns: 'oauth' })} ${t(($) => $['tips.loggedIn'], { ns: 'oauth' })}` + ? `${appLabel} ${t(($) => $['tips.loggedIn'], { ns: 'oauth' })}` : t(($) => $['tips.needLogin'], { ns: 'oauth' })}
diff --git a/web/service/use-oauth.ts b/web/service/use-oauth.ts deleted file mode 100644 index 4ef1b955e17..00000000000 --- a/web/service/use-oauth.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { useMutation, useQuery } from '@tanstack/react-query' -import { post } from './base' - -const NAME_SPACE = 'oauth-provider' - -type OAuthAppInfo = { - app_icon: string - app_label: Record - scope: string -} - -type OAuthAuthorizeResponse = { - code: string -} - -export const useOAuthAppInfo = (client_id: string, redirect_uri: string) => { - return useQuery({ - queryKey: [NAME_SPACE, 'authAppInfo', client_id, redirect_uri], - queryFn: () => - post( - '/oauth/provider', - { body: { client_id, redirect_uri } }, - { silent: true }, - ), - enabled: Boolean(client_id && redirect_uri), - }) -} - -export const useAuthorizeOAuthApp = () => { - return useMutation({ - mutationKey: [NAME_SPACE, 'authorize'], - mutationFn: (payload: { client_id: string }) => - post('/oauth/provider/authorize', { body: payload }), - }) -}