Skip to content

Commit 3414896

Browse files
kellyw1806claude
andcommitted
✨ [FFL-2597] add feature flags tab with OAuth sign-in
Adds the Flags tab (gated behind datadogMode) with an OAuth authorization_code + PKCE sign-in against Datadog's first-party OAuth server, the connect screen, and site handling. The selected site is validated against the js-core site allowlist before building any OAuth/API host, and the callback domain is checked against it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 253d597 commit 3414896

11 files changed

Lines changed: 473 additions & 1 deletion

File tree

developer-extension/src/common/extension.types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,6 @@ export interface Settings {
5757
logsConfigurationOverride: object | null
5858
debugMode: boolean
5959
datadogMode: boolean
60+
// Datadog site used for the feature-flag OAuth flow + catalog fetch.
61+
flagsSite: string
6062
}

developer-extension/src/common/panelTabConstants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export const enum PanelTabs {
33
Infos = 'infos',
44
Settings = 'settings',
55
Replay = 'replay',
6+
Flags = 'flags',
67
}
78

89
export const DEFAULT_PANEL_TAB = PanelTabs.Events

developer-extension/src/panel/components/panel.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { SettingsTab } from './tabs/settingsTab'
1313
import { InfosTab } from './tabs/infosTab'
1414
import { EventsTab, DEFAULT_COLUMNS } from './tabs/eventsTab'
1515
import { ReplayTab } from './tabs/replayTab'
16+
import { FlagsTab } from './tabs/flagsTab'
1617

1718
import * as classes from './panel.module.css'
1819

@@ -53,6 +54,11 @@ export function Panel() {
5354
<Tabs.Tab value={PanelTabs.Replay}>
5455
<Text>Live replay</Text>
5556
</Tabs.Tab>
57+
{settings.datadogMode && (
58+
<Tabs.Tab value={PanelTabs.Flags}>
59+
<Text>Feature Flags</Text>
60+
</Tabs.Tab>
61+
)}
5662
<Tabs.Tab
5763
value={PanelTabs.Settings}
5864
rightSection={
@@ -92,6 +98,11 @@ export function Panel() {
9298
<Tabs.Panel value={PanelTabs.Replay} className={classes.tab}>
9399
<ReplayTab />
94100
</Tabs.Panel>
101+
{settings.datadogMode && (
102+
<Tabs.Panel value={PanelTabs.Flags} className={classes.tab}>
103+
<FlagsTab />
104+
</Tabs.Panel>
105+
)}
95106
<Tabs.Panel value={PanelTabs.Settings} className={classes.tab}>
96107
<SettingsTab />
97108
</Tabs.Panel>
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { Anchor, Badge, Button, Center, Group, Select, Stack, Text } from '@mantine/core'
2+
import React, { useState } from 'react'
3+
import { useSettings } from '../../../hooks/useSettings'
4+
import type { FlagAuthState } from './useFlagAuth'
5+
import { FLAG_SITES } from './oauth'
6+
7+
export function ConnectScreen({ auth }: { auth: FlagAuthState }) {
8+
const [advancedOpen, setAdvancedOpen] = useState(false)
9+
10+
return (
11+
<Center h="100%" className="dd-privacy-allow">
12+
<Stack align="center" gap="md" maw={460} px="md">
13+
<Text size="xl" fw={600} ta="center">
14+
Authenticate with Datadog to access your feature flags
15+
</Text>
16+
<Button color="violet" onClick={auth.connect} loading={auth.connecting}>
17+
Sign in to Datadog
18+
</Button>
19+
{auth.error && (
20+
<Text c="red" size="xs" ta="center">
21+
{auth.error}
22+
</Text>
23+
)}
24+
25+
<Anchor component="button" type="button" size="xs" c="dimmed" onClick={() => setAdvancedOpen((open) => !open)}>
26+
{advancedOpen ? '− Hide advanced' : 'Advanced: site'}
27+
</Anchor>
28+
{advancedOpen && (
29+
<Stack gap="sm" style={{ width: '100%' }}>
30+
<SiteField />
31+
</Stack>
32+
)}
33+
</Stack>
34+
</Center>
35+
)
36+
}
37+
38+
export function ConnectionHeader({ auth }: { auth: FlagAuthState }) {
39+
return (
40+
<Stack gap={4}>
41+
<Group justify="space-between">
42+
<Group gap="xs">
43+
<Badge color="green" variant="light">
44+
Connected via OAuth
45+
</Badge>
46+
<Text c="dimmed" size="xs">
47+
{auth.site}
48+
</Text>
49+
</Group>
50+
<Button size="compact-xs" variant="subtle" color="gray" onClick={auth.disconnect}>
51+
Disconnect
52+
</Button>
53+
</Group>
54+
{/* Surface disconnect failures here too — otherwise a failed Disconnect looks like a no-op. */}
55+
{auth.error && (
56+
<Text c="red" size="xs" ta="right">
57+
{auth.error}
58+
</Text>
59+
)}
60+
</Stack>
61+
)
62+
}
63+
64+
function SiteField() {
65+
const [{ flagsSite }, setSetting] = useSettings()
66+
67+
return (
68+
<Select
69+
label="Datadog site"
70+
description="Your organization's Datadog site."
71+
data={FLAG_SITES.map(({ site, label }) => ({ value: site, label }))}
72+
value={flagsSite}
73+
onChange={(value) => value && setSetting('flagsSite', value)}
74+
allowDeselect={false}
75+
size="xs"
76+
/>
77+
)
78+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { Box } from '@mantine/core'
2+
import React from 'react'
3+
import { TabBase } from '../../tabBase'
4+
import { useFlagAuth } from './useFlagAuth'
5+
import { ConnectScreen, ConnectionHeader } from './connectScreen'
6+
7+
export function FlagsTab() {
8+
const auth = useFlagAuth()
9+
10+
// Gate the whole tab: nothing shows until the user connects via OAuth. Browsing the flag catalog
11+
// is added on top of this in the follow-up PR.
12+
if (!auth.isConnected) {
13+
return (
14+
<TabBase>
15+
<ConnectScreen auth={auth} />
16+
</TabBase>
17+
)
18+
}
19+
20+
return (
21+
<TabBase
22+
top={
23+
<Box px="md" className="dd-privacy-allow">
24+
<ConnectionHeader auth={auth} />
25+
</Box>
26+
}
27+
>
28+
<Box px="md" py="sm" className="dd-privacy-allow" />
29+
</TabBase>
30+
)
31+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { FlagsTab } from './flagsTab'
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { registerCleanupTask, replaceMockable } from '../../../../../../packages/browser-core/test'
2+
import { getFlagsApiHost, loginWithOAuth, sha256 } from './oauth'
3+
4+
describe('oauth', () => {
5+
describe('getFlagsApiHost', () => {
6+
it('maps each site to its frontend host (US1/EU1 → app, staging → dd, regional sites as-is)', () => {
7+
expect(getFlagsApiHost('datadoghq.com')).toBe('app.datadoghq.com')
8+
expect(getFlagsApiHost('datadoghq.eu')).toBe('app.datadoghq.eu')
9+
expect(getFlagsApiHost('datad0g.com')).toBe('dd.datad0g.com')
10+
expect(getFlagsApiHost('us3.datadoghq.com')).toBe('us3.datadoghq.com')
11+
expect(getFlagsApiHost('ddog-gov.com')).toBe('ddog-gov.com')
12+
})
13+
14+
it('throws on a site that is not in the known list', () => {
15+
expect(() => getFlagsApiHost('evil.example')).toThrowError(/Unknown Datadog site/)
16+
expect(() => getFlagsApiHost('')).toThrowError(/Unknown Datadog site/)
17+
})
18+
})
19+
20+
describe('loginWithOAuth', () => {
21+
// loginWithOAuth's PKCE step hashes with crypto.subtle, which is only exposed in a secure
22+
// context — some CI browsers (mobile devices reached over http) don't provide it. Stub the hash
23+
// via its mockable seam so these tests don't depend on the runtime's secure-context status.
24+
// (Production runs on the extension's chrome-extension:// origin, always a secure context.)
25+
beforeEach(() => {
26+
replaceMockable(sha256, () => Promise.resolve(new Uint8Array(32).buffer))
27+
})
28+
29+
// Stub chrome.identity so launchWebAuthFlow echoes back a redirect built from the state that
30+
// loginWithOAuth actually generated (so the state check passes and we exercise the domain check).
31+
function mockChromeIdentity(makeRedirect: (params: { state: string }) => string) {
32+
const previousChrome = (globalThis as any).chrome
33+
;(globalThis as any).chrome = {
34+
identity: {
35+
getRedirectURL: () => 'https://ext-id.chromiumapp.org/',
36+
launchWebAuthFlow: ({ url }: { url: string }) => {
37+
const state = new URL(url).searchParams.get('state')!
38+
return Promise.resolve(makeRedirect({ state }))
39+
},
40+
},
41+
}
42+
registerCleanupTask(() => {
43+
;(globalThis as any).chrome = previousChrome
44+
})
45+
}
46+
47+
it('aborts when the redirect domain does not match the selected site', async () => {
48+
mockChromeIdentity(({ state }) => `https://ext-id.chromiumapp.org/?code=abc&state=${state}&domain=datadoghq.com`)
49+
const fetchSpy = spyOn(globalThis, 'fetch')
50+
51+
await expectAsync(loginWithOAuth('datad0g.com')).toBeRejectedWithError(/but "datad0g.com" was selected/)
52+
expect(fetchSpy).not.toHaveBeenCalled()
53+
})
54+
55+
it('exchanges the code when the redirect domain matches the selected site', async () => {
56+
mockChromeIdentity(({ state }) => `https://ext-id.chromiumapp.org/?code=abc&state=${state}&domain=datad0g.com`)
57+
spyOn(globalThis, 'fetch').and.returnValue(
58+
Promise.resolve(new Response(JSON.stringify({ access_token: 'tok', expires_in: 3600 })))
59+
)
60+
61+
const tokens = await loginWithOAuth('datad0g.com')
62+
expect(tokens.accessToken).toBe('tok')
63+
})
64+
65+
it('proceeds when the redirect omits a domain', async () => {
66+
mockChromeIdentity(({ state }) => `https://ext-id.chromiumapp.org/?code=abc&state=${state}`)
67+
spyOn(globalThis, 'fetch').and.returnValue(
68+
Promise.resolve(new Response(JSON.stringify({ access_token: 'tok', expires_in: 3600 })))
69+
)
70+
71+
const tokens = await loginWithOAuth('datad0g.com')
72+
expect(tokens.accessToken).toBe('tok')
73+
})
74+
})
75+
})

0 commit comments

Comments
 (0)