diff --git a/app/(main)/[locale]/auth/callback/page.tsx b/app/(main)/[locale]/auth/callback/page.tsx index bbd8076c..d994fda8 100644 --- a/app/(main)/[locale]/auth/callback/page.tsx +++ b/app/(main)/[locale]/auth/callback/page.tsx @@ -4,7 +4,7 @@ import { Suspense, useEffect, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { useTranslations } from "next-intl"; import { useAuthStore } from "@/stores/auth-store"; -import { apiFetch, getPathPrefix } from "@/lib/browser-navigation"; +import { apiFetch, getPathPrefix, toRouterPath } from "@/lib/browser-navigation"; import { Loader2, AlertCircle } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useParams } from "next/navigation"; @@ -60,7 +60,7 @@ function OAuthCallbackInner() { sessionStorage.setItem("settings-deep-link-tab", "security"); } catch { /* ignore */ } const prefix = getPathPrefix(params.locale as string); - router.push(`${prefix}/${params.locale}/settings`); + router.push(toRouterPath(`${prefix}/${params.locale}/settings`)); } catch { setError("token_exchange_failed"); } @@ -105,7 +105,7 @@ function OAuthCallbackInner() { redirectTo = saved; } } catch { /* sessionStorage may be unavailable */ } - router.push(redirectTo); + router.push(toRouterPath(redirectTo)); } else { setError("token_exchange_failed"); } @@ -189,7 +189,7 @@ function OAuthCallbackInner() { redirectTo = saved; } } catch { /* sessionStorage may be unavailable */ } - router.push(redirectTo); + router.push(toRouterPath(redirectTo)); } else { setError("token_exchange_failed"); } @@ -217,7 +217,7 @@ function OAuthCallbackInner() {
diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index 563fbefe..6cdc3838 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -11,7 +11,7 @@ import { useAccountStore } from "@/stores/account-store"; import { useThemeStore } from "@/stores/theme-store"; import { useShallow } from "zustand/react/shallow"; import { useConfig } from "@/hooks/use-config"; -import { apiFetch, getPathPrefix, withBasePath } from "@/lib/browser-navigation"; +import { apiFetch, getPathPrefix, toRouterPath, withBasePath } from "@/lib/browser-navigation"; import { cn } from "@/lib/utils"; import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react"; import { type OAuthMetadata } from "@/lib/oauth/discovery"; @@ -279,7 +279,7 @@ export default function LoginPage() { redirectTo = saved; } } catch { /* ignore */ } - router.push(redirectTo); + router.push(toRouterPath(redirectTo)); } }, [isAuthenticated, router, isAddAccountMode, isMobileHandoff, mobileRedirectUri, mobileState]); @@ -655,7 +655,7 @@ export default function LoginPage() { redirectTo = saved; } } catch { /* ignore */ } - router.push(redirectTo); + router.push(toRouterPath(redirectTo)); } }; diff --git a/lib/__tests__/base-path.test.ts b/lib/__tests__/base-path.test.ts index 929018cd..fabc4741 100644 --- a/lib/__tests__/base-path.test.ts +++ b/lib/__tests__/base-path.test.ts @@ -72,3 +72,33 @@ describe('withBasePath — asset-URL fallbacks under a subpath', () => { expect(getPathPrefix()).toBe('/webmail'); }); }); + +describe('toRouterPath — router.push paths under a subpath', () => { + // With a build-time basePath, Next's router prepends the prefix itself, so + // browser-derived paths (redirect_after_login stores + // window.location.pathname) must be stripped or the redirect lands on + // /webmail/webmail/en. See #390. + it('strips the static base path from a stored browser path', async () => { + const { toRouterPath } = await loadNav('/webmail'); + expect(toRouterPath('/webmail/en')).toBe('/en'); + expect(toRouterPath('/webmail/en/calendar?view=day')).toBe('/en/calendar?view=day'); + expect(toRouterPath('/webmail')).toBe('/'); + expect(toRouterPath('/webmail?compose=1')).toBe('/?compose=1'); + }); + + it('leaves already-stripped and unrelated paths alone', async () => { + const { toRouterPath } = await loadNav('/webmail'); + expect(toRouterPath('/en')).toBe('/en'); + expect(toRouterPath('/')).toBe('/'); + // Shares the prefix text but is a different first segment. + expect(toRouterPath('/webmail2/en')).toBe('/webmail2/en'); + }); + + it('passes paths through unchanged when no base path is built in', async () => { + // Legacy runtime-detected proxy mounts: Next knows nothing about the + // prefix, so router.push needs the full prefixed path. + const { toRouterPath } = await loadNav(undefined); + expect(toRouterPath('/webmail/en')).toBe('/webmail/en'); + expect(toRouterPath('/en')).toBe('/en'); + }); +}); diff --git a/lib/browser-navigation.ts b/lib/browser-navigation.ts index 15369273..8cd0854a 100644 --- a/lib/browser-navigation.ts +++ b/lib/browser-navigation.ts @@ -96,6 +96,27 @@ export function withBasePath(url: string | null | undefined): string { } +/** + * Converts a browser-style path (as found in `window.location.pathname`, + * which always includes the mount prefix) into a path safe to hand to Next's + * client router (`router.push` / `router.replace`). + * + * When the app is built with NEXT_PUBLIC_BASE_PATH, Next's router prepends + * the basePath itself, so a stored prefixed path would get it twice (#390) — + * strip it here. Legacy runtime-detected proxy mounts pass through unchanged: + * Next knows nothing about that prefix, so the router needs the full path. + * + * Accepts paths with query/hash suffixes (`/webmail/en/calendar?view=day`). + */ +export function toRouterPath(path: string): string { + if (!STATIC_BASE_PATH || !path.startsWith(STATIC_BASE_PATH)) return path; + const rest = path.slice(STATIC_BASE_PATH.length); + if (rest === '') return '/'; + if (rest[0] === '/') return rest; + if (rest[0] === '?' || rest[0] === '#') return '/' + rest; + return path; // different first segment that merely shares the prefix text +} + /** * Extracts the locale from the current URL, skipping any mount prefix. * Falls back to 'en' when no known locale segment is found.