diff --git a/client/src/App.tsx b/client/src/App.tsx
index 5e0f5ed2..f5d96b51 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -58,7 +58,7 @@ function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedR
}
if (!isAuthenticated) {
- const redirectParam = encodeURIComponent(location.pathname + location.search)
+ const redirectParam = encodeURIComponent(location.pathname + location.search + location.hash)
return
}
diff --git a/client/src/api/client.ts b/client/src/api/client.ts
index 378ddeab..e39c6a47 100644
--- a/client/src/api/client.ts
+++ b/client/src/api/client.ts
@@ -75,7 +75,7 @@ apiClient.interceptors.response.use(
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
const { pathname } = window.location
if (!isAuthPublicPath(pathname)) {
- const currentPath = pathname + window.location.search
+ const currentPath = pathname + window.location.search + window.location.hash
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
}
}
diff --git a/client/src/pages/LoginPage.oidc-redirect.test.tsx b/client/src/pages/LoginPage.oidc-redirect.test.tsx
new file mode 100644
index 00000000..c14e0d96
--- /dev/null
+++ b/client/src/pages/LoginPage.oidc-redirect.test.tsx
@@ -0,0 +1,105 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { render, screen, waitFor } from '../../tests/helpers/render';
+import { http, HttpResponse } from 'msw';
+import { server } from '../../tests/helpers/msw/server';
+import { resetAllStores } from '../../tests/helpers/store';
+import LoginPage from './LoginPage';
+
+const mockNavigate = vi.fn();
+vi.mock('react-router-dom', async () => {
+ const actual = await vi.importActual('react-router-dom');
+ return { ...actual, useNavigate: () => mockNavigate };
+});
+
+describe('LoginPage — OIDC redirect preservation', () => {
+ let savedLocation: Location;
+
+ beforeEach(() => {
+ resetAllStores();
+ mockNavigate.mockClear();
+ sessionStorage.clear();
+ savedLocation = window.location;
+ });
+
+ afterEach(() => {
+ Object.defineProperty(window, 'location', {
+ configurable: true,
+ writable: true,
+ value: savedLocation,
+ });
+ });
+
+ function setSearch(search: string) {
+ Object.defineProperty(window, 'location', {
+ configurable: true,
+ writable: true,
+ value: { ...window.location, search },
+ });
+ }
+
+ describe('FE-PAGE-LOGIN-022: redirect param stashed in sessionStorage on mount', () => {
+ it('saves decoded redirect to sessionStorage when ?redirect= is present', async () => {
+ setSearch('?redirect=%2Foauth%2Fauthorize%3Fclient_id%3Dfoo');
+ render();
+
+ await waitFor(() => {
+ expect(sessionStorage.getItem('oidc_redirect')).toBe('/oauth/authorize?client_id=foo');
+ });
+ });
+
+ it('does not write to sessionStorage when no redirect param is present', async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.getByPlaceholderText('your@email.com')).toBeInTheDocument();
+ });
+
+ expect(sessionStorage.getItem('oidc_redirect')).toBeNull();
+ });
+ });
+
+ describe('FE-PAGE-LOGIN-023: OIDC code exchange navigates to sessionStorage redirect', () => {
+ beforeEach(() => {
+ server.use(
+ http.get('/api/auth/oidc/exchange', () =>
+ HttpResponse.json({ token: 'mock-oidc-token' })
+ ),
+ );
+ });
+
+ it('navigates to the saved sessionStorage redirect after successful OIDC exchange', async () => {
+ sessionStorage.setItem('oidc_redirect', '/oauth/authorize?client_id=foo&state=xyz');
+ setSearch('?oidc_code=testcode123');
+ render();
+
+ await waitFor(() => {
+ expect(mockNavigate).toHaveBeenCalledWith(
+ '/oauth/authorize?client_id=foo&state=xyz',
+ { replace: true },
+ );
+ });
+
+ expect(sessionStorage.getItem('oidc_redirect')).toBeNull();
+ });
+
+ it('falls back to /dashboard when no sessionStorage redirect is set', async () => {
+ setSearch('?oidc_code=testcode123');
+ render();
+
+ await waitFor(() => {
+ expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true });
+ });
+ });
+ });
+
+ describe('FE-PAGE-LOGIN-024: OIDC error clears sessionStorage redirect', () => {
+ it('removes oidc_redirect from sessionStorage on OIDC error', async () => {
+ sessionStorage.setItem('oidc_redirect', '/oauth/authorize?client_id=foo');
+ setSearch('?oidc_error=token_failed');
+ render();
+
+ await waitFor(() => {
+ expect(sessionStorage.getItem('oidc_redirect')).toBeNull();
+ });
+ });
+ });
+});
diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx
index d4646635..6fa2c192 100644
--- a/client/src/pages/LoginPage.tsx
+++ b/client/src/pages/LoginPage.tsx
@@ -55,6 +55,12 @@ export default function LoginPage(): React.ReactElement {
return '/dashboard'
}, [])
+ useEffect(() => {
+ if (redirectTarget !== '/dashboard') {
+ sessionStorage.setItem('oidc_redirect', redirectTarget)
+ }
+ }, [redirectTarget])
+
useEffect(() => {
const params = new URLSearchParams(window.location.search)
@@ -83,7 +89,9 @@ export default function LoginPage(): React.ReactElement {
window.history.replaceState({}, '', '/login')
if (data.token) {
await loadUser()
- navigate('/dashboard', { replace: true })
+ const savedRedirect = sessionStorage.getItem('oidc_redirect') || '/dashboard'
+ sessionStorage.removeItem('oidc_redirect')
+ navigate(savedRedirect, { replace: true })
} else {
setError(data.error || t('login.oidcFailed'))
}
@@ -104,6 +112,7 @@ export default function LoginPage(): React.ReactElement {
invalid_state: t('login.oidc.invalidState'),
}
setError(errorMessages[oidcError] || oidcError)
+ sessionStorage.removeItem('oidc_redirect')
window.history.replaceState({}, '', '/login')
return
}
diff --git a/client/src/pages/OAuthAuthorizePage.tsx b/client/src/pages/OAuthAuthorizePage.tsx
index 457d96ae..681326f2 100644
--- a/client/src/pages/OAuthAuthorizePage.tsx
+++ b/client/src/pages/OAuthAuthorizePage.tsx
@@ -124,7 +124,7 @@ export default function OAuthAuthorizePage(): React.ReactElement {
}
function handleLoginRedirect() {
- const next = '/oauth/authorize?' + params.toString()
+ const next = '/oauth/authorize?' + params.toString() + window.location.hash
window.location.href = '/login?redirect=' + encodeURIComponent(next)
}