diff --git a/src/common/auth/getRedirectSuffix.spec.ts b/src/common/auth/getRedirectSuffix.spec.ts new file mode 100644 index 00000000..cc3b1345 --- /dev/null +++ b/src/common/auth/getRedirectSuffix.spec.ts @@ -0,0 +1,43 @@ +import { describe, it, expect, afterAll } from 'vitest'; +import { getRedirectSuffix } from './getRedirectSuffix'; + +const originalLocation = globalThis.location; + +function mockLocation(search: string, hash: string) { + Object.defineProperty(globalThis, 'location', { + value: { ...originalLocation, search, hash }, + writable: true, + configurable: true, + }); +} + +// Restore the real object once all tests have finished +afterAll(() => { + Object.defineProperty(globalThis, 'location', { + value: originalLocation, + writable: true, + configurable: true, + }); +}); + +describe('getRedirectSuffix()', () => { + it('returns "/{search}{hash}" when both parts are present', () => { + mockLocation('?sap-theme=sap_horizon', '#/mcp/projects'); + expect(getRedirectSuffix()).toBe('/?sap-theme=sap_horizon#/mcp/projects'); + }); + + it('returns "/{search}" when only the query string exists', () => { + mockLocation('?query=foo', ''); + expect(getRedirectSuffix()).toBe('/?query=foo'); + }); + + it('returns "{hash}" when only the hash fragment exists', () => { + mockLocation('', '#/dashboard'); + expect(getRedirectSuffix()).toBe('#/dashboard'); + }); + + it('returns an empty string when neither search nor hash exist', () => { + mockLocation('', ''); + expect(getRedirectSuffix()).toBe(''); + }); +}); diff --git a/src/common/auth/getRedirectSuffix.ts b/src/common/auth/getRedirectSuffix.ts new file mode 100644 index 00000000..cdf53bdc --- /dev/null +++ b/src/common/auth/getRedirectSuffix.ts @@ -0,0 +1,15 @@ +/** + * Generates the part of the URL (query string and hash fragments) that must be kept when redirecting the user. + * + * @example + * ```ts + * // Current URL: https://example.com/?sap-theme=sap_horizon#/mcp/projects + * + * const redirectTo = getRedirectSuffix(); + * // redirectTo -> "/?sap-theme=sap_horizon#/mcp/projects" + * ``` + */ +export function getRedirectSuffix() { + const { search, hash } = globalThis.location; + return (search ? `/${search}` : '') + hash; +} diff --git a/src/components/Core/DarkModeSystemSwitcher.tsx b/src/components/Core/DarkModeSystemSwitcher.tsx deleted file mode 100644 index 56621634..00000000 --- a/src/components/Core/DarkModeSystemSwitcher.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { useEffect } from 'react'; -import { setTheme } from '@ui5/webcomponents-base/dist/config/Theme.js'; - -export function DarkModeSystemSwitcher() { - useEffect(() => { - if (!window.matchMedia) { - console.warn('Dark mode system switcher is not supported in this browser'); - return; - } - - const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); - - const handleChange = (e: MediaQueryListEvent) => { - setTheme(e.matches ? 'sap_horizon_dark' : 'sap_horizon'); - }; - - mediaQuery.addEventListener('change', handleChange); - - const initialMode = mediaQuery.matches ? 'sap_horizon_dark' : 'sap_horizon'; - setTheme(initialMode); - - return () => { - mediaQuery.removeEventListener('change', handleChange); - }; - }, []); - - return null; // albo <> -} diff --git a/src/components/ThemeManager.tsx b/src/components/ThemeManager.tsx new file mode 100644 index 00000000..e7e43dc1 --- /dev/null +++ b/src/components/ThemeManager.tsx @@ -0,0 +1,13 @@ +import { useEffect } from 'react'; +import { setTheme } from '@ui5/webcomponents-base/dist/config/Theme.js'; +import { useTheme } from '../hooks/useTheme.ts'; + +export function ThemeManager() { + const { theme } = useTheme(); + + useEffect(() => { + void setTheme(theme); + }, [theme]); + + return null; +} diff --git a/src/components/Yaml/YamlViewer.tsx b/src/components/Yaml/YamlViewer.tsx index beb95cdc..fa1cefa7 100644 --- a/src/components/Yaml/YamlViewer.tsx +++ b/src/components/Yaml/YamlViewer.tsx @@ -6,12 +6,12 @@ import { Button, FlexBox } from '@ui5/webcomponents-react'; import styles from './YamlViewer.module.css'; import { useToast } from '../../context/ToastContext.tsx'; import { useTranslation } from 'react-i18next'; -import { useThemeMode } from '../../lib/useThemeMode.ts'; +import { useTheme } from '../../hooks/useTheme.ts'; type YamlViewerProps = { yamlString: string; filename: string }; const YamlViewer: FC = ({ yamlString, filename }) => { const toast = useToast(); const { t } = useTranslation(); - const { isDarkMode } = useThemeMode(); + const { isDarkTheme } = useTheme(); const copyToClipboard = () => { navigator.clipboard.writeText(yamlString); toast.show(t('yaml.copiedToClipboard')); @@ -40,7 +40,7 @@ const YamlViewer: FC = ({ yamlString, filename }) => { ([ + Theme.sap_fiori_3_dark, + Theme.sap_fiori_3_hcb, + Theme.sap_horizon_dark, + Theme.sap_horizon_hcb, +]); + +function useSystemDarkModePreference() { + return useSyncExternalStore( + (callback) => { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + mediaQuery.addEventListener('change', callback); + return () => mediaQuery.removeEventListener('change', callback); + }, + () => window.matchMedia('(prefers-color-scheme: dark)').matches, + ); +} + +export function useTheme() { + const systemPrefersDark = useSystemDarkModePreference(); + const themeFromUrl = new URL(window.location.href).searchParams.get('sap-theme'); + + // Theme from URL takes precedence over system settings + const theme = themeFromUrl || (systemPrefersDark ? DEFAULT_THEME_DARK : DEFAULT_THEME_LIGHT); + + // For well-defined SAP themes, return if they are light or dark – unknown themes will fall back to light + const isDarkTheme = DARK_SAP_THEMES.has(theme); + + return { + theme, + isDarkTheme, + }; +} diff --git a/src/lib/api/fetch.ts b/src/lib/api/fetch.ts index ef6129ed..d367a5c4 100644 --- a/src/lib/api/fetch.ts +++ b/src/lib/api/fetch.ts @@ -1,5 +1,7 @@ import { APIError } from './error'; import { ApiConfig } from './types/apiConfig'; +import { AUTH_FLOW_SESSION_KEY } from '../../common/auth/AuthCallbackHandler.tsx'; +import { getRedirectSuffix } from '../../common/auth/getRedirectSuffix.ts'; const useCrateClusterHeader = 'X-use-crate'; const projectNameHeader = 'X-project'; @@ -48,8 +50,9 @@ export const fetchApiServer = async ( if (!res.ok) { if (res.status === 401) { - // Unauthorized, redirect to the login page - window.location.replace('/api/auth/onboarding/login'); + // Unauthorized (token expired), redirect to the login page + sessionStorage.setItem(AUTH_FLOW_SESSION_KEY, 'onboarding'); + window.location.replace(`/api/auth/onboarding/login?redirectTo=${encodeURIComponent(getRedirectSuffix())}`); } const error = new APIError('An error occurred while fetching the data.', res.status); error.info = await res.json(); diff --git a/src/lib/useThemeMode.ts b/src/lib/useThemeMode.ts deleted file mode 100644 index 5ed9c0e2..00000000 --- a/src/lib/useThemeMode.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const useThemeMode = (): { - isDarkMode: boolean; - mode: 'dark' | 'light'; -} => { - const isDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches; - return { isDarkMode: isDarkMode, mode: isDarkMode ? 'dark' : 'light' }; -}; diff --git a/src/main.tsx b/src/main.tsx index 378b9cab..5ad8a947 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -7,7 +7,7 @@ import { ToastProvider } from './context/ToastContext.tsx'; import { CopyButtonProvider } from './context/CopyButtonContext.tsx'; import { FrontendConfigProvider } from './context/FrontendConfigContext.tsx'; import '@ui5/webcomponents-react/dist/Assets'; //used for loading themes -import { DarkModeSystemSwitcher } from './components/Core/DarkModeSystemSwitcher.tsx'; +import { ThemeManager } from './components/ThemeManager.tsx'; import '.././i18n.ts'; import './utils/i18n/timeAgo'; import { ErrorBoundary, FallbackProps } from 'react-error-boundary'; @@ -49,7 +49,7 @@ export function createApp() { - + diff --git a/src/spaces/mcp/auth/AuthContextMcp.tsx b/src/spaces/mcp/auth/AuthContextMcp.tsx index e7cdd248..e728f8a2 100644 --- a/src/spaces/mcp/auth/AuthContextMcp.tsx +++ b/src/spaces/mcp/auth/AuthContextMcp.tsx @@ -1,6 +1,7 @@ import { createContext, useState, useEffect, ReactNode, use } from 'react'; import { MeResponseSchema } from './auth.schemas'; import { AUTH_FLOW_SESSION_KEY } from '../../../common/auth/AuthCallbackHandler.tsx'; +import { getRedirectSuffix } from '../../../common/auth/getRedirectSuffix.ts'; interface AuthContextMcpType { isLoading: boolean; @@ -55,8 +56,7 @@ export function AuthProviderMcp({ children }: { children: ReactNode }) { const login = () => { sessionStorage.setItem(AUTH_FLOW_SESSION_KEY, 'mcp'); - - window.location.replace(`/api/auth/mcp/login?redirectTo=${encodeURIComponent(window.location.hash)}`); + window.location.replace(`/api/auth/mcp/login?redirectTo=${encodeURIComponent(getRedirectSuffix())}`); }; return {children}; diff --git a/src/spaces/onboarding/auth/AuthContextOnboarding.tsx b/src/spaces/onboarding/auth/AuthContextOnboarding.tsx index f6f76113..4efe7a0c 100644 --- a/src/spaces/onboarding/auth/AuthContextOnboarding.tsx +++ b/src/spaces/onboarding/auth/AuthContextOnboarding.tsx @@ -2,6 +2,7 @@ import { createContext, useState, useEffect, ReactNode, use } from 'react'; import { MeResponseSchema, User } from './auth.schemas'; import { AUTH_FLOW_SESSION_KEY } from '../../../common/auth/AuthCallbackHandler.tsx'; import * as Sentry from '@sentry/react'; +import { getRedirectSuffix } from '../../../common/auth/getRedirectSuffix.ts'; interface AuthContextOnboardingType { isLoading: boolean; @@ -67,8 +68,7 @@ export function AuthProviderOnboarding({ children }: { children: ReactNode }) { const login = () => { sessionStorage.setItem(AUTH_FLOW_SESSION_KEY, 'onboarding'); - - window.location.replace(`/api/auth/onboarding/login?redirectTo=${encodeURIComponent(window.location.hash)}`); + window.location.replace(`/api/auth/onboarding/login?redirectTo=${encodeURIComponent(getRedirectSuffix())}`); }; const logout = async () => {