From 0e9d2e2d3e5f4ff2b3369d6f199084c028b492ea Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:10:36 +0800 Subject: [PATCH] fix(auth): redirect signed-in visitors away from login Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- web/e2e/auth-entry.spec.ts | 7 ++++++ web/src/app/router.tsx | 4 +++- web/src/shared/lib/auth-route.test.ts | 31 ++++++++++++++++++++++++++- web/src/shared/lib/auth-route.ts | 21 ++++++++++++++++++ 4 files changed, 61 insertions(+), 2 deletions(-) diff --git a/web/e2e/auth-entry.spec.ts b/web/e2e/auth-entry.spec.ts index c3c1ea10..a7a6a3aa 100644 --- a/web/e2e/auth-entry.spec.ts +++ b/web/e2e/auth-entry.spec.ts @@ -112,6 +112,13 @@ test.describe('Auth Entry', () => { await page.getByRole('button', { name: 'Register & Login' }).click() await expect(page).toHaveURL('/') + await page.goto('/login') + await expect(page).toHaveURL('/') + await page.goto('/login?returnTo=%2Fdashboard%2Ftokens%3Ftab%3Dactive%23latest') + await expect(page).toHaveURL('/dashboard/tokens?tab=active#latest') + await page.goto('/login?returnTo=%2Flogin') + await expect(page).toHaveURL('/') + const loginPage = await browser.newPage() await setEnglishLocale(loginPage) await loginPage.goto('/login?returnTo=%2Fdashboard%2Ftokens') diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 216c4377..489efd73 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -4,7 +4,7 @@ import { Layout } from './layout' import { getCurrentUser } from '@/api/client' import { RoleGuard } from '@/shared/components/role-guard' import { RouteError } from '@/shared/components/route-error' -import { createRequireAuth, isSafeAuthReturnTo } from '@/shared/lib/auth-route' +import { createRedirectAuthenticated, createRequireAuth, isSafeAuthReturnTo } from '@/shared/lib/auth-route' import { clearDynamicImportReloadGuard, recoverFromDynamicImportError } from '@/shared/lib/dynamic-import-recovery' import { normalizeSearchQuery } from '@/shared/lib/search-query' @@ -228,6 +228,7 @@ const rootRoute = createRootRoute({ }) const requireAuth = createRequireAuth(getCurrentUser) +const redirectAuthenticated = createRedirectAuthenticated(getCurrentUser) const landingRoute = createRoute({ getParentRoute: () => rootRoute, @@ -244,6 +245,7 @@ const skillsRoute = createRoute({ const loginRoute = createRoute({ getParentRoute: () => rootRoute, path: 'login', + beforeLoad: redirectAuthenticated, validateSearch: (search: Record): { returnTo?: string; reason?: string } => ({ returnTo: isSafeAuthReturnTo(search.returnTo) ? search.returnTo : undefined, reason: typeof search.reason === 'string' ? search.reason : undefined, diff --git a/web/src/shared/lib/auth-route.test.ts b/web/src/shared/lib/auth-route.test.ts index 8444d78d..b164dd06 100644 --- a/web/src/shared/lib/auth-route.test.ts +++ b/web/src/shared/lib/auth-route.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { isRedirect } from '@tanstack/react-router' -import { buildReturnTo, createRequireAuth, resolveAuthReturnTo } from './auth-route' +import { buildReturnTo, createRedirectAuthenticated, createRequireAuth, resolveAuthReturnTo } from './auth-route' describe('auth-route', () => { it('returns to the original local page or the home page, never an external URL', () => { @@ -55,4 +55,33 @@ describe('auth-route', () => { })).resolves.toEqual({ user }) expect(getCurrentUser).toHaveBeenCalledTimes(1) }) + + it('keeps unauthenticated visitors on the login page', async () => { + const redirectAuthenticated = createRedirectAuthenticated(async () => null) + + await expect(redirectAuthenticated({ search: { returnTo: '/dashboard/tokens' } })).resolves.toBeUndefined() + }) + + it('redirects authenticated visitors to the requested local page', async () => { + const redirectAuthenticated = createRedirectAuthenticated(async () => ({ userId: 'user-1' })) + + await expect(redirectAuthenticated({ search: { returnTo: '/dashboard/tokens?tab=active#latest' } })).rejects.toSatisfy((error: unknown) => { + expect(isRedirect(error)).toBe(true) + if (!isRedirect(error)) return false + expect(error.options.to).toBe('/dashboard/tokens?tab=active#latest') + expect(error.options.replace).toBe(true) + return true + }) + }) + + it.each([undefined, '//example.com', '/login', '/login?returnTo=%2Flogin', '/register'])('redirects authenticated visitors to home for unusable destinations (%s)', async (returnTo) => { + const redirectAuthenticated = createRedirectAuthenticated(async () => ({ userId: 'user-1' })) + + await expect(redirectAuthenticated({ search: { returnTo } })).rejects.toSatisfy((error: unknown) => { + expect(isRedirect(error)).toBe(true) + if (!isRedirect(error)) return false + expect(error.options.to).toBe('/') + return true + }) + }) }) diff --git a/web/src/shared/lib/auth-route.ts b/web/src/shared/lib/auth-route.ts index f6c9fa2e..00a21e4a 100644 --- a/web/src/shared/lib/auth-route.ts +++ b/web/src/shared/lib/auth-route.ts @@ -25,6 +25,27 @@ export function resolveAuthReturnTo(value: unknown) { return isSafeAuthReturnTo(value) ? value : '/' } +function resolveAuthenticatedReturnTo(value: unknown) { + const target = resolveAuthReturnTo(value) + try { + const pathname = decodeURIComponent(new URL(target, 'http://localhost').pathname) + if (/^\/(?:login|register)\/?$/i.test(pathname) || pathname.startsWith('//') || pathname.includes('\\')) { + return '/' + } + } catch { + return '/' + } + return target +} + +export function createRedirectAuthenticated(getCurrentUser: () => Promise) { + return async function redirectAuthenticated({ search }: { search: { returnTo?: string } }) { + if (await getCurrentUser()) { + throw redirect({ to: resolveAuthenticatedReturnTo(search.returnTo), replace: true }) + } + } +} + export function createRequireAuth(getCurrentUser: () => Promise) { return async function requireAuth({ location }: { location: RouteLocationLike }) { const user = await getCurrentUser()