Skip to content

Commit 94f7e19

Browse files
fix(frontend): refresh auth on stale tabs
1 parent 074e5b7 commit 94f7e19

16 files changed

Lines changed: 733 additions & 74 deletions

frontend/src/config.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import { api, useApiStore } from './state/backend-api';
4444
import { useUIStateStore } from './state/ui-state';
4545
import { AppFeatures, getBasePath } from './utils/env';
4646
import { getEmbeddedAvailableRoutes } from './utils/route-utils';
47+
import { getRegisteredTokenRefreshInterceptor } from './utils/token-refresh-interceptor';
4748

4849
declare const __webpack_public_path__: string;
4950

@@ -206,19 +207,27 @@ const setConfig = ({
206207
}: SetConfigArguments) => {
207208
const assetsUrl =
208209
urlOverride?.assets === 'WEBPACK' ? String(__webpack_public_path__).removeSuffix('/') : urlOverride?.assets;
210+
const configuredFetch = fetch ?? window.fetch.bind(window);
211+
const tokenRefreshInterceptor = getRegisteredTokenRefreshInterceptor();
209212

210213
// instantiate the client once, if we need to add more clients you can add them in here, ideally only one transport is necessary
211214
const dataplaneTransport = createConnectTransport({
212215
baseUrl: getGrpcBasePath(urlOverride?.grpc),
213-
interceptors: [addBearerTokenInterceptor, checkExpiredLicenseInterceptor],
216+
fetch: configuredFetch,
217+
interceptors: [
218+
addBearerTokenInterceptor,
219+
...(tokenRefreshInterceptor ? [tokenRefreshInterceptor] : []),
220+
checkExpiredLicenseInterceptor,
221+
],
214222
jsonOptions: {
215223
registry: protobufRegistry,
216224
},
217225
});
218226

219227
const controlplaneTransport = createConnectTransport({
220228
baseUrl: config.controlplaneUrl,
221-
interceptors: [addBearerTokenInterceptor],
229+
fetch: configuredFetch,
230+
interceptors: [addBearerTokenInterceptor, ...(tokenRefreshInterceptor ? [tokenRefreshInterceptor] : [])],
222231
jsonOptions: {
223232
registry: protobufRegistry,
224233
},
@@ -250,7 +259,7 @@ const setConfig = ({
250259
restBasePath: getRestBasePath(urlOverride?.rest),
251260
grpcBasePath: getGrpcBasePath(urlOverride?.grpc),
252261
controlplaneUrl: config.controlplaneUrl,
253-
fetch: fetch ?? window.fetch.bind(window),
262+
fetch: configuredFetch,
254263
assetsPath: assetsUrl ?? getBasePath(),
255264
authenticationClient: authenticationGrpcClient,
256265
licenseClient: licenseGrpcClient,
@@ -352,7 +361,7 @@ setTimeout(() => {
352361
updateSidebarItems();
353362
}
354363
});
355-
} catch (error) {
364+
} catch (_error) {
356365
// Ignore errors in test environments where stores might not be properly initialized
357366
// This setTimeout runs globally when config.ts is imported
358367
}

frontend/src/embedded-app.test.tsx

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { render } from '@testing-library/react';
2+
import type { ReactNode } from 'react';
3+
4+
const { mockCreateConnectTransport, mockCreateRouter, mockFetch, mockSetup } = vi.hoisted(() => ({
5+
mockCreateConnectTransport: vi.fn((options) => options),
6+
mockCreateRouter: vi.fn(() => ({
7+
invalidate: vi.fn().mockResolvedValue(undefined),
8+
})),
9+
mockFetch: vi.fn(),
10+
mockSetup: vi.fn(),
11+
}));
12+
13+
vi.mock('@connectrpc/connect-query', () => ({
14+
TransportProvider: ({ children }: { children?: ReactNode }) => <>{children}</>,
15+
}));
16+
17+
vi.mock('@connectrpc/connect-web', () => ({
18+
createConnectTransport: mockCreateConnectTransport,
19+
}));
20+
21+
vi.mock('@redpanda-data/ui', () => ({
22+
ChakraProvider: ({ children }: { children?: ReactNode }) => <>{children}</>,
23+
redpandaTheme: {},
24+
redpandaToastOptions: {},
25+
}));
26+
27+
vi.mock('@tanstack/react-query', async () => {
28+
const actual = await vi.importActual('@tanstack/react-query');
29+
return {
30+
...actual,
31+
QueryClientProvider: ({ children }: { children?: ReactNode }) => <>{children}</>,
32+
};
33+
});
34+
35+
vi.mock('@tanstack/react-router', () => ({
36+
createRouter: mockCreateRouter,
37+
RouterProvider: () => <div data-testid="router-provider" />,
38+
}));
39+
40+
vi.mock('custom-feature-flag-provider', () => ({
41+
CustomFeatureFlagProvider: ({ children }: { children?: ReactNode }) => <>{children}</>,
42+
}));
43+
44+
vi.mock('protobuf-registry', () => ({
45+
protobufRegistry: {},
46+
}));
47+
48+
vi.mock('./components/misc/not-found-page', () => ({
49+
NotFoundPage: () => <div>Not Found</div>,
50+
}));
51+
52+
vi.mock('./config', () => ({
53+
addBearerTokenInterceptor: vi.fn((next) => next),
54+
checkExpiredLicenseInterceptor: vi.fn((next) => next),
55+
getGrpcBasePath: vi.fn(() => 'http://localhost:9090'),
56+
setup: mockSetup,
57+
}));
58+
59+
vi.mock('./routeTree.gen', () => ({
60+
routeTree: {},
61+
}));
62+
63+
vi.mock('./state/app-global', () => ({
64+
appGlobal: {
65+
historyLocation: vi.fn(() => ({ pathname: '/topics' })),
66+
historyPush: vi.fn(),
67+
},
68+
}));
69+
70+
vi.mock('./state/backend-api', () => ({
71+
api: {
72+
refreshUserData: vi.fn().mockResolvedValue(undefined),
73+
},
74+
}));
75+
76+
import EmbeddedApp from './embedded-app';
77+
78+
describe('EmbeddedApp', () => {
79+
beforeEach(() => {
80+
vi.clearAllMocks();
81+
});
82+
83+
test('uses the host-provided fetch for the root dataplane transport', () => {
84+
render(<EmbeddedApp fetch={mockFetch} isConsoleReadyToMount={true} />);
85+
86+
expect(mockSetup).toHaveBeenCalled();
87+
expect(mockCreateConnectTransport).toHaveBeenCalledWith(
88+
expect.objectContaining({
89+
baseUrl: 'http://localhost:9090',
90+
fetch: mockFetch,
91+
})
92+
);
93+
});
94+
});

frontend/src/embedded-app.tsx

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@ import {
5252
} from './config';
5353
import { routeTree } from './routeTree.gen';
5454
import { appGlobal } from './state/app-global';
55+
import { api } from './state/backend-api';
56+
import {
57+
getRegisteredTokenRefreshInterceptor,
58+
TokenRefreshInterceptorProvider,
59+
} from './utils/token-refresh-interceptor';
60+
import { useEmbeddedAuthPrewarm } from './utils/use-embedded-auth-prewarm';
5561

5662
// Regex for normalizing paths by removing trailing slashes
5763
const TRAILING_SLASH_REGEX = /\/+$/;
@@ -86,6 +92,10 @@ export interface EmbeddedProps extends SetConfigArguments {
8692
}
8793

8894
function EmbeddedApp({ basePath = '', ...p }: EmbeddedProps) {
95+
const tokenRefreshInterceptor = getRegisteredTokenRefreshInterceptor();
96+
const defaultFetch = useMemo(() => window.fetch.bind(window), []);
97+
const configuredFetch = p.fetch ?? defaultFetch;
98+
8999
useEffect(() => {
90100
const shellNavigationHandler = (event: Event) => {
91101
const pathname = (event as CustomEvent<string>).detail;
@@ -115,12 +125,17 @@ function EmbeddedApp({ basePath = '', ...p }: EmbeddedProps) {
115125
() =>
116126
createConnectTransport({
117127
baseUrl: getGrpcBasePath(p.urlOverride?.grpc),
118-
interceptors: [addBearerTokenInterceptor, checkExpiredLicenseInterceptor],
128+
fetch: configuredFetch,
129+
interceptors: [
130+
addBearerTokenInterceptor,
131+
...(tokenRefreshInterceptor ? [tokenRefreshInterceptor] : []),
132+
checkExpiredLicenseInterceptor,
133+
],
119134
jsonOptions: {
120135
registry: protobufRegistry,
121136
},
122137
}),
123-
[p.urlOverride?.grpc]
138+
[configuredFetch, p.urlOverride?.grpc, tokenRefreshInterceptor]
124139
);
125140

126141
// Create router with dynamic basePath
@@ -139,20 +154,33 @@ function EmbeddedApp({ basePath = '', ...p }: EmbeddedProps) {
139154
[basePath, dataplaneTransport]
140155
);
141156

157+
useEmbeddedAuthPrewarm({
158+
enabled: Boolean(p.isConsoleReadyToMount),
159+
prewarm: async () => {
160+
await api.refreshUserData().catch(() => {
161+
// Best-effort prewarm only; embedded hosts handle any hard auth failures.
162+
});
163+
164+
await Promise.allSettled([queryClient.invalidateQueries(), router.invalidate()]);
165+
},
166+
});
167+
142168
if (!p.isConsoleReadyToMount) {
143169
return null;
144170
}
145171

146172
return (
147-
<CustomFeatureFlagProvider initialFlags={p.featureFlags}>
148-
<ChakraProvider resetCSS={false} theme={redpandaTheme} toastOptions={redpandaToastOptions}>
149-
<TransportProvider transport={dataplaneTransport}>
150-
<QueryClientProvider client={queryClient}>
151-
<RouterProvider router={router} />
152-
</QueryClientProvider>
153-
</TransportProvider>
154-
</ChakraProvider>
155-
</CustomFeatureFlagProvider>
173+
<TokenRefreshInterceptorProvider value={tokenRefreshInterceptor}>
174+
<CustomFeatureFlagProvider initialFlags={p.featureFlags}>
175+
<ChakraProvider resetCSS={false} theme={redpandaTheme} toastOptions={redpandaToastOptions}>
176+
<TransportProvider transport={dataplaneTransport}>
177+
<QueryClientProvider client={queryClient}>
178+
<RouterProvider router={router} />
179+
</QueryClientProvider>
180+
</TransportProvider>
181+
</ChakraProvider>
182+
</CustomFeatureFlagProvider>
183+
</TokenRefreshInterceptorProvider>
156184
);
157185
}
158186

frontend/src/federation/console-app.test.tsx

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@
99
* by the Apache License, Version 2.0
1010
*/
1111

12-
import { render, waitFor } from '@testing-library/react';
12+
import { act, render, waitFor } from '@testing-library/react';
13+
import type { ReactNode } from 'react';
14+
15+
const tokenManagerInstances: Array<{ refresh: ReturnType<typeof vi.fn> }> = [];
1316

1417
// Mock TanStack Router before any imports
1518
vi.mock('@tanstack/react-router', async () => {
@@ -19,6 +22,7 @@ vi.mock('@tanstack/react-router', async () => {
1922
createRouter: vi.fn(() => ({
2023
subscribe: vi.fn(() => vi.fn()), // Returns unsubscribe function
2124
load: vi.fn().mockResolvedValue(undefined),
25+
invalidate: vi.fn().mockResolvedValue(undefined),
2226
state: { location: { pathname: '/topics' } },
2327
navigate: vi.fn(),
2428
})),
@@ -28,12 +32,22 @@ vi.mock('@tanstack/react-router', async () => {
2832
push: vi.fn(),
2933
replace: vi.fn(),
3034
})),
31-
RouterProvider: ({ children }: { children?: React.ReactNode }) => (
32-
<div data-testid="router-provider">{children}</div>
33-
),
35+
RouterProvider: ({ children }: { children?: ReactNode }) => <div data-testid="router-provider">{children}</div>,
3436
};
3537
});
3638

39+
vi.mock('@redpanda-data/ui', () => ({
40+
ChakraProvider: ({ children }: { children?: ReactNode }) => <>{children}</>,
41+
createStandaloneToast: vi.fn(() => ({
42+
ToastContainer: () => null,
43+
toast: {
44+
closeAll: vi.fn(),
45+
},
46+
})),
47+
redpandaTheme: {},
48+
redpandaToastOptions: {},
49+
}));
50+
3751
vi.mock('config', () => ({
3852
config: {
3953
jwt: undefined as string | undefined,
@@ -56,6 +70,16 @@ vi.mock('../components/misc/not-found-page', () => ({
5670
NotFoundPage: () => <div>Not Found</div>,
5771
}));
5872

73+
vi.mock('../state/backend-api', () => ({
74+
api: {
75+
refreshUserData: vi.fn().mockResolvedValue(undefined),
76+
},
77+
}));
78+
79+
vi.mock('./federated-providers', () => ({
80+
FederatedProviders: ({ children }: { children?: ReactNode }) => <>{children}</>,
81+
}));
82+
5983
vi.mock('./token-manager', () => ({
6084
TokenManager: class MockTokenManager {
6185
private getToken: () => Promise<string>;
@@ -70,6 +94,7 @@ vi.mock('./token-manager', () => ({
7094
this.setGetAccessToken.mockImplementation((fn: () => Promise<string>) => {
7195
this.getToken = fn;
7296
});
97+
tokenManagerInstances.push(this);
7398
}
7499
},
75100
}));
@@ -99,16 +124,27 @@ describe('ConsoleApp', () => {
99124

100125
beforeEach(() => {
101126
vi.clearAllMocks();
127+
tokenManagerInstances.length = 0;
102128
mockGetAccessToken.mockResolvedValue('test-token-123');
103129
// Reset config.jwt
104130
config.jwt = undefined;
105131
});
106132

133+
afterEach(() => {
134+
vi.useRealTimers();
135+
});
136+
107137
test('shows nothing while loading', () => {
108-
const { container } = render(<ConsoleApp {...defaultProps} />);
138+
const tokenPromise = new Promise<string>(() => {
139+
// Intentionally unresolved to keep the component in its loading state.
140+
});
141+
mockGetAccessToken.mockReturnValueOnce(tokenPromise);
142+
143+
const { container, unmount } = render(<ConsoleApp {...defaultProps} />);
109144

110145
// Component returns null while initializing
111146
expect(container.firstChild).toBeNull();
147+
unmount();
112148
});
113149

114150
test('calls getAccessToken on mount', async () => {
@@ -154,7 +190,9 @@ describe('ConsoleApp', () => {
154190

155191
test('stays in loading state while token refresh is pending', async () => {
156192
// Create a deferred promise that never resolves during this test
157-
const tokenPromise = new Promise<string>(() => {});
193+
const tokenPromise = new Promise<string>(() => {
194+
// Intentionally unresolved to keep the component in its loading state.
195+
});
158196
mockGetAccessToken.mockReturnValueOnce(tokenPromise);
159197

160198
const { container } = render(<ConsoleApp {...defaultProps} />);
@@ -224,6 +262,25 @@ describe('ConsoleApp', () => {
224262
unmount();
225263
});
226264

265+
test('prewarms auth once when focus and visibility events arrive in a burst', async () => {
266+
render(<ConsoleApp {...defaultProps} />);
267+
268+
await waitFor(() => {
269+
expect(mockGetAccessToken).toHaveBeenCalledTimes(1);
270+
});
271+
272+
expect(tokenManagerInstances).toHaveLength(1);
273+
vi.useFakeTimers();
274+
275+
await act(async () => {
276+
document.dispatchEvent(new Event('visibilitychange'));
277+
window.dispatchEvent(new Event('focus'));
278+
await vi.advanceTimersByTimeAsync(200);
279+
});
280+
281+
expect(tokenManagerInstances[0]?.refresh).toHaveBeenCalledTimes(2);
282+
});
283+
227284
describe('Feature Flags', () => {
228285
test('passes feature flags to setup()', async () => {
229286
const flags = { schemaRegistry: true, enableKnowledgeBaseInConsoleUi: false };

0 commit comments

Comments
 (0)