feat: implement path prefix handling for OAuth and login redirects
This commit is contained in:
@@ -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() {
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push(`/${params.locale}/login`)}
|
||||
onClick={() => router.push(`${getPathPrefix(params.locale as string)}/${params.locale}/login`)}
|
||||
>
|
||||
{t("oauth_error.back_to_login")}
|
||||
</Button>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user