Skip to content

Commit b5b44d4

Browse files
authored
feat: [REL-10443] Developer Toolbar authentication (#273)
* feat: add rough proof of concept for auth * feat: minimal communication between iframe and parent * feat: more auth buildout * feat: hide auth button when authenticated * feat: add effect to close modal after authenticating * chore: remove console log, add comment * feat: properly remove event listener after getting data back * feat: add message constants * feat: clean up * feat: add temp settings toggle (until we add ld feature flags) * feat: use auth popup instead of authenticating in iframe * feat: add dynamic iframe URL * test: add tests for AuthenticationModal * fix: minor fix to UX * lint: remove unused import * lint: run pnpm format * refactor: clean up * feat: pass window.location.origin as a query param to enforce strict postMessage comms * fix: run pnpm format * feat: clean up how iframe is determined * test: fix tests * run pnpm format * feat: address PR feedback * refactor: remove unused import * chore: run pnpm format * refactor: remove console logs
1 parent beb9ea8 commit b5b44d4

26 files changed

+816
-121
lines changed

packages/demo/.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
VITE_LD_CLIENT_SIDE_ID=your-client-side-id
22
VITE_LD_BASE_URL=https://app.launchdarkly.com
3+
VITE_LD_AUTH_URL=https://integrations.launchdarkly.com
34
VITE_LD_STREAM_URL=https://clientstream.launchdarkly.com
45
VITE_LD_EVENTS_URL=https://events.launchdarkly.com
56
VITE_LD_DEV_SERVER_URL=http://localhost:8765

packages/demo/src/pages/DevServerMode.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ export function DevServerMode() {
2121

2222
useLaunchDarklyToolbar({
2323
toolbarBundleUrl: import.meta.env.DEV ? 'http://localhost:8080/toolbar.min.js' : undefined,
24+
baseUrl: import.meta.env.VITE_LD_BASE_URL,
25+
authUrl: import.meta.env.VITE_LD_AUTH_URL,
2426
enabled: true,
2527
devServerUrl: import.meta.env.VITE_LD_DEV_SERVER_URL,
2628
eventInterceptionPlugin,

packages/demo/src/pages/SDKMode.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ export function SDKMode() {
1919

2020
useLaunchDarklyToolbar({
2121
toolbarBundleUrl: import.meta.env.DEV ? 'http://localhost:8080/toolbar.min.js' : undefined,
22+
baseUrl: import.meta.env.VITE_LD_BASE_URL,
23+
authUrl: import.meta.env.VITE_LD_AUTH_URL,
2224
enabled: true,
2325
flagOverridePlugin,
2426
eventInterceptionPlugin,

packages/toolbar/src/core/mount.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export default function mount(rootNode: HTMLElement, config: InitializationConfi
2626
<LaunchDarklyToolbar
2727
domId={TOOLBAR_DOM_ID}
2828
baseUrl={config.baseUrl}
29+
authUrl={config.authUrl}
2930
devServerUrl={config.devServerUrl}
3031
projectKey={config.projectKey}
3132
flagOverridePlugin={config.flagOverridePlugin}
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import React from 'react';
2+
import { render, screen } from '@testing-library/react';
3+
import { describe, it, expect, vi, beforeEach } from 'vitest';
4+
import { AuthenticationModal } from '../ui/Toolbar/components/AuthenticationModal';
5+
import { IFrameProvider } from '../ui/Toolbar/context/IFrameProvider';
6+
7+
// Mock the oauthPopup utility
8+
vi.mock('../ui/Toolbar/utils/oauthPopup', () => ({
9+
openOAuthPopup: vi.fn().mockResolvedValue({}),
10+
}));
11+
12+
// Mock AuthProvider to allow controlled state for testing
13+
const mockAuthContext = {
14+
authenticated: false,
15+
loading: false,
16+
authenticating: false,
17+
setAuthenticating: vi.fn(),
18+
};
19+
20+
vi.mock('../ui/Toolbar/context/AuthProvider', () => ({
21+
useAuthContext: () => mockAuthContext,
22+
AuthProvider: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
23+
}));
24+
25+
// Test wrapper with required providers
26+
const TestWrapper = ({ children, authUrl }: { children: React.ReactNode; authUrl: string }) => (
27+
<IFrameProvider authUrl={authUrl}>{children}</IFrameProvider>
28+
);
29+
30+
describe('AuthenticationModal', () => {
31+
const defaultProps = {
32+
baseUrl: 'https://app.launchdarkly.com',
33+
isOpen: true,
34+
onClose: vi.fn(),
35+
};
36+
37+
beforeEach(() => {
38+
// Reset mock state before each test
39+
mockAuthContext.authenticated = false;
40+
mockAuthContext.authenticating = false;
41+
mockAuthContext.loading = false;
42+
vi.clearAllMocks();
43+
});
44+
45+
describe('iframe URL determination', () => {
46+
it('should map production LaunchDarkly URL to production integrations URL', () => {
47+
render(
48+
<TestWrapper authUrl="https://integrations.launchdarkly.com">
49+
<AuthenticationModal {...defaultProps} />
50+
</TestWrapper>,
51+
);
52+
53+
const iframe = screen.getByTitle('LaunchDarkly Toolbar') as HTMLIFrameElement;
54+
expect(iframe.src).toContain('https://integrations.launchdarkly.com/toolbar/index.html');
55+
});
56+
57+
it('should map staging LaunchDarkly URL to staging integrations URL', () => {
58+
render(
59+
<TestWrapper authUrl="https://integrations-stg.launchdarkly.com">
60+
<AuthenticationModal {...defaultProps} />
61+
</TestWrapper>,
62+
);
63+
64+
const iframe = screen.getByTitle('LaunchDarkly Toolbar') as HTMLIFrameElement;
65+
expect(iframe.src).toContain('https://integrations-stg.launchdarkly.com/toolbar/index.html');
66+
});
67+
68+
it('should map catamorphic LaunchDarkly URL to catamorphic integrations URL', () => {
69+
render(
70+
<TestWrapper authUrl="https://integrations.ld.catamorphic.com">
71+
<AuthenticationModal {...defaultProps} />
72+
</TestWrapper>,
73+
);
74+
75+
const iframe = screen.getByTitle('LaunchDarkly Toolbar') as HTMLIFrameElement;
76+
expect(iframe.src).toContain('https://integrations.ld.catamorphic.com/toolbar/index.html');
77+
});
78+
});
79+
80+
describe('authenticating state behavior', () => {
81+
it('should show authenticating.html when authenticating is true', () => {
82+
mockAuthContext.authenticating = true;
83+
84+
render(
85+
<TestWrapper authUrl="https://integrations.launchdarkly.com">
86+
<AuthenticationModal {...defaultProps} />
87+
</TestWrapper>,
88+
);
89+
90+
const iframe = screen.getByTitle('LaunchDarkly Toolbar') as HTMLIFrameElement;
91+
expect(iframe.src).toContain('https://integrations.launchdarkly.com/toolbar/authenticating.html');
92+
});
93+
94+
it('should show index.html when authenticating is false', () => {
95+
mockAuthContext.authenticating = false;
96+
97+
render(
98+
<TestWrapper authUrl="https://integrations.launchdarkly.com">
99+
<AuthenticationModal {...defaultProps} />
100+
</TestWrapper>,
101+
);
102+
103+
const iframe = screen.getByTitle('LaunchDarkly Toolbar') as HTMLIFrameElement;
104+
expect(iframe.src).toContain('https://integrations.launchdarkly.com/toolbar/index.html');
105+
});
106+
107+
it('should show authenticating.html with correct integration URL based on baseUrl', () => {
108+
mockAuthContext.authenticating = true;
109+
110+
render(
111+
<TestWrapper authUrl="https://integrations-stg.launchdarkly.com">
112+
<AuthenticationModal {...defaultProps} />
113+
</TestWrapper>,
114+
);
115+
116+
const iframe = screen.getByTitle('LaunchDarkly Toolbar') as HTMLIFrameElement;
117+
expect(iframe.src).toContain('https://integrations-stg.launchdarkly.com/toolbar/authenticating.html');
118+
});
119+
});
120+
121+
describe('iframe properties', () => {
122+
it('should render iframe with correct title', () => {
123+
render(
124+
<TestWrapper authUrl="https://integrations.launchdarkly.com">
125+
<AuthenticationModal {...defaultProps} />
126+
</TestWrapper>,
127+
);
128+
129+
const iframe = screen.getByTitle('LaunchDarkly Toolbar');
130+
expect(iframe).toBeDefined();
131+
});
132+
133+
it('should render iframe as hidden', () => {
134+
render(
135+
<TestWrapper authUrl="https://integrations.launchdarkly.com">
136+
<AuthenticationModal {...defaultProps} />
137+
</TestWrapper>,
138+
);
139+
140+
const iframe = screen.getByTitle('LaunchDarkly Toolbar');
141+
expect(iframe.style.display).toBe('none');
142+
});
143+
144+
it('should provide iframe ref from IFrameContext', () => {
145+
const { container } = render(
146+
<TestWrapper authUrl="https://integrations.launchdarkly.com">
147+
<AuthenticationModal {...defaultProps} />
148+
</TestWrapper>,
149+
);
150+
151+
const iframe = container.querySelector('iframe');
152+
expect(iframe).toBeDefined();
153+
expect(iframe?.tagName).toBe('IFRAME');
154+
});
155+
});
156+
157+
describe('component structure', () => {
158+
it('should render iframe within container', () => {
159+
const { container } = render(
160+
<TestWrapper authUrl="https://integrations.launchdarkly.com">
161+
<AuthenticationModal {...defaultProps} />
162+
</TestWrapper>,
163+
);
164+
165+
const iframe = container.querySelector('iframe');
166+
const containerDiv = iframe?.parentElement;
167+
expect(containerDiv?.className).toContain('iframeContainer');
168+
});
169+
170+
it('should always render iframe regardless of isOpen prop', () => {
171+
render(
172+
<TestWrapper authUrl="https://integrations.launchdarkly.com">
173+
<AuthenticationModal {...defaultProps} isOpen={false} />
174+
</TestWrapper>,
175+
);
176+
177+
const iframe = screen.getByTitle('LaunchDarkly Toolbar');
178+
expect(iframe).toBeDefined();
179+
});
180+
});
181+
});

packages/toolbar/src/core/tests/DevServerProvider.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React from 'react';
22
import { render, screen, waitFor } from '@testing-library/react';
3-
import { expect, test, describe, vi, beforeEach, Mock } from 'vitest';
3+
import { expect, test, describe, vi, beforeEach } from 'vitest';
44
import { DevServerProvider, useDevServerContext } from '../ui/Toolbar/context/DevServerProvider';
55

66
// Create mock instances that we can access in tests

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,21 @@ export interface HeaderProps {
1616
label: string;
1717
mode: ToolbarMode;
1818
onMouseDown?: (event: React.MouseEvent) => void;
19+
onOpenConfig?: () => void;
1920
}
2021

2122
export function Header(props: HeaderProps) {
22-
const { onClose, onSearch, searchTerm, searchIsExpanded, setSearchIsExpanded, label, mode, onMouseDown } = props;
23+
const {
24+
onClose,
25+
onSearch,
26+
searchTerm,
27+
searchIsExpanded,
28+
setSearchIsExpanded,
29+
label,
30+
mode,
31+
onMouseDown,
32+
onOpenConfig,
33+
} = props;
2334

2435
const { state, refresh } = useDevServerContext();
2536
const { connectionStatus } = state;
@@ -83,6 +94,7 @@ export function Header(props: HeaderProps) {
8394
setSearchIsExpanded={setSearchIsExpanded}
8495
onClose={onClose}
8596
onRefresh={refresh}
97+
onOpenConfig={onOpenConfig}
8698
showSearchButton={showSearch}
8799
showRefreshButton={showRefresh}
88100
/>

packages/toolbar/src/core/ui/Toolbar/Header/components/ActionButtons.tsx

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,39 @@
11
import { Dispatch, SetStateAction, useCallback, useState } from 'react';
22
import { motion, AnimatePresence } from 'motion/react';
33
import { IconButton } from '../../components/IconButton';
4-
import { SearchIcon, SyncIcon, ChevronDownIcon, ChevronUpIcon } from '../../components/icons';
4+
import { SearchIcon, SyncIcon, ChevronDownIcon, ChevronUpIcon, PersonPassword } from '../../components/icons';
55
import { useToolbarUIContext } from '../../context/ToolbarUIProvider';
66

77
import * as styles from '../Header.css';
8+
import { useAuthContext } from '../../context/AuthProvider';
89

910
interface ActionButtonsProps {
1011
searchIsExpanded: boolean;
1112
setSearchIsExpanded: Dispatch<SetStateAction<boolean>>;
1213
onClose: () => void;
1314
onRefresh: () => void;
15+
onOpenConfig?: () => void;
1416
showSearchButton: boolean;
1517
showRefreshButton: boolean;
1618
}
1719

1820
export function ActionButtons(props: ActionButtonsProps) {
19-
const { searchIsExpanded, setSearchIsExpanded, onClose, onRefresh, showSearchButton, showRefreshButton } = props;
21+
const {
22+
searchIsExpanded,
23+
setSearchIsExpanded,
24+
onClose,
25+
onRefresh,
26+
onOpenConfig,
27+
showSearchButton,
28+
showRefreshButton,
29+
} = props;
2030
const [isSpinning, setIsSpinning] = useState(false);
2131
const [rotationCount, setRotationCount] = useState(0);
2232
const { position } = useToolbarUIContext();
2333
const isTop = position.startsWith('top-');
2434

35+
const { authenticated, loading } = useAuthContext();
36+
2537
const handleRefreshClick = useCallback(() => {
2638
// Prevent multiple clicks while already spinning
2739
if (isSpinning) return;
@@ -60,6 +72,14 @@ export function ActionButtons(props: ActionButtonsProps) {
6072
)}
6173
</AnimatePresence>
6274
)}
75+
{onOpenConfig && !authenticated && !loading && (
76+
<IconButton
77+
icon={<PersonPassword />}
78+
label="Configuration"
79+
onClick={onOpenConfig}
80+
className={styles.actionButton}
81+
/>
82+
)}
6383
{showRefreshButton && (
6484
<IconButton
6585
icon={

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

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ import { ToolbarMode, ToolbarPosition, getToolbarMode, getDefaultActiveTab } fro
1010
import * as styles from './LaunchDarklyToolbar.css';
1111
import { DevServerProvider } from './context';
1212
import { IEventInterceptionPlugin, IFlagOverridePlugin } from '../../../types';
13+
import { AuthProvider } from './context/AuthProvider';
14+
import { ApiProvider } from './context/ApiProvider';
15+
import { IFrameProvider } from './context/IFrameProvider';
1316

1417
export interface LdToolbarProps {
1518
mode: ToolbarMode;
@@ -47,6 +50,8 @@ export function LdToolbar(props: LdToolbarProps) {
4750
isAutoCollapseEnabled,
4851
setSearchIsExpanded,
4952
setIsAnimating,
53+
optInToNewFeatures,
54+
handleToggleOptInToNewFeatures,
5055
} = toolbarState;
5156

5257
// Focus management for expand/collapse
@@ -172,6 +177,8 @@ export function LdToolbar(props: LdToolbarProps) {
172177
onHeaderMouseDown={handleMouseDown}
173178
reloadOnFlagChangeIsEnabled={reloadOnFlagChangeIsEnabled}
174179
onToggleReloadOnFlagChange={handleToggleReloadOnFlagChange}
180+
optInToNewFeatures={optInToNewFeatures}
181+
onToggleOptInToNewFeatures={handleToggleOptInToNewFeatures}
175182
/>
176183
)}
177184
</AnimatePresence>
@@ -181,6 +188,7 @@ export function LdToolbar(props: LdToolbarProps) {
181188

182189
export interface LaunchDarklyToolbarProps {
183190
baseUrl?: string; // Optional - will default to https://app.launchdarkly.com
191+
authUrl?: string; // Optional - will default to https://integrations.launchdarkly.com
184192
devServerUrl?: string; // Optional - will default to dev server mode if provided
185193
projectKey?: string; // Optional - will auto-detect first available project if not provided
186194
flagOverridePlugin?: IFlagOverridePlugin; // Optional - for flag override functionality
@@ -193,6 +201,7 @@ export interface LaunchDarklyToolbarProps {
193201
export function LaunchDarklyToolbar(props: LaunchDarklyToolbarProps) {
194202
const {
195203
baseUrl = 'https://app.launchdarkly.com',
204+
authUrl = 'https://integrations.launchdarkly.com',
196205
projectKey,
197206
position,
198207
devServerUrl,
@@ -221,15 +230,21 @@ export function LaunchDarklyToolbar(props: LaunchDarklyToolbarProps) {
221230
>
222231
<AnalyticsProvider ldClient={flagOverridePlugin?.getClient() ?? eventInterceptionPlugin?.getClient()}>
223232
<SearchProvider>
224-
<StarredFlagsProvider>
225-
<LdToolbar
226-
domId={domId}
227-
mode={mode}
228-
baseUrl={baseUrl}
229-
flagOverridePlugin={flagOverridePlugin}
230-
eventInterceptionPlugin={eventInterceptionPlugin}
231-
/>
232-
</StarredFlagsProvider>
233+
<IFrameProvider authUrl={authUrl}>
234+
<AuthProvider>
235+
<ApiProvider>
236+
<StarredFlagsProvider>
237+
<LdToolbar
238+
domId={domId}
239+
mode={mode}
240+
baseUrl={baseUrl}
241+
flagOverridePlugin={flagOverridePlugin}
242+
eventInterceptionPlugin={eventInterceptionPlugin}
243+
/>
244+
</StarredFlagsProvider>
245+
</ApiProvider>
246+
</AuthProvider>
247+
</IFrameProvider>
233248
</SearchProvider>
234249
</AnalyticsProvider>
235250
</DevServerProvider>

0 commit comments

Comments
 (0)