diff --git a/web/service/base.signin-redirect.spec.ts b/web/service/base.signin-redirect.spec.ts index 6ec5f379fb..08b780a687 100644 --- a/web/service/base.signin-redirect.spec.ts +++ b/web/service/base.signin-redirect.spec.ts @@ -23,6 +23,23 @@ describe('buildSigninUrlWithRedirect', () => { }) }) + describe('Non-OAuth redirect handling', () => { + it('should return plain signin URL without oauth_redirect_url for generic pages', () => { + // Arrange + const currentLocation = { + origin: 'https://example.com', + pathname: '/apps', + search: '?tab=all', + } as const + + // Act + const signinUrl = buildSigninUrlWithRedirect(currentLocation, '') + + // Assert + expect(signinUrl).toBe('https://example.com/signin') + }) + }) + describe('Signin self-redirect guard', () => { it('should return plain signin URL when current path is already signin', () => { // Arrange @@ -41,12 +58,12 @@ describe('buildSigninUrlWithRedirect', () => { }) describe('basePath support', () => { - it('should respect basePath when building signin URL', () => { + it('should respect basePath for OAuth authorize path', () => { // Arrange const currentLocation = { origin: 'https://example.com', - pathname: '/console/apps', - search: '?tab=all', + pathname: '/console/account/oauth/authorize', + search: '?client_id=abc', } as const // Act @@ -55,7 +72,7 @@ describe('buildSigninUrlWithRedirect', () => { // Assert expect(signinUrl.startsWith('https://example.com/console/signin?')).toBe(true) const encodedRedirect = new URL(signinUrl).searchParams.get('oauth_redirect_url') - expect(decodeURIComponent(encodedRedirect || '')).toBe('https://example.com/console/apps?tab=all') + expect(decodeURIComponent(encodedRedirect || '')).toBe('https://example.com/console/account/oauth/authorize?client_id=abc') }) }) }) diff --git a/web/service/base.ts b/web/service/base.ts index f01940942e..107920f758 100644 --- a/web/service/base.ts +++ b/web/service/base.ts @@ -38,6 +38,7 @@ import { getWebAppPassport } from './webapp-auth' const TIME_OUT = 100000 const SIGNIN_REDIRECT_URL_KEY = 'oauth_redirect_url' +const OAUTH_AUTHORIZE_PATH = '/account/oauth/authorize' export type IconObject = { background: string @@ -160,11 +161,17 @@ function jumpTo(url: string) { export function buildSigninUrlWithRedirect(currentLocation: Pick, currentBasePath: string) { const signinPath = `${currentBasePath}/signin` const signinUrl = `${currentLocation.origin}${signinPath}` + const oauthAuthorizePath = `${currentBasePath}${OAUTH_AUTHORIZE_PATH}` // Keep signin as-is to avoid redirect loops. if (currentLocation.pathname === signinPath) return signinUrl + // Only OAuth authorization flow requires preserving the full original URL. + // For generic 401 redirects (e.g. manual logout), keep signin URL clean. + if (currentLocation.pathname !== oauthAuthorizePath) + return signinUrl + const currentUrl = `${currentLocation.origin}${currentLocation.pathname}${currentLocation.search}` const params = new URLSearchParams() params.set(SIGNIN_REDIRECT_URL_KEY, encodeURIComponent(currentUrl)) diff --git a/web/service/use-common.spec.tsx b/web/service/use-common.spec.tsx new file mode 100644 index 0000000000..8499b1db86 --- /dev/null +++ b/web/service/use-common.spec.tsx @@ -0,0 +1,43 @@ +import type { ReactNode } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { post } from './base' +import { commonQueryKeys, useLogout } from './use-common' + +vi.mock('./base', () => ({ + get: vi.fn(), + post: vi.fn(), +})) + +const createWrapper = (queryClient: QueryClient) => { + return ({ children }: { children: ReactNode }) => ( + + {children} + + ) +} + +describe('useLogout', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should reset login cache when logout succeeds', async () => { + // Arrange + const queryClient = new QueryClient() + const wrapper = createWrapper(queryClient) + queryClient.setQueryData(commonQueryKeys.isLogin, { logged_in: true }) + vi.mocked(post).mockResolvedValue({ result: 'success' } as never) + const { result } = renderHook(() => useLogout(), { wrapper }) + + // Act + await act(async () => { + await result.current.mutateAsync() + }) + + // Assert + expect(post).toHaveBeenCalledWith('/logout') + expect(queryClient.getQueryData(commonQueryKeys.isLogin)).toEqual({ logged_in: false }) + }) +}) diff --git a/web/service/use-common.ts b/web/service/use-common.ts index 002d154d1d..bac1a95160 100644 --- a/web/service/use-common.ts +++ b/web/service/use-common.ts @@ -22,7 +22,7 @@ import type { UserProfileResponse, } from '@/models/common' import type { RETRIEVE_METHOD } from '@/types/app' -import { useMutation, useQuery } from '@tanstack/react-query' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { IS_DEV } from '@/config' import { get, post } from './base' import { useInvalid } from './use-base' @@ -232,9 +232,15 @@ export const useIsLogin = () => { } export const useLogout = () => { + const queryClient = useQueryClient() + return useMutation({ mutationKey: [NAME_SPACE, 'logout'], mutationFn: () => post('/logout'), + onSuccess: () => { + queryClient.setQueryData(commonQueryKeys.isLogin, { logged_in: false }) + queryClient.removeQueries({ queryKey: commonQueryKeys.userProfile }) + }, }) }