|
|
@@ -1,5 +1,5 @@
|
|
|
import { useEffect, useRef, useState } from 'react';
|
|
|
-import { useNavigate, useSearchParams } from 'react-router-dom';
|
|
|
+import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
|
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
|
|
import { useTranslation } from 'react-i18next';
|
|
|
import { useAuth } from '../contexts/AuthContext';
|
|
|
@@ -16,6 +16,7 @@ type LoginStep = 'credentials' | '2fa' | 'reset-password';
|
|
|
// Read + remove in one try so all branches in the OIDC useEffect see the same
|
|
|
// value and a subsequent page load does not replay the flag.
|
|
|
const REMEMBER_ME_KEY = 'auth_remember_me';
|
|
|
+const POST_LOGIN_REDIRECT_KEY = 'auth_post_login_redirect';
|
|
|
|
|
|
function toPersistence(remember: boolean): TokenPersistence {
|
|
|
return remember ? 'persistent' : 'session';
|
|
|
@@ -32,6 +33,38 @@ function consumeSavedRememberMe(): boolean {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+// Only accept same-origin internal paths. Rejects protocol-relative (`//evil.com`),
|
|
|
+// absolute URLs, and the login page itself (would loop). Anything else falls
|
|
|
+// back to `/` so a tampered sessionStorage entry can't open-redirect.
|
|
|
+function sanitizeRedirectTarget(target: string | null | undefined): string | null {
|
|
|
+ if (!target) return null;
|
|
|
+ if (!target.startsWith('/')) return null;
|
|
|
+ if (target.startsWith('//')) return null;
|
|
|
+ if (target.startsWith('/login')) return null;
|
|
|
+ return target;
|
|
|
+}
|
|
|
+
|
|
|
+function stashPostLoginRedirect(target: string): void {
|
|
|
+ const safe = sanitizeRedirectTarget(target);
|
|
|
+ if (!safe) return;
|
|
|
+ try {
|
|
|
+ sessionStorage.setItem(POST_LOGIN_REDIRECT_KEY, safe);
|
|
|
+ } catch (err) {
|
|
|
+ console.warn('stashPostLoginRedirect: sessionStorage unavailable, post-login target will be lost across OIDC redirect', err);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function consumePostLoginRedirect(): string | null {
|
|
|
+ try {
|
|
|
+ const saved = sessionStorage.getItem(POST_LOGIN_REDIRECT_KEY);
|
|
|
+ sessionStorage.removeItem(POST_LOGIN_REDIRECT_KEY);
|
|
|
+ return sanitizeRedirectTarget(saved);
|
|
|
+ } catch (err) {
|
|
|
+ console.warn('consumePostLoginRedirect: sessionStorage unavailable', err);
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
/**
|
|
|
* Single OIDC-provider login button. Extracted from the `.map()` body
|
|
|
* because hooks can't be used inside a loop callback — the `iconFailed`
|
|
|
@@ -77,12 +110,27 @@ function OIDCProviderButton({
|
|
|
|
|
|
export function LoginPage() {
|
|
|
const navigate = useNavigate();
|
|
|
+ const location = useLocation();
|
|
|
const [searchParams] = useSearchParams();
|
|
|
const { t } = useTranslation();
|
|
|
const { login, loginWithToken } = useAuth();
|
|
|
const { showToast } = useToast();
|
|
|
const { mode } = useTheme();
|
|
|
|
|
|
+ // Resolve the post-login destination, preferring router state (set by
|
|
|
+ // ProtectedRoute when it redirects an unauthed visit) over the sessionStorage
|
|
|
+ // stash (used to survive the OIDC provider round-trip, which kills React
|
|
|
+ // state). Falls back to `/` and rejects unsafe targets via sanitize.
|
|
|
+ function resolvePostLoginRedirect(): string {
|
|
|
+ const fromState = (location.state as { from?: { pathname?: string; search?: string } } | null)?.from;
|
|
|
+ if (fromState?.pathname) {
|
|
|
+ const target = `${fromState.pathname}${fromState.search ?? ''}`;
|
|
|
+ const safe = sanitizeRedirectTarget(target);
|
|
|
+ if (safe) return safe;
|
|
|
+ }
|
|
|
+ return consumePostLoginRedirect() ?? '/';
|
|
|
+ }
|
|
|
+
|
|
|
// Credentials step state
|
|
|
const [username, setUsername] = useState('');
|
|
|
const [password, setPassword] = useState('');
|
|
|
@@ -190,7 +238,7 @@ export function LoginPage() {
|
|
|
} else if (resp.access_token && resp.user) {
|
|
|
loginWithToken(resp.access_token, resp.user, toPersistence(savedRememberMe));
|
|
|
showToast(t('login.loginSuccess'));
|
|
|
- navigate('/', { replace: true });
|
|
|
+ navigate(resolvePostLoginRedirect(), { replace: true });
|
|
|
} else {
|
|
|
showToast(t('login.oidcLoginFailed'), 'error');
|
|
|
navigate('/login', { replace: true });
|
|
|
@@ -219,7 +267,7 @@ export function LoginPage() {
|
|
|
setStep('2fa');
|
|
|
} else if (resp.access_token && resp.user) {
|
|
|
showToast(t('login.loginSuccess'));
|
|
|
- navigate('/');
|
|
|
+ navigate(resolvePostLoginRedirect(), { replace: true });
|
|
|
}
|
|
|
},
|
|
|
onError: (error: Error) => {
|
|
|
@@ -275,7 +323,7 @@ export function LoginPage() {
|
|
|
if (resp.access_token && resp.user) {
|
|
|
loginWithToken(resp.access_token, resp.user, toPersistence(rememberMe));
|
|
|
showToast(t('login.loginSuccess'));
|
|
|
- navigate('/');
|
|
|
+ navigate(resolvePostLoginRedirect(), { replace: true });
|
|
|
} else {
|
|
|
console.error('2FA verify: unexpected response shape', resp);
|
|
|
showToast(t('login.loginFailed'), 'error');
|
|
|
@@ -298,6 +346,13 @@ export function LoginPage() {
|
|
|
console.warn('setItem auth_remember_me failed, Remember Me will not carry through OIDC redirect', err);
|
|
|
}
|
|
|
}
|
|
|
+ // Stash the post-login destination from router state so it survives the
|
|
|
+ // provider round-trip (window.location.href kills React state). If the
|
|
|
+ // user landed on /login directly, fromState is absent and we don't stash.
|
|
|
+ const fromState = (location.state as { from?: { pathname?: string; search?: string } } | null)?.from;
|
|
|
+ if (fromState?.pathname) {
|
|
|
+ stashPostLoginRedirect(`${fromState.pathname}${fromState.search ?? ''}`);
|
|
|
+ }
|
|
|
window.location.href = data.auth_url;
|
|
|
},
|
|
|
onError: (error: Error) => {
|