Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions developer-extension/src/common/extension.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,6 @@ export interface Settings {
logsConfigurationOverride: object | null
debugMode: boolean
datadogMode: boolean
// Datadog site used for the feature-flag OAuth flow + catalog fetch.
flagsSite: string
}
1 change: 1 addition & 0 deletions developer-extension/src/common/panelTabConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export const enum PanelTabs {
Infos = 'infos',
Settings = 'settings',
Replay = 'replay',
Flags = 'flags',
}

export const DEFAULT_PANEL_TAB = PanelTabs.Events
11 changes: 11 additions & 0 deletions developer-extension/src/panel/components/panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { SettingsTab } from './tabs/settingsTab'
import { InfosTab } from './tabs/infosTab'
import { EventsTab, DEFAULT_COLUMNS } from './tabs/eventsTab'
import { ReplayTab } from './tabs/replayTab'
import { FlagsTab } from './tabs/flagsTab'

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

Expand Down Expand Up @@ -53,6 +54,11 @@ export function Panel() {
<Tabs.Tab value={PanelTabs.Replay}>
<Text>Live replay</Text>
</Tabs.Tab>
{settings.datadogMode && (
<Tabs.Tab value={PanelTabs.Flags}>
<Text>Feature Flags</Text>
</Tabs.Tab>
)}
<Tabs.Tab
value={PanelTabs.Settings}
rightSection={
Expand Down Expand Up @@ -92,6 +98,11 @@ export function Panel() {
<Tabs.Panel value={PanelTabs.Replay} className={classes.tab}>
<ReplayTab />
</Tabs.Panel>
{settings.datadogMode && (
<Tabs.Panel value={PanelTabs.Flags} className={classes.tab}>
<FlagsTab />
</Tabs.Panel>
)}
<Tabs.Panel value={PanelTabs.Settings} className={classes.tab}>
<SettingsTab />
</Tabs.Panel>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { Anchor, Badge, Button, Center, Group, Select, Stack, Text } from '@mantine/core'
import React, { useState } from 'react'
import { useSettings } from '../../../hooks/useSettings'
import type { FlagAuthState } from './useFlagAuth'
import { FLAG_SITES } from './oauth'

export function ConnectScreen({ auth }: { auth: FlagAuthState }) {
const [advancedOpen, setAdvancedOpen] = useState(false)

return (
<Center h="100%" className="dd-privacy-allow">
<Stack align="center" gap="md" maw={460} px="md">
<Text size="xl" fw={600} ta="center">
Authenticate with Datadog to access your feature flags
</Text>
<Button color="violet" onClick={auth.connect} loading={auth.connecting}>
Sign in to Datadog
</Button>
{auth.error && (
<Text c="red" size="xs" ta="center">
{auth.error}
</Text>
)}

<Anchor component="button" type="button" size="xs" c="dimmed" onClick={() => setAdvancedOpen((open) => !open)}>
{advancedOpen ? '− Hide advanced' : 'Advanced: site'}
</Anchor>
{advancedOpen && (
<Stack gap="sm" style={{ width: '100%' }}>
<SiteField />
</Stack>
)}
</Stack>
</Center>
)
}

export function ConnectionHeader({ auth }: { auth: FlagAuthState }) {
return (
<Stack gap={4}>
<Group justify="space-between">
<Group gap="xs">
<Badge color="green" variant="light">
Connected via OAuth
</Badge>
<Text c="dimmed" size="xs">
{auth.site}
</Text>
</Group>
<Button size="compact-xs" variant="subtle" color="gray" onClick={auth.disconnect}>
Disconnect
</Button>
</Group>
{/* Surface disconnect failures here too — otherwise a failed Disconnect looks like a no-op. */}
{auth.error && (
<Text c="red" size="xs" ta="right">
{auth.error}
</Text>
)}
</Stack>
)
}

function SiteField() {
const [{ flagsSite }, setSetting] = useSettings()

return (
<Select
label="Datadog site"
description="Your organization's Datadog site."
data={FLAG_SITES.map(({ site, label }) => ({ value: site, label }))}
value={flagsSite}
onChange={(value) => value && setSetting('flagsSite', value)}
allowDeselect={false}
size="xs"
/>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Box } from '@mantine/core'
import React from 'react'
import { TabBase } from '../../tabBase'
import { useFlagAuth } from './useFlagAuth'
import { ConnectScreen, ConnectionHeader } from './connectScreen'

export function FlagsTab() {
const auth = useFlagAuth()

// Gate the whole tab: nothing shows until the user connects via OAuth. Browsing the flag catalog
// is added on top of this in the follow-up PR.
if (!auth.isConnected) {
return (
<TabBase>
<ConnectScreen auth={auth} />
</TabBase>
)
}

return (
<TabBase
top={
<Box px="md" className="dd-privacy-allow">
<ConnectionHeader auth={auth} />
</Box>
}
>
<Box px="md" py="sm" className="dd-privacy-allow" />
</TabBase>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { FlagsTab } from './flagsTab'
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { registerCleanupTask, replaceMockable } from '../../../../../../packages/browser-core/test'
import { getFlagsApiHost, loginWithOAuth, sha256 } from './oauth'

describe('oauth', () => {
describe('getFlagsApiHost', () => {
it('maps each site to its frontend host (US1/EU1 → app, staging → dd, regional sites as-is)', () => {
expect(getFlagsApiHost('datadoghq.com')).toBe('app.datadoghq.com')
expect(getFlagsApiHost('datadoghq.eu')).toBe('app.datadoghq.eu')
expect(getFlagsApiHost('datad0g.com')).toBe('dd.datad0g.com')
expect(getFlagsApiHost('us3.datadoghq.com')).toBe('us3.datadoghq.com')
expect(getFlagsApiHost('ddog-gov.com')).toBe('ddog-gov.com')
})

it('throws on a site that is not in the known list', () => {
expect(() => getFlagsApiHost('evil.example')).toThrowError(/Unknown Datadog site/)
expect(() => getFlagsApiHost('')).toThrowError(/Unknown Datadog site/)
})
})

describe('loginWithOAuth', () => {
// loginWithOAuth's PKCE step hashes with crypto.subtle, which is only exposed in a secure
// context — some CI browsers (mobile devices reached over http) don't provide it. Stub the hash
// via its mockable seam so these tests don't depend on the runtime's secure-context status.
// (Production runs on the extension's chrome-extension:// origin, always a secure context.)
beforeEach(() => {
replaceMockable(sha256, () => Promise.resolve(new Uint8Array(32).buffer))
})

// Stub chrome.identity so launchWebAuthFlow echoes back a redirect built from the state that
// loginWithOAuth actually generated (so the state check passes and we exercise the domain check).
function mockChromeIdentity(makeRedirect: (params: { state: string }) => string) {
const previousChrome = (globalThis as any).chrome
;(globalThis as any).chrome = {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Route OAuth dependencies through mockable wrappers

issue: This spec replaces the entire globalThis.chrome object and directly spies on browser globals instead of wrapping those source dependencies with mockable() and using replaceMockable() or replaceMockableWithSpy(). Besides discarding unrelated Chrome APIs for the duration of each test, this bypasses the repository's required auto-cleanup mocking pattern and makes the OAuth tests brittle as the implementation starts using additional APIs; expose narrow mockable wrappers rather than replacing the browser globals.

AGENTS.md reference: AGENTS.md:L105-L105

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

identity: {
getRedirectURL: () => 'https://ext-id.chromiumapp.org/',
launchWebAuthFlow: ({ url }: { url: string }) => {
const state = new URL(url).searchParams.get('state')!
return Promise.resolve(makeRedirect({ state }))
},
},
}
registerCleanupTask(() => {
;(globalThis as any).chrome = previousChrome
})
}

it('aborts when the redirect domain does not match the selected site', async () => {
mockChromeIdentity(({ state }) => `https://ext-id.chromiumapp.org/?code=abc&state=${state}&domain=datadoghq.com`)
const fetchSpy = spyOn(globalThis, 'fetch')

await expectAsync(loginWithOAuth('datad0g.com')).toBeRejectedWithError(/but "datad0g.com" was selected/)
expect(fetchSpy).not.toHaveBeenCalled()
})

it('exchanges the code when the redirect domain matches the selected site', async () => {
mockChromeIdentity(({ state }) => `https://ext-id.chromiumapp.org/?code=abc&state=${state}&domain=datad0g.com`)
spyOn(globalThis, 'fetch').and.returnValue(
Promise.resolve(new Response(JSON.stringify({ access_token: 'tok', expires_in: 3600 })))
)

const tokens = await loginWithOAuth('datad0g.com')
expect(tokens.accessToken).toBe('tok')
})

it('proceeds when the redirect omits a domain', async () => {
mockChromeIdentity(({ state }) => `https://ext-id.chromiumapp.org/?code=abc&state=${state}`)
spyOn(globalThis, 'fetch').and.returnValue(
Promise.resolve(new Response(JSON.stringify({ access_token: 'tok', expires_in: 3600 })))
)

const tokens = await loginWithOAuth('datad0g.com')
expect(tokens.accessToken).toBe('tok')
})
})
})
Loading
Loading