fix: strip build-time basePath from router.push redirects after login #390
This commit is contained in:
@@ -4,7 +4,7 @@ import { Suspense, useEffect, useState } from "react";
|
|||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
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 { Loader2, AlertCircle } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
@@ -60,7 +60,7 @@ function OAuthCallbackInner() {
|
|||||||
sessionStorage.setItem("settings-deep-link-tab", "security");
|
sessionStorage.setItem("settings-deep-link-tab", "security");
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
const prefix = getPathPrefix(params.locale as string);
|
const prefix = getPathPrefix(params.locale as string);
|
||||||
router.push(`${prefix}/${params.locale}/settings`);
|
router.push(toRouterPath(`${prefix}/${params.locale}/settings`));
|
||||||
} catch {
|
} catch {
|
||||||
setError("token_exchange_failed");
|
setError("token_exchange_failed");
|
||||||
}
|
}
|
||||||
@@ -105,7 +105,7 @@ function OAuthCallbackInner() {
|
|||||||
redirectTo = saved;
|
redirectTo = saved;
|
||||||
}
|
}
|
||||||
} catch { /* sessionStorage may be unavailable */ }
|
} catch { /* sessionStorage may be unavailable */ }
|
||||||
router.push(redirectTo);
|
router.push(toRouterPath(redirectTo));
|
||||||
} else {
|
} else {
|
||||||
setError("token_exchange_failed");
|
setError("token_exchange_failed");
|
||||||
}
|
}
|
||||||
@@ -189,7 +189,7 @@ function OAuthCallbackInner() {
|
|||||||
redirectTo = saved;
|
redirectTo = saved;
|
||||||
}
|
}
|
||||||
} catch { /* sessionStorage may be unavailable */ }
|
} catch { /* sessionStorage may be unavailable */ }
|
||||||
router.push(redirectTo);
|
router.push(toRouterPath(redirectTo));
|
||||||
} else {
|
} else {
|
||||||
setError("token_exchange_failed");
|
setError("token_exchange_failed");
|
||||||
}
|
}
|
||||||
@@ -217,7 +217,7 @@ function OAuthCallbackInner() {
|
|||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => router.push(`${getPathPrefix(params.locale as string)}/${params.locale}/login`)}
|
onClick={() => router.push(toRouterPath(`${getPathPrefix(params.locale as string)}/${params.locale}/login`))}
|
||||||
>
|
>
|
||||||
{t("oauth_error.back_to_login")}
|
{t("oauth_error.back_to_login")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { useAccountStore } from "@/stores/account-store";
|
|||||||
import { useThemeStore } from "@/stores/theme-store";
|
import { useThemeStore } from "@/stores/theme-store";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useConfig } from "@/hooks/use-config";
|
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 { cn } from "@/lib/utils";
|
||||||
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react";
|
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";
|
import { type OAuthMetadata } from "@/lib/oauth/discovery";
|
||||||
@@ -279,7 +279,7 @@ export default function LoginPage() {
|
|||||||
redirectTo = saved;
|
redirectTo = saved;
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
router.push(redirectTo);
|
router.push(toRouterPath(redirectTo));
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, router, isAddAccountMode, isMobileHandoff, mobileRedirectUri, mobileState]);
|
}, [isAuthenticated, router, isAddAccountMode, isMobileHandoff, mobileRedirectUri, mobileState]);
|
||||||
|
|
||||||
@@ -655,7 +655,7 @@ export default function LoginPage() {
|
|||||||
redirectTo = saved;
|
redirectTo = saved;
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
router.push(redirectTo);
|
router.push(toRouterPath(redirectTo));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -72,3 +72,33 @@ describe('withBasePath — asset-URL fallbacks under a subpath', () => {
|
|||||||
expect(getPathPrefix()).toBe('/webmail');
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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.
|
* Extracts the locale from the current URL, skipping any mount prefix.
|
||||||
* Falls back to 'en' when no known locale segment is found.
|
* Falls back to 'en' when no known locale segment is found.
|
||||||
|
|||||||
Reference in New Issue
Block a user