From 2c4d8ef098352ebdba3a4aaabcd03df8bf1d5388 Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Tue, 14 Jul 2026 15:47:34 +0800 Subject: [PATCH] fix(auth): preserve redirect URL across auth flows (#38905) Co-authored-by: CodingOnStar --- .../set-password/__tests__/page.spec.tsx | 109 ++++++++++++++++++ web/app/reset-password/set-password/page.tsx | 8 ++ web/app/signin/__tests__/normal-form.spec.tsx | 41 ++++++- .../signin/__tests__/one-more-step.spec.tsx | 62 ++++++++++ web/app/signin/normal-form.tsx | 4 +- web/app/signin/one-more-step.tsx | 5 +- web/app/signup/components/input-mail.spec.tsx | 27 +++++ web/app/signup/components/input-mail.tsx | 6 +- .../set-password/__tests__/page.spec.tsx | 16 +++ web/app/signup/set-password/page.tsx | 18 ++- web/service/__tests__/base-request.spec.ts | 96 ++++++++++++++- web/service/__tests__/base.spec.ts | 41 ++++++- web/service/base.ts | 21 +++- 13 files changed, 435 insertions(+), 19 deletions(-) create mode 100644 web/app/reset-password/set-password/__tests__/page.spec.tsx create mode 100644 web/app/signin/__tests__/one-more-step.spec.tsx diff --git a/web/app/reset-password/set-password/__tests__/page.spec.tsx b/web/app/reset-password/set-password/__tests__/page.spec.tsx new file mode 100644 index 00000000000..11225d344e2 --- /dev/null +++ b/web/app/reset-password/set-password/__tests__/page.spec.tsx @@ -0,0 +1,109 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { useRouter, useSearchParams } from '@/next/navigation' +import { changePasswordWithToken } from '@/service/common' +import ChangePasswordForm from '../page' + +const countdownState = vi.hoisted(() => ({ + onEnd: undefined as (() => void) | undefined, +})) + +vi.mock('ahooks', () => ({ + useCountDown: ({ leftTime, onEnd }: { leftTime?: number; onEnd?: () => void }) => { + countdownState.onEnd = onEnd + return [leftTime ?? 0] + }, +})) + +vi.mock('@/next/navigation', () => ({ + useRouter: vi.fn(), + useSearchParams: vi.fn(), +})) + +vi.mock('@/service/common', () => ({ + changePasswordWithToken: vi.fn(), +})) + +const mockReplace = vi.fn() +const mockUseRouter = vi.mocked(useRouter) +const mockUseSearchParams = vi.mocked(useSearchParams) +const mockChangePasswordWithToken = vi.mocked(changePasswordWithToken) + +const redirectUrl = '/apps?template-id=template-1&utm_source=dify_blog' +const encodedSigninUrl = + '/signin?redirect_url=%2Fapps%3Ftemplate-id%3Dtemplate-1%26utm_source%3Ddify_blog' + +const setSearchParams = (params: Record) => { + mockUseSearchParams.mockReturnValue( + new URLSearchParams(params) as unknown as ReturnType, + ) +} + +const completePasswordChange = async () => { + render() + + fireEvent.change(screen.getByLabelText('common.account.newPassword'), { + target: { value: 'ValidPass123!' }, + }) + fireEvent.change(screen.getByLabelText('common.account.confirmPassword'), { + target: { value: 'ValidPass123!' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'login.changePasswordBtn' })) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /login\.passwordChanged/ })).toBeInTheDocument() + }) +} + +describe('Reset Password Set Password Page', () => { + beforeEach(() => { + vi.clearAllMocks() + countdownState.onEnd = undefined + mockUseRouter.mockReturnValue({ replace: mockReplace } as unknown as ReturnType< + typeof useRouter + >) + mockChangePasswordWithToken.mockResolvedValue({ result: 'success' }) + setSearchParams({ token: 'reset-token' }) + }) + + describe('Post-reset navigation', () => { + it('should preserve redirect_url when the user returns to sign in manually', async () => { + setSearchParams({ token: 'reset-token', redirect_url: redirectUrl }) + await completePasswordChange() + + fireEvent.click(screen.getByRole('button', { name: /login\.passwordChanged/ })) + + expect(mockReplace).toHaveBeenCalledWith(encodedSigninUrl) + }) + + it('should preserve redirect_url when the countdown returns to sign in automatically', async () => { + setSearchParams({ token: 'reset-token', redirect_url: redirectUrl }) + await completePasswordChange() + + expect(countdownState.onEnd).toBeTypeOf('function') + act(() => countdownState.onEnd?.()) + + expect(mockReplace).toHaveBeenCalledWith(encodedSigninUrl) + }) + + it('should preserve the activation redirect when an invite token is present', async () => { + setSearchParams({ + token: 'reset-token', + invite_token: 'invite-token', + redirect_url: redirectUrl, + }) + await completePasswordChange() + + fireEvent.click(screen.getByRole('button', { name: /login\.passwordChanged/ })) + + expect(mockReplace).toHaveBeenCalledWith('/activate?token=invite-token') + }) + + it('should return to plain sign in when no redirect target is present', async () => { + await completePasswordChange() + + fireEvent.click(screen.getByRole('button', { name: /login\.passwordChanged/ })) + + expect(mockReplace).toHaveBeenCalledWith('/signin') + }) + }) +}) diff --git a/web/app/reset-password/set-password/page.tsx b/web/app/reset-password/set-password/page.tsx index 1c8b72024c3..14beddb53c4 100644 --- a/web/app/reset-password/set-password/page.tsx +++ b/web/app/reset-password/set-password/page.tsx @@ -33,6 +33,14 @@ const ChangePasswordForm = () => { params.set('token', searchParams.get('invite_token') as string) return `/activate?${params.toString()}` } + + const redirectUrl = searchParams.get('redirect_url') + if (redirectUrl) { + const params = new URLSearchParams() + params.set('redirect_url', redirectUrl) + return `/signin?${params.toString()}` + } + return '/signin' } diff --git a/web/app/signin/__tests__/normal-form.spec.tsx b/web/app/signin/__tests__/normal-form.spec.tsx index 14131c03942..a5e39601b29 100644 --- a/web/app/signin/__tests__/normal-form.spec.tsx +++ b/web/app/signin/__tests__/normal-form.spec.tsx @@ -1,5 +1,5 @@ -import { useQuery, useSuspenseQuery } from '@tanstack/react-query' -import { render, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider, useQuery, useSuspenseQuery } from '@tanstack/react-query' +import { render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { useRouter, useSearchParams } from '@/next/navigation' import NormalForm from '../normal-form' @@ -147,4 +147,41 @@ describe('NormalForm', () => { }) }) }) + + describe('Registration Navigation', () => { + it('should preserve the current query when navigating to sign up', () => { + mockUseSearchParams.mockReturnValue( + new URLSearchParams('redirect_url=%2Fapps%3Ftag%3Dworkflow&source=pricing'), + ) + mockUseQuery.mockReturnValue(nonInviteQueryResult as unknown as ReturnType) + mockUseSuspenseQuery.mockReturnValue({ + data: { + enable_social_oauth_login: false, + sso_enforced_for_signin: false, + enable_email_code_login: false, + enable_email_password_login: true, + is_email_setup: true, + is_allow_register: true, + license: { + status: 'none', + }, + branding: { + enabled: true, + }, + }, + } as unknown as ReturnType) + + const queryClient = new QueryClient() + render( + + + , + ) + + expect(screen.getByRole('link', { name: 'login.signup.signUp' })).toHaveAttribute( + 'href', + '/signup?redirect_url=%2Fapps%3Ftag%3Dworkflow&source=pricing', + ) + }) + }) }) diff --git a/web/app/signin/__tests__/one-more-step.spec.tsx b/web/app/signin/__tests__/one-more-step.spec.tsx new file mode 100644 index 00000000000..de0eec84078 --- /dev/null +++ b/web/app/signin/__tests__/one-more-step.spec.tsx @@ -0,0 +1,62 @@ +import type { MockedFunction } from 'vitest' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useRouter, useSearchParams } from '@/next/navigation' +import { useOneMoreStep } from '@/service/use-common' +import OneMoreStep from '../one-more-step' + +vi.mock('@/next/navigation', () => ({ + useRouter: vi.fn(), + useSearchParams: vi.fn(), +})) + +vi.mock('@/service/use-common', () => ({ + useOneMoreStep: vi.fn(), +})) + +const mockReplace = vi.fn() +const mockSubmitOneMoreStep = vi.fn() + +const mockUseRouter = useRouter as unknown as MockedFunction +const mockUseSearchParams = useSearchParams as unknown as MockedFunction +const mockUseOneMoreStep = useOneMoreStep as unknown as MockedFunction + +describe('OneMoreStep', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseRouter.mockReturnValue({ replace: mockReplace } as unknown as ReturnType< + typeof useRouter + >) + mockUseSearchParams.mockReturnValue( + new URLSearchParams( + 'invitation_code=invite-code&redirect_url=%2Fapps%3Ftag%3Dworkflow', + ) as unknown as ReturnType, + ) + mockUseOneMoreStep.mockReturnValue({ + mutateAsync: mockSubmitOneMoreStep, + isPending: false, + } as unknown as ReturnType) + mockSubmitOneMoreStep.mockResolvedValue({ result: 'success' }) + }) + + // Successful account initialization returns users to their original console destination. + describe('Post-registration redirect', () => { + it('should return to the requested console page when account initialization succeeds', async () => { + const user = userEvent.setup() + const queryClient = new QueryClient() + render( + + + , + ) + + await user.click(screen.getByRole('button', { name: 'login.go' })) + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('/apps?tag=workflow') + }) + }) + }) +}) diff --git a/web/app/signin/normal-form.tsx b/web/app/signin/normal-form.tsx index d6e3822aaf5..008f781d7b8 100644 --- a/web/app/signin/normal-form.tsx +++ b/web/app/signin/normal-form.tsx @@ -27,6 +27,8 @@ function NormalForm() { const { t } = useTranslation() const router = useRouter() const searchParams = useSearchParams() + const queryString = searchParams.toString() + const signupHref = queryString ? `/signup?${queryString}` : '/signup' // Login probe: 401 stays as `error` (legitimate "not logged in" state on /signin), // other errors throw to error.tsx. jumpTo same-pathname guard in service/base.ts // prevents the redirect loop on 401. @@ -270,7 +272,7 @@ function NormalForm() { {systemFeatures.is_allow_register && authType === 'password' && (
{t(($) => $['signup.noAccount'], { ns: 'login' })} - + {t(($) => $['signup.signUp'], { ns: 'login' })}
diff --git a/web/app/signin/one-more-step.tsx b/web/app/signin/one-more-step.tsx index 350248b399e..c97dc5e4256 100644 --- a/web/app/signin/one-more-step.tsx +++ b/web/app/signin/one-more-step.tsx @@ -14,13 +14,16 @@ import { toast } from '@langgenius/dify-ui/toast' import { useQueryClient } from '@tanstack/react-query' import { useReducer } from 'react' import { useTranslation } from 'react-i18next' +import { resolvePostLoginRedirect } from '@/app/signin/utils/post-login-redirect' import { LICENSE_LINK } from '@/constants/link' import { languages } from '@/i18n-config/language' import Link from '@/next/link' import { useRouter, useSearchParams } from '@/next/navigation' import { consoleQuery } from '@/service/client' import { useOneMoreStep } from '@/service/use-common' +import { replaceLoginRedirect } from '@/utils/login-redirect.client' import { timezones } from '@/utils/timezone' +import { basePath } from '@/utils/var' import Input from '../components/base/input' type IState = { @@ -108,7 +111,7 @@ const OneMoreStep = () => { timezone: state.timezone, }) await queryClient.resetQueries({ queryKey: consoleQuery.account.profile.get.key() }) - router.replace('/') + replaceLoginRedirect(resolvePostLoginRedirect(searchParams), router.replace, basePath) } catch (error: unknown) { if (hasStatus(error) && error.status === 400) toast.error(t(($) => $.invalidInvitationCode, { ns: 'login' })) diff --git a/web/app/signup/components/input-mail.spec.tsx b/web/app/signup/components/input-mail.spec.tsx index 9cf02717c9c..9b51ffc5fb7 100644 --- a/web/app/signup/components/input-mail.spec.tsx +++ b/web/app/signup/components/input-mail.spec.tsx @@ -3,6 +3,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import { useLocale } from '@/context/i18n' +import { useSearchParams } from '@/next/navigation' import { useSendMail } from '@/service/use-common' import Form from './input-mail' @@ -33,6 +34,10 @@ vi.mock('@/context/i18n', () => ({ useLocale: vi.fn(), })) +vi.mock('@/next/navigation', () => ({ + useSearchParams: vi.fn(), +})) + vi.mock('@/service/use-common', () => ({ useSendMail: vi.fn(), })) @@ -40,6 +45,7 @@ vi.mock('@/service/use-common', () => ({ type UseSendMailResult = ReturnType const mockUseLocale = useLocale as unknown as MockedFunction +const mockUseSearchParams = useSearchParams as unknown as MockedFunction const mockUseSendMail = useSendMail as unknown as MockedFunction const renderForm = ({ @@ -62,6 +68,9 @@ const renderForm = ({ describe('InputMail Form', () => { beforeEach(() => { vi.clearAllMocks() + mockUseSearchParams.mockReturnValue( + new URLSearchParams() as unknown as ReturnType, + ) mockSubmitMail.mockResolvedValue({ result: 'success', data: 'token' }) }) @@ -114,6 +123,24 @@ describe('InputMail Form', () => { }) }) + // Navigation between registration and login keeps the original destination. + describe('Registration Navigation', () => { + it('should preserve the current query when navigating to sign in', () => { + mockUseSearchParams.mockReturnValue( + new URLSearchParams( + 'redirect_url=%2Fapps%3Ftag%3Dworkflow&source=pricing', + ) as unknown as ReturnType, + ) + + renderForm() + + expect(screen.getByRole('link', { name: 'login.signup.signIn' })).toHaveAttribute( + 'href', + '/signin?redirect_url=%2Fapps%3Ftag%3Dworkflow&source=pricing', + ) + }) + }) + // Validation and failure paths. describe('Edge Cases', () => { it('should block submission when email is invalid', () => { diff --git a/web/app/signup/components/input-mail.tsx b/web/app/signup/components/input-mail.tsx index 79be544779e..806e9bdc823 100644 --- a/web/app/signup/components/input-mail.tsx +++ b/web/app/signup/components/input-mail.tsx @@ -11,6 +11,7 @@ import { emailRegex } from '@/config' import { useLocale } from '@/context/i18n' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import Link from '@/next/link' +import { useSearchParams } from '@/next/navigation' import { useSendMail } from '@/service/use-common' type Props = { @@ -20,6 +21,9 @@ export default function Form({ onSuccess }: Props) { const { t } = useTranslation() const [email, setEmail] = useState('') const locale = useLocale() + const searchParams = useSearchParams() + const queryString = searchParams.toString() + const signinHref = queryString ? `/signin?${queryString}` : '/signin' const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { mutateAsync: submitMail, isPending } = useSendMail() @@ -78,7 +82,7 @@ export default function Form({ onSuccess }: Props) {
{t(($) => $['signup.haveAccount'], { ns: 'login' })} - + {t(($) => $['signup.signIn'], { ns: 'login' })}
diff --git a/web/app/signup/set-password/__tests__/page.spec.tsx b/web/app/signup/set-password/__tests__/page.spec.tsx index c8f09482e83..35821b51281 100644 --- a/web/app/signup/set-password/__tests__/page.spec.tsx +++ b/web/app/signup/set-password/__tests__/page.spec.tsx @@ -145,6 +145,22 @@ describe('Signup Set Password Page', () => { expect(mockReplace).toHaveBeenCalledWith('/') }) + it('should return to the requested console page when registration succeeds', async () => { + mockUseSearchParams.mockReturnValue( + new URLSearchParams( + 'token=register-token&redirect_url=%2Fapps%3Ftag%3Dworkflow', + ) as unknown as ReturnType, + ) + mockRegister.mockResolvedValue({ result: 'success', data: {} }) + + renderWithQueryClient() + fillAndSubmit() + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('/apps?tag=workflow') + }) + }) + it('should remember the utm event with slug and clear the utm cookie when a utm_info cookie is present', async () => { Cookies.set('utm_info', JSON.stringify({ utm_source: 'community', slug: 'partner-launch' })) mockRegister.mockResolvedValue({ result: 'success', data: {} }) diff --git a/web/app/signup/set-password/page.tsx b/web/app/signup/set-password/page.tsx index 32918747a11..5e124afbe51 100644 --- a/web/app/signup/set-password/page.tsx +++ b/web/app/signup/set-password/page.tsx @@ -9,6 +9,7 @@ import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { rememberRegistrationSuccess } from '@/app/components/base/amplitude/registration-tracking' import Input from '@/app/components/base/input' +import { resolvePostLoginRedirect } from '@/app/signin/utils/post-login-redirect' import { validPassword } from '@/config' import { useLocale } from '@/context/i18n' import { useRouter, useSearchParams } from '@/next/navigation' @@ -16,7 +17,9 @@ import { consoleQuery } from '@/service/client' import { useMailRegister } from '@/service/use-common' import { rememberCreateAppExternalAttribution } from '@/utils/create-app-tracking' import { sendGAEvent } from '@/utils/gtag' +import { replaceLoginRedirect } from '@/utils/login-redirect.client' import { getBrowserTimezone } from '@/utils/timezone' +import { basePath } from '@/utils/var' const parseUtmInfo = () => { const utmInfoStr = Cookies.get('utm_info') @@ -88,12 +91,23 @@ const ChangePasswordForm = () => { toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) await queryClient.resetQueries({ queryKey: consoleQuery.account.profile.get.key() }) - router.replace('/') + replaceLoginRedirect(resolvePostLoginRedirect(searchParams), router.replace, basePath) } } catch (error) { console.error(error) } - }, [password, token, valid, confirmPassword, register, locale, queryClient, router, t]) + }, [ + password, + token, + valid, + confirmPassword, + register, + locale, + queryClient, + router, + searchParams, + t, + ]) return (
new Response( @@ -49,8 +49,75 @@ async function loadServerRequest() { } } +type ClientRequestOptions = { + response: Response + refreshError?: Error +} + +async function loadClientRequest({ response, refreshError }: ClientRequestOptions) { + vi.resetModules() + + const mockBaseFetch = vi.fn(async () => { + throw response + }) + const mockRefreshAccessTokenOrReLogin = refreshError + ? vi.fn().mockRejectedValue(refreshError) + : vi.fn() + + vi.doMock('@/utils/client', () => ({ + isClient: true, + isServer: false, + })) + vi.doMock('@/utils/var', () => ({ + basePath: '/app', + })) + vi.doMock('../fetch', () => ({ + base: mockBaseFetch, + ContentType: { + audio: 'audio/mpeg', + download: 'application/octet-stream', + downloadZip: 'application/zip', + json: 'application/json', + }, + getBaseOptions: vi.fn(() => ({})), + })) + vi.doMock('../refresh-token', () => ({ + refreshAccessTokenOrReLogin: mockRefreshAccessTokenOrReLogin, + })) + + const { request } = await import('../base') + + return { + request, + mockRefreshAccessTokenOrReLogin, + } +} + describe('request 401 handling', () => { + const originalLocation = globalThis.location + + beforeEach(() => { + vi.clearAllMocks() + Object.defineProperty(globalThis, 'location', { + value: { + origin: 'https://example.com', + pathname: '/app/apps', + search: '?category=agent', + hash: '#recent', + href: 'https://example.com/app/apps?category=agent#recent', + reload: vi.fn(), + }, + writable: true, + configurable: true, + }) + }) + afterEach(() => { + Object.defineProperty(globalThis, 'location', { + value: originalLocation, + writable: true, + configurable: true, + }) vi.resetModules() vi.restoreAllMocks() }) @@ -62,4 +129,31 @@ describe('request 401 handling', () => { expect(mockRefreshAccessTokenOrReLogin).not.toHaveBeenCalled() }) + + it('should preserve the current URL when a 401 response cannot be parsed', async () => { + const response = new Response('not-json', { status: 401 }) + const { request, mockRefreshAccessTokenOrReLogin } = await loadClientRequest({ response }) + + await expect(request('/account/profile')).rejects.toBe(response) + + expect(globalThis.location.href).toBe( + `https://example.com/app/signin?redirect_url=${encodeURIComponent('/app/apps?category=agent#recent')}`, + ) + expect(mockRefreshAccessTokenOrReLogin).not.toHaveBeenCalled() + }) + + it('should preserve the current URL when token refresh fails', async () => { + const response = createUnauthorizedResponse() + const { request, mockRefreshAccessTokenOrReLogin } = await loadClientRequest({ + response, + refreshError: new Error('refresh failed'), + }) + + await expect(request('/account/profile')).rejects.toBe(response) + + expect(mockRefreshAccessTokenOrReLogin).toHaveBeenCalledOnce() + expect(globalThis.location.href).toBe( + `https://example.com/app/signin?redirect_url=${encodeURIComponent('/app/apps?category=agent#recent')}`, + ) + }) }) diff --git a/web/service/__tests__/base.spec.ts b/web/service/__tests__/base.spec.ts index 748de185df2..b9df08598c3 100644 --- a/web/service/__tests__/base.spec.ts +++ b/web/service/__tests__/base.spec.ts @@ -18,8 +18,10 @@ describe('buildSigninUrlWithRedirect', () => { Object.defineProperty(globalThis, 'location', { value: { origin: 'https://example.com', - pathname: '/apps', - href: 'https://example.com/apps', + pathname: '/app/apps', + search: '?category=agent', + hash: '#recent', + href: 'https://example.com/app/apps?category=agent#recent', }, writable: true, configurable: true, @@ -34,9 +36,11 @@ describe('buildSigninUrlWithRedirect', () => { }) }) - it('should return plain signin URL for non-OAuth pages', () => { + it('should preserve the current internal URL for Console pages', () => { const url = buildSigninUrlWithRedirect() - expect(url).toBe('https://example.com/app/signin') + expect(url).toBe( + `https://example.com/app/signin?redirect_url=${encodeURIComponent('/app/apps?category=agent#recent')}`, + ) }) it('should append redirect_url for OAuth authorize pages', () => { @@ -45,6 +49,8 @@ describe('buildSigninUrlWithRedirect', () => { value: { origin: 'https://example.com', pathname: '/account/oauth/authorize', + search: '?client_id=abc&state=xyz', + hash: '', href: oauthHref, }, writable: true, @@ -55,11 +61,13 @@ describe('buildSigninUrlWithRedirect', () => { expect(url).toBe(`https://example.com/app/signin?redirect_url=${encodeURIComponent(oauthHref)}`) }) - it('should not include redirect_url for other paths containing partial match', () => { + it('should treat other paths containing a partial OAuth match as Console pages', () => { Object.defineProperty(globalThis, 'location', { value: { origin: 'https://example.com', pathname: '/settings/oauth', + search: '', + hash: '', href: 'https://example.com/settings/oauth', }, writable: true, @@ -67,8 +75,29 @@ describe('buildSigninUrlWithRedirect', () => { }) const url = buildSigninUrlWithRedirect() - expect(url).toBe('https://example.com/app/signin') + expect(url).toBe( + `https://example.com/app/signin?redirect_url=${encodeURIComponent('/settings/oauth')}`, + ) }) + + it.each(['/app/signin', '/app/signin/'])( + 'should not create a self-referential redirect for the signin page: %s', + (pathname) => { + Object.defineProperty(globalThis, 'location', { + value: { + origin: 'https://example.com', + pathname, + search: '?redirect_url=%2Fapp%2Fapps', + hash: '', + href: `https://example.com${pathname}?redirect_url=%2Fapp%2Fapps`, + }, + writable: true, + configurable: true, + }) + + expect(buildSigninUrlWithRedirect()).toBe('https://example.com/app/signin') + }, + ) }) describe('buildWebAppSigninUrlWithRedirect', () => { diff --git a/web/service/base.ts b/web/service/base.ts index c47fcff5f7d..2e244ef20c1 100644 --- a/web/service/base.ts +++ b/web/service/base.ts @@ -41,6 +41,7 @@ import { } from '@/config' import { asyncRunSafe } from '@/utils' import { isClient } from '@/utils/client' +import { resolveLoginRedirectTarget } from '@/utils/login-redirect' import { basePath } from '@/utils/var' import { base, ContentType, getBaseOptions } from './fetch' import { refreshAccessTokenOrReLogin } from './refresh-token' @@ -162,17 +163,28 @@ function jumpTo(url: string) { } const OAUTH_AUTHORIZE_PATH = '/account/oauth/authorize' +const SIGNIN_PATH = '/signin' export const buildSigninUrlWithRedirect = (): string => { const loginUrl = `${isClient ? window.location.origin : ''}${basePath}/signin` + if (!isClient) return loginUrl - // Only preserve redirect URL for OAuth authorize pages - if (isClient && window.location.pathname.includes(OAUTH_AUTHORIZE_PATH)) { + const signinPath = `${basePath}${SIGNIN_PATH}` + if (window.location.pathname === signinPath || window.location.pathname === `${signinPath}/`) + return loginUrl + + if (window.location.pathname.includes(OAUTH_AUTHORIZE_PATH)) { const currentUrl = window.location.href return `${loginUrl}?redirect_url=${encodeURIComponent(currentUrl)}` } - return loginUrl + const currentTarget = resolveLoginRedirectTarget( + `${window.location.pathname}${window.location.search}${window.location.hash}`, + { allowSameOriginAbsolute: false }, + ) + if (!currentTarget || currentTarget.kind !== 'internal') return loginUrl + + return `${loginUrl}?redirect_url=${encodeURIComponent(currentTarget.href)}` } function unicodeToChar(text: string) { @@ -941,9 +953,8 @@ export const request = async (url: string, options = {}, otherOptions?: IOthe if (!isClient) return Promise.reject(err) const [parseErr, errRespData] = await asyncRunSafe(errResp.json()) - const loginUrl = `${window.location.origin}${basePath}/signin` if (parseErr) { - window.location.href = loginUrl + window.location.href = buildSigninUrlWithRedirect() return Promise.reject(err) } if (/\/login/.test(url)) return Promise.reject(errRespData)