Skip to content

Commit fe770e2

Browse files
committed
Preserve theme after redirect
1 parent cd33b4b commit fe770e2

File tree

5 files changed

+71
-26
lines changed

5 files changed

+71
-26
lines changed
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { describe, it, expect, afterAll } from 'vitest';
2+
import { getRedirectSuffix } from './getRedirectSuffix';
3+
4+
const originalLocation = globalThis.location;
5+
6+
function mockLocation(search: string, hash: string) {
7+
Object.defineProperty(globalThis, 'location', {
8+
value: { ...originalLocation, search, hash },
9+
writable: true,
10+
configurable: true,
11+
});
12+
}
13+
14+
// Restore the real object once all tests have finished
15+
afterAll(() => {
16+
Object.defineProperty(globalThis, 'location', {
17+
value: originalLocation,
18+
writable: true,
19+
configurable: true,
20+
});
21+
});
22+
23+
describe('getRedirectSuffix()', () => {
24+
it('returns "/{search}{hash}" when both parts are present', () => {
25+
mockLocation('?sap-theme=sap_horizon', '#/mcp/projects');
26+
expect(getRedirectSuffix()).toBe('/?sap-theme=sap_horizon#/mcp/projects');
27+
});
28+
29+
it('returns "/{search}" when only the query string exists', () => {
30+
mockLocation('?query=foo', '');
31+
expect(getRedirectSuffix()).toBe('/?query=foo');
32+
});
33+
34+
it('returns "{hash}" when only the hash fragment exists', () => {
35+
mockLocation('', '#/dashboard');
36+
expect(getRedirectSuffix()).toBe('#/dashboard');
37+
});
38+
39+
it('returns an empty string when neither search nor hash exist', () => {
40+
mockLocation('', '');
41+
expect(getRedirectSuffix()).toBe('');
42+
});
43+
});
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/**
2+
* Generates the part of the URL (query string and hash fragments) that must be kept when redirecting the user.
3+
*
4+
* @example
5+
* ```ts
6+
* // Current URL: https://example.com/?sap-theme=sap_horizon#/mcp/projects
7+
*
8+
* const redirectTo = getRedirectSuffix();
9+
* // redirectTo -> "/?sap-theme=sap_horizon#/mcp/projects"
10+
* ```
11+
*/
12+
export function getRedirectSuffix() {
13+
const { search, hash } = globalThis.location;
14+
return (search ? `/${search}` : '') + hash;
15+
}

src/lib/api/fetch.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { APIError } from './error';
22
import { ApiConfig } from './types/apiConfig';
3+
import { AUTH_FLOW_SESSION_KEY } from '../../common/auth/AuthCallbackHandler.tsx';
4+
import { getRedirectSuffix } from '../../common/auth/getRedirectSuffix.ts';
35

46
const useCrateClusterHeader = 'X-use-crate';
57
const projectNameHeader = 'X-project';
@@ -48,13 +50,11 @@ export const fetchApiServer = async (
4850

4951
if (!res.ok) {
5052
if (res.status === 401) {
51-
// Unauthorized, redirect to the login page
52-
window.location.replace('/api/auth/onboarding/login');
53+
// Unauthorized (token expired), redirect to the login page
54+
sessionStorage.setItem(AUTH_FLOW_SESSION_KEY, 'onboarding');
55+
window.location.replace(`/api/auth/onboarding/login?redirectTo=${encodeURIComponent(getRedirectSuffix())}`);
5356
}
54-
const error = new APIError(
55-
'An error occurred while fetching the data.',
56-
res.status,
57-
);
57+
const error = new APIError('An error occurred while fetching the data.', res.status);
5858
error.info = await res.json();
5959
throw error;
6060
}

src/spaces/mcp/auth/AuthContextMcp.tsx

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createContext, useState, useEffect, ReactNode, use } from 'react';
22
import { MeResponseSchema } from './auth.schemas';
33
import { AUTH_FLOW_SESSION_KEY } from '../../../common/auth/AuthCallbackHandler.tsx';
4+
import { getRedirectSuffix } from '../../../common/auth/getRedirectSuffix.ts';
45

56
interface AuthContextMcpType {
67
isLoading: boolean;
@@ -34,18 +35,13 @@ export function AuthProviderMcp({ children }: { children: ReactNode }) {
3435
} catch (_) {
3536
/* safe to ignore */
3637
}
37-
throw new Error(
38-
errorBody?.message ||
39-
`Authentication check failed with status: ${response.status}`,
40-
);
38+
throw new Error(errorBody?.message || `Authentication check failed with status: ${response.status}`);
4139
}
4240

4341
const body = await response.json();
4442
const validationResult = MeResponseSchema.safeParse(body);
4543
if (!validationResult.success) {
46-
throw new Error(
47-
`Auth API response validation failed: ${validationResult.error.flatten()}`,
48-
);
44+
throw new Error(`Auth API response validation failed: ${validationResult.error.flatten()}`);
4945
}
5046

5147
const { isAuthenticated: apiIsAuthenticated } = validationResult.data;
@@ -60,17 +56,10 @@ export function AuthProviderMcp({ children }: { children: ReactNode }) {
6056

6157
const login = () => {
6258
sessionStorage.setItem(AUTH_FLOW_SESSION_KEY, 'mcp');
63-
64-
window.location.replace(
65-
`/api/auth/mcp/login?redirectTo=${encodeURIComponent(window.location.hash)}`,
66-
);
59+
window.location.replace(`/api/auth/mcp/login?redirectTo=${encodeURIComponent(getRedirectSuffix())}`);
6760
};
6861

69-
return (
70-
<AuthContextMcp value={{ isLoading, isAuthenticated, error, login }}>
71-
{children}
72-
</AuthContextMcp>
73-
);
62+
return <AuthContextMcp value={{ isLoading, isAuthenticated, error, login }}>{children}</AuthContextMcp>;
7463
}
7564

7665
export const useAuthMcp = () => {

src/spaces/onboarding/auth/AuthContextOnboarding.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createContext, useState, useEffect, ReactNode, use } from 'react';
22
import { MeResponseSchema, User } from './auth.schemas';
33
import { AUTH_FLOW_SESSION_KEY } from '../../../common/auth/AuthCallbackHandler.tsx';
44
import * as Sentry from '@sentry/react';
5+
import { getRedirectSuffix } from '../../../common/auth/getRedirectSuffix.ts';
56

67
interface AuthContextOnboardingType {
78
isLoading: boolean;
@@ -67,10 +68,7 @@ export function AuthProviderOnboarding({ children }: { children: ReactNode }) {
6768

6869
const login = () => {
6970
sessionStorage.setItem(AUTH_FLOW_SESSION_KEY, 'onboarding');
70-
// The query parameters and hash fragments need to be preserved, e.g. /?sap-theme=sap_horizon#/mcp/projects
71-
const { search, hash } = window.location;
72-
const redirectTo = (search ? `/${search}` : '') + hash;
73-
window.location.replace(`/api/auth/onboarding/login?redirectTo=${encodeURIComponent(redirectTo)}`);
71+
window.location.replace(`/api/auth/onboarding/login?redirectTo=${encodeURIComponent(getRedirectSuffix())}`);
7472
};
7573

7674
const logout = async () => {

0 commit comments

Comments
 (0)