Skip to content

Commit 42a4186

Browse files
authored
feat: [REL-12062] iframe domain whitelist error UX (#627)
* feat: add UX state for when the toolbar is not able to mount/communicate with the auth iframe * chore: update README * fix: fix rendering bug * test: add test coverage
1 parent 0d64d88 commit 42a4186

File tree

12 files changed

+497
-10
lines changed

12 files changed

+497
-10
lines changed

e2e/tests/iframe-error.spec.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { expect } from '@playwright/test';
2+
import type { Page } from '@playwright/test';
3+
import { test } from '../setup/global';
4+
5+
/**
6+
* Block all postMessage communication from the iframe so the toolbar
7+
* never receives an auth response and eventually shows the error screen.
8+
*/
9+
async function blockAllIframeMessages(page: Page) {
10+
await page.addInitScript(() => {
11+
const originalAddEventListener = window.addEventListener;
12+
window.addEventListener = function (this: any, type: any, listener: any, options: any) {
13+
if (type === 'message') {
14+
const wrappedListener = function (this: any, event: any) {
15+
const data = event.data;
16+
if (data && data.type && typeof data.type === 'string') {
17+
console.log('[Test] Blocked iframe message:', data.type);
18+
return;
19+
}
20+
(listener as any).call(this, event);
21+
};
22+
return originalAddEventListener.call(this, type, wrappedListener as any, options);
23+
}
24+
return originalAddEventListener.call(this, type, listener, options);
25+
} as any;
26+
});
27+
}
28+
29+
test.describe('LaunchDarkly Toolbar - Iframe Error Screen', () => {
30+
test('should show error screen when iframe communication is blocked', async ({ page }: { page: Page }) => {
31+
await blockAllIframeMessages(page);
32+
33+
await page.goto('/sdk');
34+
35+
await page.getByTestId('launchdarkly-toolbar').waitFor({ state: 'visible' });
36+
37+
await page.getByRole('img', { name: 'LaunchDarkly' }).click();
38+
39+
const errorScreen = page.getByTestId('iframe-error-screen');
40+
await expect(errorScreen).toBeVisible({ timeout: 15000 });
41+
42+
await expect(page.getByText('Unable to connect to LaunchDarkly')).toBeVisible();
43+
await expect(page.getByText(/domain that is not whitelisted/)).toBeVisible();
44+
});
45+
46+
test('should display a link to the integration settings page', async ({ page }: { page: Page }) => {
47+
await blockAllIframeMessages(page);
48+
49+
await page.goto('/sdk');
50+
await page.getByTestId('launchdarkly-toolbar').waitFor({ state: 'visible' });
51+
await page.getByRole('img', { name: 'LaunchDarkly' }).click();
52+
53+
const errorScreen = page.getByTestId('iframe-error-screen');
54+
await expect(errorScreen).toBeVisible({ timeout: 15000 });
55+
56+
const whitelistLink = page.getByRole('link', { name: /here to whitelist your domain/i });
57+
await expect(whitelistLink).toBeVisible();
58+
await expect(whitelistLink).toHaveAttribute('target', '_blank');
59+
60+
const href = await whitelistLink.getAttribute('href');
61+
expect(href).toContain('/settings/integrations/launchdarkly-developer-toolbar/new');
62+
});
63+
64+
test('should not show normal toolbar content when in error state', async ({ page }: { page: Page }) => {
65+
await blockAllIframeMessages(page);
66+
67+
await page.goto('/sdk');
68+
await page.getByTestId('launchdarkly-toolbar').waitFor({ state: 'visible' });
69+
await page.getByRole('img', { name: 'LaunchDarkly' }).click();
70+
71+
const errorScreen = page.getByTestId('iframe-error-screen');
72+
await expect(errorScreen).toBeVisible({ timeout: 15000 });
73+
74+
await expect(page.getByLabel('Flags', { exact: true })).not.toBeVisible();
75+
await expect(page.getByLabel('Analytics', { exact: true })).not.toBeVisible();
76+
await expect(page.getByLabel('Settings', { exact: true })).not.toBeVisible();
77+
});
78+
});

packages/demo/vite.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export default defineConfig(({ command }) => {
4343
},
4444
server: isDev
4545
? {
46+
allowedHosts: true,
4647
fs: {
4748
allow: [rootDir],
4849
},

packages/toolbar/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,15 @@ declare global {
259259
}
260260
```
261261

262+
## Usage in a Hosted Environment
263+
264+
By default, the LaunchDarkly Developer Toolbar is configured to only allow hosting on apps running on localhost. However, if you want to run the toolbar in a hosted environment,
265+
you can whitelist additional domains to circumvent this limitation. To do so, navigate to the Integrations page of your LaunchDarkly organization settings [here](https://app.launchdarkly.com/settings/integrations).
266+
Search for the LaunchDarkly Developer Toolbar integration, and click `Add` to configure. When configuring, enter any of the domains you would like to whitelist,
267+
and click `Save` to save the integration settings. This list can be updated at any time to add additional domains or remove whitelisted domains.
268+
269+
Note: After configuring, it may take up to 5 minutes for changes to reflect in the Developer Toolbar, as it caches the list of valid domains.
270+
262271
## Framework Support
263272

264273
The toolbar provides first-class support for popular frameworks:
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import React from 'react';
2+
import { render, screen, act, waitFor } from '@testing-library/react';
3+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
4+
import '@testing-library/jest-dom/vitest';
5+
6+
const mockIframeLoaded = { value: false };
7+
8+
vi.mock('../ui/Toolbar/context/api/IFrameProvider', () => ({
9+
IFrameProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
10+
useIFrameContext: () => ({
11+
ref: { current: null },
12+
iframeSrc: 'https://integrations.launchdarkly.com',
13+
iframeLoaded: mockIframeLoaded.value,
14+
onIFrameLoad: vi.fn(),
15+
}),
16+
IFRAME_COMMANDS: {
17+
LOGOUT: 'logout',
18+
GET_PROJECTS: 'get-projects',
19+
GET_FLAGS: 'get-flags',
20+
GET_FLAG: 'get-flag',
21+
},
22+
IFRAME_EVENTS: {
23+
AUTHENTICATED: 'toolbar-authenticated',
24+
AUTH_REQUIRED: 'toolbar-authentication-required',
25+
AUTH_ERROR: 'toolbar-authentication-error',
26+
API_READY: 'api-ready',
27+
},
28+
getResponseTopic: (command: string) => `${command}-response`,
29+
getErrorTopic: (command: string) => `${command}-error`,
30+
}));
31+
32+
vi.mock('../ui/Toolbar/context/telemetry/AnalyticsProvider', () => ({
33+
useAnalytics: () => ({
34+
trackLoginSuccess: vi.fn(),
35+
trackAuthError: vi.fn(),
36+
}),
37+
}));
38+
39+
vi.mock('../ui/Toolbar/context/telemetry/InternalClientProvider', () => ({
40+
useInternalClient: () => ({
41+
client: null,
42+
loading: false,
43+
error: null,
44+
updateContext: vi.fn(),
45+
}),
46+
}));
47+
48+
import { AuthProvider, useAuthContext } from '../ui/Toolbar/context/api/AuthProvider';
49+
50+
function AuthStateDisplay() {
51+
const { loading, iframeError, authenticated } = useAuthContext();
52+
return (
53+
<div>
54+
<span data-testid="loading">{String(loading)}</span>
55+
<span data-testid="iframe-error">{String(iframeError)}</span>
56+
<span data-testid="authenticated">{String(authenticated)}</span>
57+
</div>
58+
);
59+
}
60+
61+
describe('AuthProvider - Iframe Error Detection', () => {
62+
beforeEach(() => {
63+
vi.clearAllMocks();
64+
vi.useFakeTimers();
65+
mockIframeLoaded.value = false;
66+
});
67+
68+
afterEach(() => {
69+
vi.useRealTimers();
70+
});
71+
72+
it('should not set iframeError before iframe loads', () => {
73+
mockIframeLoaded.value = false;
74+
75+
render(
76+
<AuthProvider>
77+
<AuthStateDisplay />
78+
</AuthProvider>,
79+
);
80+
81+
expect(screen.getByTestId('iframe-error')).toHaveTextContent('false');
82+
expect(screen.getByTestId('loading')).toHaveTextContent('true');
83+
});
84+
85+
it('should set iframeError after iframe loads and no message arrives within timeout', async () => {
86+
mockIframeLoaded.value = true;
87+
88+
render(
89+
<AuthProvider>
90+
<AuthStateDisplay />
91+
</AuthProvider>,
92+
);
93+
94+
expect(screen.getByTestId('iframe-error')).toHaveTextContent('false');
95+
96+
act(() => {
97+
vi.advanceTimersByTime(5000);
98+
});
99+
100+
expect(screen.getByTestId('iframe-error')).toHaveTextContent('true');
101+
expect(screen.getByTestId('loading')).toHaveTextContent('false');
102+
});
103+
104+
it('should not set iframeError if a message arrives before the timeout', async () => {
105+
mockIframeLoaded.value = true;
106+
107+
render(
108+
<AuthProvider>
109+
<AuthStateDisplay />
110+
</AuthProvider>,
111+
);
112+
113+
act(() => {
114+
window.dispatchEvent(
115+
new MessageEvent('message', {
116+
origin: 'https://integrations.launchdarkly.com',
117+
data: { type: 'toolbar-authentication-required' },
118+
}),
119+
);
120+
});
121+
122+
act(() => {
123+
vi.advanceTimersByTime(5000);
124+
});
125+
126+
expect(screen.getByTestId('iframe-error')).toHaveTextContent('false');
127+
expect(screen.getByTestId('loading')).toHaveTextContent('false');
128+
});
129+
130+
it('should not set iframeError before the full timeout elapses', () => {
131+
mockIframeLoaded.value = true;
132+
133+
render(
134+
<AuthProvider>
135+
<AuthStateDisplay />
136+
</AuthProvider>,
137+
);
138+
139+
act(() => {
140+
vi.advanceTimersByTime(4999);
141+
});
142+
143+
expect(screen.getByTestId('iframe-error')).toHaveTextContent('false');
144+
expect(screen.getByTestId('loading')).toHaveTextContent('true');
145+
});
146+
147+
it('should set loading to false when iframeError is triggered', () => {
148+
mockIframeLoaded.value = true;
149+
150+
render(
151+
<AuthProvider>
152+
<AuthStateDisplay />
153+
</AuthProvider>,
154+
);
155+
156+
expect(screen.getByTestId('loading')).toHaveTextContent('true');
157+
158+
act(() => {
159+
vi.advanceTimersByTime(5000);
160+
});
161+
162+
expect(screen.getByTestId('loading')).toHaveTextContent('false');
163+
expect(screen.getByTestId('iframe-error')).toHaveTextContent('true');
164+
});
165+
});
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { render, screen } from '@testing-library/react';
2+
import { expect, describe, it, vi, beforeEach } from 'vitest';
3+
import '@testing-library/jest-dom/vitest';
4+
import React from 'react';
5+
6+
vi.mock('../ui/Toolbar/context/state/PluginsProvider', () => ({
7+
usePlugins: vi.fn(),
8+
}));
9+
10+
import { usePlugins } from '../ui/Toolbar/context/state/PluginsProvider';
11+
import { IFrameErrorScreen } from '../ui/Toolbar/components/IFrameErrorScreen/IFrameErrorScreen';
12+
13+
describe('IFrameErrorScreen', () => {
14+
beforeEach(() => {
15+
vi.clearAllMocks();
16+
(usePlugins as any).mockReturnValue({
17+
baseUrl: 'https://app.launchdarkly.com',
18+
});
19+
});
20+
21+
describe('Content and Messaging', () => {
22+
it('displays the error title', () => {
23+
render(<IFrameErrorScreen />);
24+
25+
expect(screen.getByText('Unable to connect to LaunchDarkly')).toBeInTheDocument();
26+
});
27+
28+
it('displays the error description with whitelisting guidance', () => {
29+
render(<IFrameErrorScreen />);
30+
31+
expect(
32+
screen.getByText(/typically caused by trying to load the toolbar from a domain that is not whitelisted/),
33+
).toBeInTheDocument();
34+
});
35+
36+
it('renders the error screen container with correct test id', () => {
37+
render(<IFrameErrorScreen />);
38+
39+
expect(screen.getByTestId('iframe-error-screen')).toBeInTheDocument();
40+
});
41+
});
42+
43+
describe('Whitelist Link', () => {
44+
it('constructs the link using baseUrl from context', () => {
45+
(usePlugins as any).mockReturnValue({
46+
baseUrl: 'https://app.launchdarkly.com',
47+
});
48+
49+
render(<IFrameErrorScreen />);
50+
51+
const link = screen.getByRole('link', { name: /here to whitelist your domain/i });
52+
expect(link).toHaveAttribute(
53+
'href',
54+
'https://app.launchdarkly.com/settings/integrations/launchdarkly-developer-toolbar/new',
55+
);
56+
});
57+
58+
it('uses a custom baseUrl when configured', () => {
59+
(usePlugins as any).mockReturnValue({
60+
baseUrl: 'https://custom-ld-instance.example.com',
61+
});
62+
63+
render(<IFrameErrorScreen />);
64+
65+
const link = screen.getByRole('link', { name: /here to whitelist your domain/i });
66+
expect(link).toHaveAttribute(
67+
'href',
68+
'https://custom-ld-instance.example.com/settings/integrations/launchdarkly-developer-toolbar/new',
69+
);
70+
});
71+
72+
it('opens the link in a new tab with security attributes', () => {
73+
render(<IFrameErrorScreen />);
74+
75+
const link = screen.getByRole('link', { name: /here to whitelist your domain/i });
76+
expect(link).toHaveAttribute('target', '_blank');
77+
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
78+
});
79+
});
80+
81+
describe('Interaction', () => {
82+
it('forwards onMouseDown to the logo for drag handling', () => {
83+
const mockOnMouseDown = vi.fn();
84+
render(<IFrameErrorScreen onMouseDown={mockOnMouseDown} />);
85+
86+
const errorScreen = screen.getByTestId('iframe-error-screen');
87+
expect(errorScreen).toBeInTheDocument();
88+
});
89+
});
90+
});

packages/toolbar/src/core/ui/Toolbar/LaunchDarklyToolbar.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
} from './context';
2020
import { CircleLogo } from './components';
2121
import { LoadingScreen } from './components/LoadingScreen';
22+
import { IFrameErrorScreen } from './components/IFrameErrorScreen';
2223
import { ExpandedToolbarContent } from './components/new/ExpandedToolbarContent';
2324
import { InteractiveWrapper } from './components/new/Interactive';
2425
import { AuthenticationModal } from './components/AuthenticationModal/AuthenticationModal';
@@ -33,7 +34,7 @@ export function LdToolbar() {
3334
const analytics = useAnalytics();
3435
const { activeTab, setActiveTab } = useActiveTabContext();
3536
const { isOptedInToSessionReplay } = useAnalyticsPreferences();
36-
const { loading: authLoading } = useAuthContext();
37+
const { loading: authLoading, iframeError } = useAuthContext();
3738
const { loading: internalClientLoading } = useInternalClient();
3839
const defaultActiveTab = getDefaultActiveTab();
3940
const [isAuthModalOpen, setIsAuthModalOpen] = useState(false);
@@ -169,8 +170,9 @@ export function LdToolbar() {
169170
>
170171
<AnimatePresence>{!isExpanded ? <CircleLogo onMouseDown={handleMouseDown} /> : null}</AnimatePresence>
171172
<AnimatePresence>
172-
{isExpanded && isInitializing ? <LoadingScreen onMouseDown={handleMouseDown} /> : null}
173-
{isExpanded && !isInitializing ? (
173+
{isExpanded && iframeError ? <IFrameErrorScreen onMouseDown={handleMouseDown} /> : null}
174+
{isExpanded && !iframeError && isInitializing ? <LoadingScreen onMouseDown={handleMouseDown} /> : null}
175+
{isExpanded && !iframeError && !isInitializing ? (
174176
<ExpandedToolbarContent
175177
onClose={handleClose}
176178
onHeaderMouseDown={handleMouseDown}

0 commit comments

Comments
 (0)