fix(auth): redirect signed-in visitors away from login

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-09-24 11:10:36 +08:00
parent 5be60043d5
commit 0e9d2e2d3e
4 changed files with 61 additions and 2 deletions

View file

@ -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')

View file

@ -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<string, unknown>): { returnTo?: string; reason?: string } => ({
returnTo: isSafeAuthReturnTo(search.returnTo) ? search.returnTo : undefined,
reason: typeof search.reason === 'string' ? search.reason : undefined,

View file

@ -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
})
})
})

View file

@ -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<unknown>) {
return async function redirectAuthenticated({ search }: { search: { returnTo?: string } }) {
if (await getCurrentUser()) {
throw redirect({ to: resolveAuthenticatedReturnTo(search.returnTo), replace: true })
}
}
}
export function createRequireAuth(getCurrentUser: () => Promise<unknown>) {
return async function requireAuth({ location }: { location: RouteLocationLike }) {
const user = await getCurrentUser()