diff --git a/frontend/src/config.ts b/frontend/src/config.ts
index 207d07feca..a99843c936 100644
--- a/frontend/src/config.ts
+++ b/frontend/src/config.ts
@@ -44,6 +44,7 @@ import { api, useApiStore } from './state/backend-api';
import { useUIStateStore } from './state/ui-state';
import { AppFeatures, getBasePath } from './utils/env';
import { getEmbeddedAvailableRoutes } from './utils/route-utils';
+import { getRegisteredTokenRefreshInterceptor } from './utils/token-refresh-interceptor';
declare const __webpack_public_path__: string;
@@ -206,11 +207,18 @@ const setConfig = ({
}: SetConfigArguments) => {
const assetsUrl =
urlOverride?.assets === 'WEBPACK' ? String(__webpack_public_path__).removeSuffix('/') : urlOverride?.assets;
+ const configuredFetch = fetch ?? window.fetch.bind(window);
+ const tokenRefreshInterceptor = getRegisteredTokenRefreshInterceptor();
// instantiate the client once, if we need to add more clients you can add them in here, ideally only one transport is necessary
const dataplaneTransport = createConnectTransport({
baseUrl: getGrpcBasePath(urlOverride?.grpc),
- interceptors: [addBearerTokenInterceptor, checkExpiredLicenseInterceptor],
+ fetch: configuredFetch,
+ interceptors: [
+ addBearerTokenInterceptor,
+ ...(tokenRefreshInterceptor ? [tokenRefreshInterceptor] : []),
+ checkExpiredLicenseInterceptor,
+ ],
jsonOptions: {
registry: protobufRegistry,
},
@@ -218,7 +226,8 @@ const setConfig = ({
const controlplaneTransport = createConnectTransport({
baseUrl: config.controlplaneUrl,
- interceptors: [addBearerTokenInterceptor],
+ fetch: configuredFetch,
+ interceptors: [addBearerTokenInterceptor, ...(tokenRefreshInterceptor ? [tokenRefreshInterceptor] : [])],
jsonOptions: {
registry: protobufRegistry,
},
@@ -250,7 +259,7 @@ const setConfig = ({
restBasePath: getRestBasePath(urlOverride?.rest),
grpcBasePath: getGrpcBasePath(urlOverride?.grpc),
controlplaneUrl: config.controlplaneUrl,
- fetch: fetch ?? window.fetch.bind(window),
+ fetch: configuredFetch,
assetsPath: assetsUrl ?? getBasePath(),
authenticationClient: authenticationGrpcClient,
licenseClient: licenseGrpcClient,
@@ -352,7 +361,7 @@ setTimeout(() => {
updateSidebarItems();
}
});
- } catch (error) {
+ } catch (_error) {
// Ignore errors in test environments where stores might not be properly initialized
// This setTimeout runs globally when config.ts is imported
}
diff --git a/frontend/src/embedded-app.test.tsx b/frontend/src/embedded-app.test.tsx
new file mode 100644
index 0000000000..e56b8ba8aa
--- /dev/null
+++ b/frontend/src/embedded-app.test.tsx
@@ -0,0 +1,94 @@
+import { render } from '@testing-library/react';
+import type { ReactNode } from 'react';
+
+const { mockCreateConnectTransport, mockCreateRouter, mockFetch, mockSetup } = vi.hoisted(() => ({
+ mockCreateConnectTransport: vi.fn((options) => options),
+ mockCreateRouter: vi.fn(() => ({
+ invalidate: vi.fn().mockResolvedValue(undefined),
+ })),
+ mockFetch: vi.fn(),
+ mockSetup: vi.fn(),
+}));
+
+vi.mock('@connectrpc/connect-query', () => ({
+ TransportProvider: ({ children }: { children?: ReactNode }) => <>{children}>,
+}));
+
+vi.mock('@connectrpc/connect-web', () => ({
+ createConnectTransport: mockCreateConnectTransport,
+}));
+
+vi.mock('@redpanda-data/ui', () => ({
+ ChakraProvider: ({ children }: { children?: ReactNode }) => <>{children}>,
+ redpandaTheme: {},
+ redpandaToastOptions: {},
+}));
+
+vi.mock('@tanstack/react-query', async () => {
+ const actual = await vi.importActual('@tanstack/react-query');
+ return {
+ ...actual,
+ QueryClientProvider: ({ children }: { children?: ReactNode }) => <>{children}>,
+ };
+});
+
+vi.mock('@tanstack/react-router', () => ({
+ createRouter: mockCreateRouter,
+ RouterProvider: () =>
,
+}));
+
+vi.mock('custom-feature-flag-provider', () => ({
+ CustomFeatureFlagProvider: ({ children }: { children?: ReactNode }) => <>{children}>,
+}));
+
+vi.mock('protobuf-registry', () => ({
+ protobufRegistry: {},
+}));
+
+vi.mock('./components/misc/not-found-page', () => ({
+ NotFoundPage: () => Not Found
,
+}));
+
+vi.mock('./config', () => ({
+ addBearerTokenInterceptor: vi.fn((next) => next),
+ checkExpiredLicenseInterceptor: vi.fn((next) => next),
+ getGrpcBasePath: vi.fn(() => 'http://localhost:9090'),
+ setup: mockSetup,
+}));
+
+vi.mock('./routeTree.gen', () => ({
+ routeTree: {},
+}));
+
+vi.mock('./state/app-global', () => ({
+ appGlobal: {
+ historyLocation: vi.fn(() => ({ pathname: '/topics' })),
+ historyPush: vi.fn(),
+ },
+}));
+
+vi.mock('./state/backend-api', () => ({
+ api: {
+ refreshUserData: vi.fn().mockResolvedValue(undefined),
+ },
+}));
+
+import EmbeddedApp from './embedded-app';
+
+describe('EmbeddedApp', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ test('uses the host-provided fetch for the root dataplane transport', () => {
+ render();
+
+ expect(mockSetup).toHaveBeenCalled();
+ expect(mockCreateConnectTransport).toHaveBeenCalledWith(
+ expect.objectContaining({
+ baseUrl: 'http://localhost:9090',
+ fetch: mockFetch,
+ })
+ );
+ });
+});
diff --git a/frontend/src/embedded-app.tsx b/frontend/src/embedded-app.tsx
index 318f4b21ff..f60810acf2 100644
--- a/frontend/src/embedded-app.tsx
+++ b/frontend/src/embedded-app.tsx
@@ -52,6 +52,12 @@ import {
} from './config';
import { routeTree } from './routeTree.gen';
import { appGlobal } from './state/app-global';
+import { api } from './state/backend-api';
+import {
+ getRegisteredTokenRefreshInterceptor,
+ TokenRefreshInterceptorProvider,
+} from './utils/token-refresh-interceptor';
+import { useEmbeddedAuthPrewarm } from './utils/use-embedded-auth-prewarm';
// Regex for normalizing paths by removing trailing slashes
const TRAILING_SLASH_REGEX = /\/+$/;
@@ -86,6 +92,10 @@ export interface EmbeddedProps extends SetConfigArguments {
}
function EmbeddedApp({ basePath = '', ...p }: EmbeddedProps) {
+ const tokenRefreshInterceptor = getRegisteredTokenRefreshInterceptor();
+ const defaultFetch = useMemo(() => window.fetch.bind(window), []);
+ const configuredFetch = p.fetch ?? defaultFetch;
+
useEffect(() => {
const shellNavigationHandler = (event: Event) => {
const pathname = (event as CustomEvent).detail;
@@ -115,12 +125,17 @@ function EmbeddedApp({ basePath = '', ...p }: EmbeddedProps) {
() =>
createConnectTransport({
baseUrl: getGrpcBasePath(p.urlOverride?.grpc),
- interceptors: [addBearerTokenInterceptor, checkExpiredLicenseInterceptor],
+ fetch: configuredFetch,
+ interceptors: [
+ addBearerTokenInterceptor,
+ ...(tokenRefreshInterceptor ? [tokenRefreshInterceptor] : []),
+ checkExpiredLicenseInterceptor,
+ ],
jsonOptions: {
registry: protobufRegistry,
},
}),
- [p.urlOverride?.grpc]
+ [configuredFetch, p.urlOverride?.grpc, tokenRefreshInterceptor]
);
// Create router with dynamic basePath
@@ -139,20 +154,33 @@ function EmbeddedApp({ basePath = '', ...p }: EmbeddedProps) {
[basePath, dataplaneTransport]
);
+ useEmbeddedAuthPrewarm({
+ enabled: Boolean(p.isConsoleReadyToMount),
+ prewarm: async () => {
+ await api.refreshUserData().catch(() => {
+ // Best-effort prewarm only; embedded hosts handle any hard auth failures.
+ });
+
+ await Promise.allSettled([queryClient.invalidateQueries(), router.invalidate()]);
+ },
+ });
+
if (!p.isConsoleReadyToMount) {
return null;
}
return (
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/frontend/src/federation/console-app.test.tsx b/frontend/src/federation/console-app.test.tsx
index e4b32d9b94..4553e7ccd0 100644
--- a/frontend/src/federation/console-app.test.tsx
+++ b/frontend/src/federation/console-app.test.tsx
@@ -9,7 +9,10 @@
* by the Apache License, Version 2.0
*/
-import { render, waitFor } from '@testing-library/react';
+import { act, render, waitFor } from '@testing-library/react';
+import type { ReactNode } from 'react';
+
+const tokenManagerInstances: Array<{ refresh: ReturnType }> = [];
// Mock TanStack Router before any imports
vi.mock('@tanstack/react-router', async () => {
@@ -19,6 +22,7 @@ vi.mock('@tanstack/react-router', async () => {
createRouter: vi.fn(() => ({
subscribe: vi.fn(() => vi.fn()), // Returns unsubscribe function
load: vi.fn().mockResolvedValue(undefined),
+ invalidate: vi.fn().mockResolvedValue(undefined),
state: { location: { pathname: '/topics' } },
navigate: vi.fn(),
})),
@@ -28,12 +32,22 @@ vi.mock('@tanstack/react-router', async () => {
push: vi.fn(),
replace: vi.fn(),
})),
- RouterProvider: ({ children }: { children?: React.ReactNode }) => (
- {children}
- ),
+ RouterProvider: ({ children }: { children?: ReactNode }) => {children}
,
};
});
+vi.mock('@redpanda-data/ui', () => ({
+ ChakraProvider: ({ children }: { children?: ReactNode }) => <>{children}>,
+ createStandaloneToast: vi.fn(() => ({
+ ToastContainer: () => null,
+ toast: {
+ closeAll: vi.fn(),
+ },
+ })),
+ redpandaTheme: {},
+ redpandaToastOptions: {},
+}));
+
vi.mock('config', () => ({
config: {
jwt: undefined as string | undefined,
@@ -56,6 +70,16 @@ vi.mock('../components/misc/not-found-page', () => ({
NotFoundPage: () => Not Found
,
}));
+vi.mock('../state/backend-api', () => ({
+ api: {
+ refreshUserData: vi.fn().mockResolvedValue(undefined),
+ },
+}));
+
+vi.mock('./federated-providers', () => ({
+ FederatedProviders: ({ children }: { children?: ReactNode }) => <>{children}>,
+}));
+
vi.mock('./token-manager', () => ({
TokenManager: class MockTokenManager {
private getToken: () => Promise;
@@ -70,6 +94,7 @@ vi.mock('./token-manager', () => ({
this.setGetAccessToken.mockImplementation((fn: () => Promise) => {
this.getToken = fn;
});
+ tokenManagerInstances.push(this);
}
},
}));
@@ -99,16 +124,27 @@ describe('ConsoleApp', () => {
beforeEach(() => {
vi.clearAllMocks();
+ tokenManagerInstances.length = 0;
mockGetAccessToken.mockResolvedValue('test-token-123');
// Reset config.jwt
config.jwt = undefined;
});
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
test('shows nothing while loading', () => {
- const { container } = render();
+ const tokenPromise = new Promise(() => {
+ // Intentionally unresolved to keep the component in its loading state.
+ });
+ mockGetAccessToken.mockReturnValueOnce(tokenPromise);
+
+ const { container, unmount } = render();
// Component returns null while initializing
expect(container.firstChild).toBeNull();
+ unmount();
});
test('calls getAccessToken on mount', async () => {
@@ -154,7 +190,9 @@ describe('ConsoleApp', () => {
test('stays in loading state while token refresh is pending', async () => {
// Create a deferred promise that never resolves during this test
- const tokenPromise = new Promise(() => {});
+ const tokenPromise = new Promise(() => {
+ // Intentionally unresolved to keep the component in its loading state.
+ });
mockGetAccessToken.mockReturnValueOnce(tokenPromise);
const { container } = render();
@@ -224,6 +262,25 @@ describe('ConsoleApp', () => {
unmount();
});
+ test('prewarms auth once when focus and visibility events arrive in a burst', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(mockGetAccessToken).toHaveBeenCalledTimes(1);
+ });
+
+ expect(tokenManagerInstances).toHaveLength(1);
+ vi.useFakeTimers();
+
+ await act(async () => {
+ document.dispatchEvent(new Event('visibilitychange'));
+ window.dispatchEvent(new Event('focus'));
+ await vi.advanceTimersByTimeAsync(200);
+ });
+
+ expect(tokenManagerInstances[0]?.refresh).toHaveBeenCalledTimes(2);
+ });
+
describe('Feature Flags', () => {
test('passes feature flags to setup()', async () => {
const flags = { schemaRegistry: true, enableKnowledgeBaseInConsoleUi: false };
diff --git a/frontend/src/federation/console-app.tsx b/frontend/src/federation/console-app.tsx
index 5b09ae8b7b..1ccdc2f00a 100644
--- a/frontend/src/federation/console-app.tsx
+++ b/frontend/src/federation/console-app.tsx
@@ -14,7 +14,7 @@
// Array prototype extensions (must be imported early)
import '../utils/array-extensions';
-import { Component, type ErrorInfo, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
+import { Component, type ErrorInfo, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import '@xyflow/react/dist/base.css';
import '@xyflow/react/dist/style.css';
@@ -32,7 +32,6 @@ import '../assets/fonts/kumbh-sans.css';
import '../globals.css';
/* end tailwind styles */
-import { Code, ConnectError, type Interceptor } from '@connectrpc/connect';
import { createConnectTransport } from '@connectrpc/connect-web';
import { QueryClient } from '@tanstack/react-query';
import { createMemoryHistory, createRouter, RouterProvider } from '@tanstack/react-router';
@@ -44,38 +43,14 @@ import type { ConsoleAppProps } from './types';
import { NotFoundPage } from '../components/misc/not-found-page';
import { addBearerTokenInterceptor, checkExpiredLicenseInterceptor, config, getGrpcBasePath, setup } from '../config';
import { routeTree } from '../routeTree.gen';
-
-/**
- * Creates an interceptor that refreshes the token on 401 and retries the request.
- * Uses TokenManager for deduplication and abort support.
- */
-function createTokenRefreshInterceptor(tokenManager: TokenManager): Interceptor {
- return (next) => async (request) => {
- try {
- return await next(request);
- } catch (error) {
- // Only handle Unauthenticated errors
- if (!(error instanceof ConnectError && error.code === Code.Unauthenticated)) {
- throw error;
- }
-
- // Use TokenManager for deduplicated refresh
- try {
- await tokenManager.refresh();
- } catch {
- throw error; // Throw original error if refresh fails
- }
-
- // Retry the request with refreshed token.
- // Header mutation is necessary because the original request was created
- // with the old token by addBearerTokenInterceptor on the first attempt.
- if (config.jwt) {
- request.header.set('Authorization', `Bearer ${config.jwt}`);
- }
- return await next(request);
- }
- };
-}
+import { api } from '../state/backend-api';
+import { createTokenRefreshInterceptor } from '../utils/create-token-refresh-interceptor';
+import { notifyEmbeddedAuthError } from '../utils/notify-embedded-auth-error';
+import {
+ setRegisteredTokenRefreshInterceptor,
+ TokenRefreshInterceptorProvider,
+} from '../utils/token-refresh-interceptor';
+import { useEmbeddedAuthPrewarm } from '../utils/use-embedded-auth-prewarm';
/**
* Error boundary for the federated Console app.
@@ -170,6 +145,8 @@ function ConsoleAppInner({
const [isInitialized, setIsInitialized] = useState(false);
// Track last notified path to prevent navigation loops between host and remote
const lastNotifiedPathRef = useRef(initialPath);
+ const defaultFetch = useMemo(() => window.fetch.bind(window), []);
+ const configuredFetch = configOverrides?.fetch ?? defaultFetch;
// Create stable QueryClient instance
const queryClient = useMemo(() => createFederatedQueryClient(), []);
@@ -194,11 +171,20 @@ function ConsoleAppInner({
}, [getAccessToken, tokenManager]);
// Create token refresh interceptor using TokenManager
- const tokenRefreshInterceptor = useMemo(() => createTokenRefreshInterceptor(tokenManager), [tokenManager]);
+ const tokenRefreshInterceptor = useMemo(
+ () =>
+ createTokenRefreshInterceptor({
+ getAccessToken: () => config.jwt,
+ refreshAccessToken: () => tokenManager.refresh(),
+ onRefreshFailure: () => notifyEmbeddedAuthError({ clusterId }),
+ }),
+ [clusterId, tokenManager]
+ );
// Initialize Console on mount and cleanup on unmount
useEffect(() => {
const initialize = async () => {
+ setRegisteredTokenRefreshInterceptor(tokenRefreshInterceptor);
await tokenManager.refresh();
// Setup Console config with overrides
@@ -218,22 +204,33 @@ function ConsoleAppInner({
// Cleanup on unmount
return () => {
+ setRegisteredTokenRefreshInterceptor(undefined);
tokenManager.reset();
queryClient.clear();
};
- }, [tokenManager, queryClient, clusterId, onSidebarItemsChange, onBreadcrumbsChange, featureFlags, configOverrides]);
+ }, [
+ tokenManager,
+ tokenRefreshInterceptor,
+ queryClient,
+ clusterId,
+ onSidebarItemsChange,
+ onBreadcrumbsChange,
+ featureFlags,
+ configOverrides,
+ ]);
// Create transport with token interceptors (including refresh on 401)
const dataplaneTransport = useMemo(
() =>
createConnectTransport({
baseUrl: getGrpcBasePath(configOverrides?.urlOverride?.grpc),
+ fetch: configuredFetch,
interceptors: [addBearerTokenInterceptor, tokenRefreshInterceptor, checkExpiredLicenseInterceptor],
jsonOptions: {
registry: protobufRegistry,
},
}),
- [configOverrides?.urlOverride?.grpc, tokenRefreshInterceptor]
+ [configOverrides?.urlOverride?.grpc, configuredFetch, tokenRefreshInterceptor]
);
// Create memory history router (host controls browser URL)
@@ -293,17 +290,39 @@ function ConsoleAppInner({
}
}, [navigateTo, isInitialized, router]);
+ const prewarmEmbeddedAuth = useCallback(async () => {
+ try {
+ await tokenManager.refresh();
+ } catch {
+ notifyEmbeddedAuthError({ clusterId });
+ return;
+ }
+
+ await api.refreshUserData().catch(() => {
+ // Best-effort only. A failed refreshUserData call should not crash the prewarm flow.
+ });
+
+ await Promise.allSettled([queryClient.invalidateQueries(), router.invalidate()]);
+ }, [clusterId, queryClient, router, tokenManager]);
+
+ useEmbeddedAuthPrewarm({
+ enabled: isInitialized,
+ prewarm: prewarmEmbeddedAuth,
+ });
+
// Don't render until initialized, don't show anything until then
if (!isInitialized) {
return null;
}
return (
-
-
-
-
-
+
+
+
+
+
+
+
);
}
diff --git a/frontend/src/hooks/use-ai-gateway-transport.test.tsx b/frontend/src/hooks/use-ai-gateway-transport.test.tsx
new file mode 100644
index 0000000000..2d63ea4575
--- /dev/null
+++ b/frontend/src/hooks/use-ai-gateway-transport.test.tsx
@@ -0,0 +1,54 @@
+import type { Interceptor } from '@connectrpc/connect';
+import { renderHook } from '@testing-library/react';
+import type { PropsWithChildren } from 'react';
+
+const { mockAddBearerTokenInterceptor, mockCreateConnectTransport, mockFetch, mockIsEmbedded } = vi.hoisted(() => ({
+ mockAddBearerTokenInterceptor: vi.fn((next) => next),
+ mockCreateConnectTransport: vi.fn((options) => options),
+ mockFetch: vi.fn(),
+ mockIsEmbedded: vi.fn(() => true),
+}));
+
+vi.mock('@connectrpc/connect-web', () => ({
+ createConnectTransport: mockCreateConnectTransport,
+}));
+
+vi.mock('config', () => ({
+ addBearerTokenInterceptor: mockAddBearerTokenInterceptor,
+ config: {
+ aiGatewayUrl: 'https://gateway.example',
+ fetch: mockFetch,
+ },
+ isEmbedded: mockIsEmbedded,
+}));
+
+vi.mock('protobuf-registry', () => ({
+ protobufRegistry: {},
+}));
+
+import { useAIGatewayTransport } from './use-ai-gateway-transport';
+import { TokenRefreshInterceptorProvider } from '../utils/token-refresh-interceptor';
+
+describe('useAIGatewayTransport', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ test('uses config.fetch and includes the provided token refresh interceptor', () => {
+ const refreshInterceptor = ((next) => next) as unknown as Interceptor;
+
+ const wrapper = ({ children }: PropsWithChildren) => (
+ {children}
+ );
+
+ renderHook(() => useAIGatewayTransport(), { wrapper });
+
+ expect(mockCreateConnectTransport).toHaveBeenCalledWith(
+ expect.objectContaining({
+ baseUrl: 'https://gateway.example/.redpanda/api',
+ fetch: mockFetch,
+ interceptors: [mockAddBearerTokenInterceptor, refreshInterceptor],
+ })
+ );
+ });
+});
diff --git a/frontend/src/hooks/use-ai-gateway-transport.ts b/frontend/src/hooks/use-ai-gateway-transport.ts
index 38d9ba9c56..413dd6f7c3 100644
--- a/frontend/src/hooks/use-ai-gateway-transport.ts
+++ b/frontend/src/hooks/use-ai-gateway-transport.ts
@@ -3,6 +3,7 @@ import { createConnectTransport } from '@connectrpc/connect-web';
import { addBearerTokenInterceptor, config, isEmbedded } from 'config';
import { protobufRegistry } from 'protobuf-registry';
import { useMemo } from 'react';
+import { useTokenRefreshInterceptor } from 'utils/token-refresh-interceptor';
/**
* Custom hook to create and memoize a Connect transport for AI Gateway API calls
@@ -21,12 +22,17 @@ import { useMemo } from 'react';
* @returns Transport instance configured for AI Gateway communication
*/
export const useAIGatewayTransport = (): Transport => {
+ const tokenRefreshInterceptor = useTokenRefreshInterceptor();
+
const aiGatewayTransport = useMemo(() => {
+ const interceptors = [addBearerTokenInterceptor, ...(tokenRefreshInterceptor ? [tokenRefreshInterceptor] : [])];
+
// In development, use relative path and rely on dev server proxy
if (process.env.NODE_ENV === 'development') {
return createConnectTransport({
baseUrl: '/.redpanda/api',
- interceptors: [addBearerTokenInterceptor],
+ fetch: config.fetch,
+ interceptors,
jsonOptions: {
registry: protobufRegistry,
},
@@ -42,7 +48,8 @@ export const useAIGatewayTransport = (): Transport => {
return createConnectTransport({
baseUrl,
- interceptors: [addBearerTokenInterceptor],
+ fetch: config.fetch,
+ interceptors,
jsonOptions: {
registry: protobufRegistry,
},
@@ -52,12 +59,13 @@ export const useAIGatewayTransport = (): Transport => {
// Fallback to relative path for standalone mode
return createConnectTransport({
baseUrl: '/.redpanda/api',
- interceptors: [addBearerTokenInterceptor],
+ fetch: config.fetch,
+ interceptors,
jsonOptions: {
registry: protobufRegistry,
},
});
- }, []);
+ }, [tokenRefreshInterceptor]);
return aiGatewayTransport;
};
diff --git a/frontend/src/hooks/use-controlplane-transport.test.tsx b/frontend/src/hooks/use-controlplane-transport.test.tsx
new file mode 100644
index 0000000000..d1c001e53e
--- /dev/null
+++ b/frontend/src/hooks/use-controlplane-transport.test.tsx
@@ -0,0 +1,56 @@
+import type { Interceptor } from '@connectrpc/connect';
+import { renderHook } from '@testing-library/react';
+
+const { mockAddBearerTokenInterceptor, mockCreateConnectTransport, mockFetch } = vi.hoisted(() => ({
+ mockAddBearerTokenInterceptor: vi.fn((next) => next),
+ mockCreateConnectTransport: vi.fn((options) => options),
+ mockFetch: vi.fn(),
+}));
+
+vi.mock('@connectrpc/connect-web', () => ({
+ createConnectTransport: mockCreateConnectTransport,
+}));
+
+vi.mock('config', () => ({
+ addBearerTokenInterceptor: mockAddBearerTokenInterceptor,
+ config: {
+ controlplaneUrl: 'https://controlplane.example',
+ fetch: mockFetch,
+ },
+}));
+
+vi.mock('protobuf-registry', () => ({
+ protobufRegistry: {},
+}));
+
+import { useControlplaneTransport } from './use-controlplane-transport';
+import { setRegisteredTokenRefreshInterceptor, useTokenRefreshInterceptor } from '../utils/token-refresh-interceptor';
+
+describe('useControlplaneTransport', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ setRegisteredTokenRefreshInterceptor(undefined);
+ });
+
+ afterEach(() => {
+ setRegisteredTokenRefreshInterceptor(undefined);
+ });
+
+ test('falls back to the registered token refresh interceptor and uses config.fetch', () => {
+ const refreshInterceptor = ((next) => next) as unknown as Interceptor;
+ setRegisteredTokenRefreshInterceptor(refreshInterceptor);
+
+ renderHook(() => ({
+ transport: useControlplaneTransport(),
+ interceptor: useTokenRefreshInterceptor(),
+ }));
+
+ expect(mockCreateConnectTransport).toHaveBeenCalledWith(
+ expect.objectContaining({
+ baseUrl: 'https://controlplane.example',
+ fetch: mockFetch,
+ interceptors: [mockAddBearerTokenInterceptor, refreshInterceptor],
+ })
+ );
+ });
+});
diff --git a/frontend/src/hooks/use-controlplane-transport.ts b/frontend/src/hooks/use-controlplane-transport.ts
index 30781adda3..5ad471d8f0 100644
--- a/frontend/src/hooks/use-controlplane-transport.ts
+++ b/frontend/src/hooks/use-controlplane-transport.ts
@@ -3,22 +3,26 @@ import { createConnectTransport } from '@connectrpc/connect-web';
import { addBearerTokenInterceptor, config } from 'config';
import { protobufRegistry } from 'protobuf-registry';
import { useMemo } from 'react';
+import { useTokenRefreshInterceptor } from 'utils/token-refresh-interceptor';
/**
* Custom hook to create and memoize a Connect transport for controlplane API calls
* @returns Transport instance configured for controlplane communication
*/
export const useControlplaneTransport = (): Transport => {
+ const tokenRefreshInterceptor = useTokenRefreshInterceptor();
+
const controlplaneTransport = useMemo(
() =>
createConnectTransport({
baseUrl: config.controlplaneUrl,
- interceptors: [addBearerTokenInterceptor],
+ fetch: config.fetch,
+ interceptors: [addBearerTokenInterceptor, ...(tokenRefreshInterceptor ? [tokenRefreshInterceptor] : [])],
jsonOptions: {
registry: protobufRegistry,
},
}),
- []
+ [tokenRefreshInterceptor]
);
return controlplaneTransport;
diff --git a/frontend/src/state/backend-api.ts b/frontend/src/state/backend-api.ts
index 378748bd0a..373c461077 100644
--- a/frontend/src/state/backend-api.ts
+++ b/frontend/src/state/backend-api.ts
@@ -175,6 +175,7 @@ import fetchWithTimeout from '../utils/fetch-with-timeout';
import { toJson } from '../utils/json-utils';
import { LazyMap } from '../utils/lazy-map';
import { convertListMessageData } from '../utils/message-converters';
+import { notifyEmbeddedAuthError } from '../utils/notify-embedded-auth-error';
import { ObjToKv } from '../utils/tsx-utils';
import { decodeBase64, getOidcSubject, TimeSince } from '../utils/utils';
@@ -256,11 +257,7 @@ async function handle401(res: Response) {
if (inEmbeddedContext) {
// In embedded mode, emit an event for the host to handle re-authentication
// instead of redirecting, which breaks the embedded experience
- window.dispatchEvent(
- new CustomEvent('console:auth-error', {
- detail: { clusterId: appConfig.clusterId, path: window.location.pathname },
- })
- );
+ notifyEmbeddedAuthError({ clusterId: appConfig.clusterId });
// Don't redirect - let the request fail gracefully and allow the host to handle auth
return;
}
diff --git a/frontend/src/utils/create-token-refresh-interceptor.test.ts b/frontend/src/utils/create-token-refresh-interceptor.test.ts
new file mode 100644
index 0000000000..f8d9ad132a
--- /dev/null
+++ b/frontend/src/utils/create-token-refresh-interceptor.test.ts
@@ -0,0 +1,82 @@
+import { Code, ConnectError } from '@connectrpc/connect';
+
+import { createTokenRefreshInterceptor } from './create-token-refresh-interceptor';
+
+describe('createTokenRefreshInterceptor', () => {
+ test('refreshes once and retries unauthenticated requests with the new token', async () => {
+ let token = 'stale-token';
+ const refreshAccessToken = vi.fn().mockImplementation(() => {
+ token = 'fresh-token';
+ return Promise.resolve(token);
+ });
+ const onRefreshFailure = vi.fn();
+ const next = vi
+ .fn()
+ .mockRejectedValueOnce(new ConnectError('expired', Code.Unauthenticated))
+ .mockResolvedValueOnce('ok');
+
+ const interceptor = createTokenRefreshInterceptor({
+ getAccessToken: () => token,
+ refreshAccessToken,
+ onRefreshFailure,
+ });
+
+ const request = { header: new Headers({ Authorization: 'Bearer stale-token' }) };
+
+ const result = await interceptor(next)(request as never);
+
+ expect(result).toBe('ok');
+ expect(refreshAccessToken).toHaveBeenCalledTimes(1);
+ expect(next).toHaveBeenCalledTimes(2);
+ expect(request.header.get('Authorization')).toBe('Bearer fresh-token');
+ expect(onRefreshFailure).not.toHaveBeenCalled();
+ });
+
+ test('calls the refresh failure handler and rethrows the original auth error when refresh fails', async () => {
+ const authError = new ConnectError('expired', Code.Unauthenticated);
+ const refreshError = new Error('refresh failed');
+ const refreshAccessToken = vi.fn().mockRejectedValue(refreshError);
+ const onRefreshFailure = vi.fn();
+ const next = vi.fn().mockRejectedValue(authError);
+
+ const interceptor = createTokenRefreshInterceptor({
+ getAccessToken: () => undefined,
+ refreshAccessToken,
+ onRefreshFailure,
+ });
+
+ await expect(interceptor(next)({ header: new Headers() } as never)).rejects.toBe(authError);
+
+ expect(refreshAccessToken).toHaveBeenCalledTimes(1);
+ expect(next).toHaveBeenCalledTimes(1);
+ expect(onRefreshFailure).toHaveBeenCalledTimes(1);
+ expect(onRefreshFailure).toHaveBeenCalledWith(refreshError);
+ });
+
+ test('does not loop when the retry is still unauthenticated', async () => {
+ let token = 'stale-token';
+ const retryError = new ConnectError('still expired', Code.Unauthenticated);
+ const refreshAccessToken = vi.fn().mockImplementation(() => {
+ token = 'fresh-token';
+ return Promise.resolve(token);
+ });
+ const onRefreshFailure = vi.fn();
+ const next = vi
+ .fn()
+ .mockRejectedValueOnce(new ConnectError('expired', Code.Unauthenticated))
+ .mockRejectedValueOnce(retryError);
+
+ const interceptor = createTokenRefreshInterceptor({
+ getAccessToken: () => token,
+ refreshAccessToken,
+ onRefreshFailure,
+ });
+
+ await expect(interceptor(next)({ header: new Headers() } as never)).rejects.toBe(retryError);
+
+ expect(refreshAccessToken).toHaveBeenCalledTimes(1);
+ expect(next).toHaveBeenCalledTimes(2);
+ expect(onRefreshFailure).toHaveBeenCalledTimes(1);
+ expect(onRefreshFailure).toHaveBeenCalledWith(retryError);
+ });
+});
diff --git a/frontend/src/utils/create-token-refresh-interceptor.ts b/frontend/src/utils/create-token-refresh-interceptor.ts
new file mode 100644
index 0000000000..e271706e54
--- /dev/null
+++ b/frontend/src/utils/create-token-refresh-interceptor.ts
@@ -0,0 +1,90 @@
+import { Code, ConnectError, type Interceptor } from '@connectrpc/connect';
+
+type CreateTokenRefreshInterceptorArgs = {
+ getAccessToken: () => string | undefined;
+ refreshAccessToken: () => Promise;
+ onRefreshFailure?: (error: unknown) => void;
+};
+
+const isUnauthenticatedError = (error: unknown): error is ConnectError =>
+ error instanceof ConnectError && error.code === Code.Unauthenticated;
+
+const getRefreshedToken = async ({
+ getAccessToken,
+ refreshAccessToken,
+ onRefreshFailure,
+ originalError,
+}: CreateTokenRefreshInterceptorArgs & { originalError: ConnectError }) => {
+ let refreshedToken: string;
+
+ try {
+ refreshedToken = await refreshAccessToken();
+ } catch (refreshError) {
+ onRefreshFailure?.(refreshError);
+ throw originalError;
+ }
+
+ const token = refreshedToken || getAccessToken();
+ if (token) {
+ return token;
+ }
+
+ const missingTokenError = new Error('Access token missing after silent refresh');
+ onRefreshFailure?.(missingTokenError);
+ throw originalError;
+};
+
+const retryRequestWithToken = async ({
+ next,
+ request,
+ token,
+ onRefreshFailure,
+}: {
+ next: ReturnType;
+ request: Parameters>[0];
+ token: string;
+ onRefreshFailure?: (error: unknown) => void;
+}) => {
+ request.header.set('Authorization', `Bearer ${token}`);
+
+ try {
+ return await next(request);
+ } catch (retryError) {
+ if (isUnauthenticatedError(retryError)) {
+ onRefreshFailure?.(retryError);
+ }
+
+ throw retryError;
+ }
+};
+
+/**
+ * Creates a Connect interceptor that retries a single unauthenticated request
+ * after silently refreshing the access token.
+ */
+export const createTokenRefreshInterceptor =
+ ({ getAccessToken, refreshAccessToken, onRefreshFailure }: CreateTokenRefreshInterceptorArgs): Interceptor =>
+ (next) =>
+ async (request) => {
+ try {
+ return await next(request);
+ } catch (error) {
+ if (!isUnauthenticatedError(error)) {
+ throw error;
+ }
+
+ const token = await getRefreshedToken({
+ getAccessToken,
+ refreshAccessToken,
+ onRefreshFailure,
+ originalError: error,
+ });
+
+ return await retryRequestWithToken({
+ next,
+ request,
+ token,
+ onRefreshFailure,
+ });
+ }
+ };
diff --git a/frontend/src/utils/notify-embedded-auth-error.ts b/frontend/src/utils/notify-embedded-auth-error.ts
new file mode 100644
index 0000000000..5ed39b21be
--- /dev/null
+++ b/frontend/src/utils/notify-embedded-auth-error.ts
@@ -0,0 +1,15 @@
+type NotifyEmbeddedAuthErrorArgs = {
+ clusterId?: string;
+ path?: string;
+};
+
+export const notifyEmbeddedAuthError = ({
+ clusterId,
+ path = window.location.pathname,
+}: NotifyEmbeddedAuthErrorArgs) => {
+ window.dispatchEvent(
+ new CustomEvent('console:auth-error', {
+ detail: { clusterId, path },
+ })
+ );
+};
diff --git a/frontend/src/utils/token-refresh-interceptor.tsx b/frontend/src/utils/token-refresh-interceptor.tsx
new file mode 100644
index 0000000000..2ba04f0ad7
--- /dev/null
+++ b/frontend/src/utils/token-refresh-interceptor.tsx
@@ -0,0 +1,17 @@
+import type { Interceptor } from '@connectrpc/connect';
+import { createContext, useContext } from 'react';
+
+let registeredTokenRefreshInterceptor: Interceptor | undefined;
+
+const TokenRefreshInterceptorContext = createContext(undefined);
+
+export const TokenRefreshInterceptorProvider = TokenRefreshInterceptorContext.Provider;
+
+export const setRegisteredTokenRefreshInterceptor = (interceptor: Interceptor | undefined) => {
+ registeredTokenRefreshInterceptor = interceptor;
+};
+
+export const getRegisteredTokenRefreshInterceptor = () => registeredTokenRefreshInterceptor;
+
+export const useTokenRefreshInterceptor = () =>
+ useContext(TokenRefreshInterceptorContext) ?? getRegisteredTokenRefreshInterceptor();
diff --git a/frontend/src/utils/use-embedded-auth-prewarm.test.tsx b/frontend/src/utils/use-embedded-auth-prewarm.test.tsx
new file mode 100644
index 0000000000..079cc320d1
--- /dev/null
+++ b/frontend/src/utils/use-embedded-auth-prewarm.test.tsx
@@ -0,0 +1,53 @@
+import { act, renderHook } from '@testing-library/react';
+
+import { useEmbeddedAuthPrewarm } from './use-embedded-auth-prewarm';
+
+describe('useEmbeddedAuthPrewarm', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ Object.defineProperty(document, 'visibilityState', {
+ configurable: true,
+ value: 'visible',
+ });
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ test('deduplicates focus and visibility bursts into a single prewarm', async () => {
+ const prewarm = vi.fn().mockResolvedValue(undefined);
+
+ renderHook(() =>
+ useEmbeddedAuthPrewarm({
+ debounceMs: 150,
+ dedupeMs: 1000,
+ prewarm,
+ })
+ );
+
+ await act(async () => {
+ document.dispatchEvent(new Event('visibilitychange'));
+ window.dispatchEvent(new Event('focus'));
+ await vi.advanceTimersByTimeAsync(200);
+ });
+
+ expect(prewarm).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ document.dispatchEvent(new Event('visibilitychange'));
+ window.dispatchEvent(new Event('focus'));
+ await vi.advanceTimersByTimeAsync(200);
+ });
+
+ expect(prewarm).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1000);
+ window.dispatchEvent(new Event('focus'));
+ await vi.advanceTimersByTimeAsync(200);
+ });
+
+ expect(prewarm).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/frontend/src/utils/use-embedded-auth-prewarm.ts b/frontend/src/utils/use-embedded-auth-prewarm.ts
new file mode 100644
index 0000000000..774cd212cc
--- /dev/null
+++ b/frontend/src/utils/use-embedded-auth-prewarm.ts
@@ -0,0 +1,76 @@
+import { useEffect, useRef } from 'react';
+
+type UseEmbeddedAuthPrewarmArgs = {
+ enabled?: boolean;
+ debounceMs?: number;
+ dedupeMs?: number;
+ prewarm: () => Promise;
+};
+
+/**
+ * Runs a best-effort auth prewarm when the tab becomes active again.
+ *
+ * Focus and visibility events usually arrive in a burst, so this hook debounces
+ * them and also deduplicates in-flight work.
+ */
+export const useEmbeddedAuthPrewarm = ({
+ enabled = true,
+ debounceMs = 150,
+ dedupeMs = 1000,
+ prewarm,
+}: UseEmbeddedAuthPrewarmArgs) => {
+ const prewarmRef = useRef(prewarm);
+ const timeoutRef = useRef(null);
+ const inFlightRef = useRef | null>(null);
+ const lastRunAtRef = useRef(0);
+
+ prewarmRef.current = prewarm;
+
+ useEffect(() => {
+ if (!enabled) {
+ return;
+ }
+
+ const runPrewarm = () => {
+ const now = Date.now();
+ if (inFlightRef.current || now - lastRunAtRef.current < dedupeMs) {
+ return;
+ }
+
+ lastRunAtRef.current = now;
+ inFlightRef.current = Promise.resolve(prewarmRef.current())
+ .catch(() => {
+ // Best-effort prewarm only. Callers handle any hard auth failures.
+ })
+ .finally(() => {
+ inFlightRef.current = null;
+ });
+ };
+
+ const schedulePrewarm = () => {
+ if (document.visibilityState === 'hidden') {
+ return;
+ }
+
+ if (timeoutRef.current !== null) {
+ window.clearTimeout(timeoutRef.current);
+ }
+
+ timeoutRef.current = window.setTimeout(() => {
+ timeoutRef.current = null;
+ runPrewarm();
+ }, debounceMs);
+ };
+
+ window.addEventListener('focus', schedulePrewarm);
+ document.addEventListener('visibilitychange', schedulePrewarm);
+
+ return () => {
+ window.removeEventListener('focus', schedulePrewarm);
+ document.removeEventListener('visibilitychange', schedulePrewarm);
+ if (timeoutRef.current !== null) {
+ window.clearTimeout(timeoutRef.current);
+ }
+ };
+ }, [debounceMs, dedupeMs, enabled]);
+};