diff --git a/app/[locale]/auth/callback/page.tsx b/app/[locale]/auth/callback/page.tsx index bb8c2896..e5c48f22 100644 --- a/app/[locale]/auth/callback/page.tsx +++ b/app/[locale]/auth/callback/page.tsx @@ -4,6 +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 { getPathPrefix } from "@/lib/browser-navigation"; import { Loader2, AlertCircle } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useParams } from "next/navigation"; @@ -48,7 +49,8 @@ function OAuthCallbackInner() { return; } - const redirectUri = `${window.location.origin}/${params.locale}/auth/callback`; + const prefix = getPathPrefix(params.locale as string); + const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`; loginWithOAuth(serverUrl, code, codeVerifier, redirectUri) .then((success) => { @@ -57,7 +59,7 @@ function OAuthCallbackInner() { sessionStorage.removeItem("oauth_code_verifier"); sessionStorage.removeItem("oauth_server_url"); sessionStorage.removeItem("oauth_add_account_mode"); - let redirectTo = `/${params.locale}`; + let redirectTo = `${prefix}/${params.locale}`; try { const saved = sessionStorage.getItem('redirect_after_login'); if (saved) { @@ -75,10 +77,11 @@ function OAuthCallbackInner() { }); } else if (state) { // Server-side SSO flow — state was stored in encrypted httpOnly cookie + const ssoPrefix = getPathPrefix(params.locale as string); loginWithServerSso(code, state) .then((success) => { if (success) { - let redirectTo = `/${params.locale}`; + let redirectTo = `${ssoPrefix}/${params.locale}`; try { const saved = sessionStorage.getItem('redirect_after_login'); if (saved) { @@ -114,7 +117,7 @@ function OAuthCallbackInner() {
diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx index 72ecbc71..119c71ca 100644 --- a/app/[locale]/login/page.tsx +++ b/app/[locale]/login/page.tsx @@ -15,6 +15,7 @@ import { Mail, AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Mon import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery"; import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce"; import { OAUTH_SCOPES } from "@/lib/oauth/tokens"; +import { getPathPrefix } from "@/lib/browser-navigation"; const APP_VERSION = "1.4.7"; @@ -180,7 +181,8 @@ export default function LoginPage() { const startServerSideSso = useCallback(async () => { setOauthLoading(true); try { - const redirectUri = `${window.location.origin}/${params.locale}/auth/callback`; + const prefix = getPathPrefix(params.locale as string); + const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`; const res = await fetch('/api/auth/sso/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -358,7 +360,8 @@ export default function LoginPage() { const verifier = generateCodeVerifier(); const challenge = await generateCodeChallenge(verifier); const state = generateState(); - const redirectUri = `${window.location.origin}/${params.locale}/auth/callback`; + const prefix = getPathPrefix(params.locale as string); + const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`; sessionStorage.setItem("oauth_code_verifier", verifier); sessionStorage.setItem("oauth_state", state); diff --git a/components/providers/embedded-bridge-provider.tsx b/components/providers/embedded-bridge-provider.tsx index 4f4cc0f1..3cb6edc4 100644 --- a/components/providers/embedded-bridge-provider.tsx +++ b/components/providers/embedded-bridge-provider.tsx @@ -2,6 +2,7 @@ import { useEffect } from "react"; import { isEmbedded, listenFromParent } from "@/lib/iframe-bridge"; +import { getPathPrefix, getLocaleFromPath } from "@/lib/browser-navigation"; import { useAuthStore } from "@/stores/auth-store"; import { useConfig } from "@/hooks/use-config"; @@ -16,9 +17,9 @@ export function EmbeddedBridgeProvider({ children }: { children: React.ReactNode switch (msg.type) { case "sso:trigger-login": { // Navigate to login page to start SSO flow - const segments = window.location.pathname.split("/").filter(Boolean); - const locale = segments[0] || "en"; - window.location.href = `/${locale}/login`; + const prefix = getPathPrefix(); + const locale = getLocaleFromPath(); + window.location.href = `${prefix}/${locale}/login`; break; } case "sso:trigger-logout": diff --git a/lib/browser-navigation.ts b/lib/browser-navigation.ts index d7b377f3..59be2d35 100644 --- a/lib/browser-navigation.ts +++ b/lib/browser-navigation.ts @@ -1,7 +1,51 @@ +import { locales } from '@/i18n/routing'; + export function replaceWindowLocation(url: string): void { if (typeof window === 'undefined') { return; } window.location.replace(url); +} + +/** + * Returns the mount prefix from the current URL. + * When the app is served behind a reverse proxy at e.g. /bulwark, + * the browser sees /bulwark/en/login while Next.js sees /en/login. + * + * If a locale is supplied (e.g. from route params) it is used directly; + * otherwise the first path segment that matches a known locale is used. + * + * Returns '' when there is no prefix. + */ +export function getPathPrefix(locale?: string): string { + if (typeof window === 'undefined') return ''; + + const segments = window.location.pathname.split('/').filter(Boolean); + + let localeIndex: number; + if (locale) { + localeIndex = segments.indexOf(locale); + } else { + localeIndex = segments.findIndex(s => + (locales as readonly string[]).includes(s) + ); + } + + if (localeIndex <= 0) return ''; + return '/' + segments.slice(0, localeIndex).join('/'); +} + +/** + * Extracts the locale from the current URL, skipping any mount prefix. + * Falls back to 'en' when no known locale segment is found. + */ +export function getLocaleFromPath(): string { + if (typeof window === 'undefined') return 'en'; + + const segments = window.location.pathname.split('/').filter(Boolean); + const locale = segments.find(s => + (locales as readonly string[]).includes(s) + ); + return locale || 'en'; } \ No newline at end of file diff --git a/stores/auth-store.ts b/stores/auth-store.ts index f3874772..338c43c2 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -12,7 +12,7 @@ import { useAccountStore } from './account-store'; import { fetchConfig } from '@/hooks/use-config'; import { debug } from '@/lib/debug'; import { generateAccountId } from '@/lib/account-utils'; -import { replaceWindowLocation } from '@/lib/browser-navigation'; +import { replaceWindowLocation, getPathPrefix, getLocaleFromPath } from '@/lib/browser-navigation'; import { notifyParent } from '@/lib/iframe-bridge'; import { snapshotAccount, restoreAccount, clearAllStores, evictAccount, evictAll } from '@/lib/account-state-manager'; import type { Identity } from '@/lib/jmap/types'; @@ -107,9 +107,9 @@ function loadIdentities(rawIdentities: Identity[], username: string): { identiti function getLocaleLoginPath(): string { if (typeof window === 'undefined') return '/en/login'; - const segments = window.location.pathname.split('/').filter(Boolean); - const locale = segments[0] || 'en'; - return `/${locale}/login`; + const prefix = getPathPrefix(); + const locale = getLocaleFromPath(); + return `${prefix}/${locale}/login`; } function saveRedirectAfterLogin(): void {