Skip to content

Commit 3a89cc2

Browse files
kellyw1806claude
andauthored
✨ [FFL-2857] Feature Flags tab — team filtering + token revocation (stacked PR 4 of 4) (#4932)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent acf9d77 commit 3a89cc2

24 files changed

Lines changed: 1399 additions & 368 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// Turns an unknown caught value into a displayable string: an Error's message, or the value coerced
2+
// to a string. One place to change how errors read across the panel.
3+
export function toErrorMessage(error: unknown): string {
4+
return error instanceof Error ? error.message : String(error)
5+
}

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

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,9 @@ export function Panel() {
5454
<Tabs.Tab value={PanelTabs.Replay}>
5555
<Text>Live replay</Text>
5656
</Tabs.Tab>
57-
{settings.datadogMode && (
58-
<Tabs.Tab value={PanelTabs.Flags}>
59-
<Text>Feature Flags</Text>
60-
</Tabs.Tab>
61-
)}
57+
<Tabs.Tab value={PanelTabs.Flags}>
58+
<Text>Feature Flags</Text>
59+
</Tabs.Tab>
6260
<Tabs.Tab
6361
value={PanelTabs.Settings}
6462
rightSection={
@@ -98,11 +96,9 @@ export function Panel() {
9896
<Tabs.Panel value={PanelTabs.Replay} className={classes.tab}>
9997
<ReplayTab />
10098
</Tabs.Panel>
101-
{settings.datadogMode && (
102-
<Tabs.Panel value={PanelTabs.Flags} className={classes.tab}>
103-
<FlagsTab />
104-
</Tabs.Panel>
105-
)}
99+
<Tabs.Panel value={PanelTabs.Flags} className={classes.tab}>
100+
<FlagsTab />
101+
</Tabs.Panel>
106102
<Tabs.Panel value={PanelTabs.Settings} className={classes.tab}>
107103
<SettingsTab />
108104
</Tabs.Panel>

developer-extension/src/panel/components/tabBase.module.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44

55
.topContainer {
66
margin: 0;
7+
/* Sit above the scrolling content and cast a soft shadow onto it, so a long list reads as scrolling
8+
*under* the header instead of looking cut off at the top edge. Applies to every tab's top bar. */
9+
position: relative;
10+
z-index: 1;
11+
box-shadow: 0 4px 8px -6px rgba(0, 0, 0, 0.25);
712
}
813

914
.leftContainer {

developer-extension/src/panel/components/tabs/flagsTab/connectScreen.tsx

Lines changed: 78 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,25 @@
1-
import { Anchor, Badge, Button, Center, Group, Select, Stack, Text } from '@mantine/core'
2-
import React, { useState } from 'react'
1+
import { Alert, Badge, Box, Button, Center, Group, Select, Stack, Text } from '@mantine/core'
2+
import React from 'react'
33
import { useSettings } from '../../../hooks/useSettings'
44
import type { FlagAuthState } from './useFlagAuth'
5+
import { useInspectedPageOverrides } from './useInspectedPageOverrides'
56
import { FLAG_SITES } from './oauth'
67

78
export function ConnectScreen({ auth }: { auth: FlagAuthState }) {
8-
const [advancedOpen, setAdvancedOpen] = useState(false)
9-
109
return (
1110
<Center h="100%" className="dd-privacy-allow">
1211
<Stack align="center" gap="md" maw={460} px="md">
12+
<DisconnectedOverridesNotice />
1313
<Text size="xl" fw={600} ta="center">
1414
Authenticate with Datadog to access your feature flags
1515
</Text>
16+
{/* Pick the site before signing in: it selects which Datadog OAuth server + FFE API the flow
17+
talks to (see FLAG_SITES), so it must be set before the Sign in button runs that flow. */}
18+
<Box w="100%">
19+
{/* Locked while signing in: the chosen site is baked into the OAuth flow already running, so
20+
switching mid-flow would point the resulting token at a different environment. */}
21+
<SiteField disabled={auth.connecting} />
22+
</Box>
1623
<Button color="violet" onClick={auth.connect} loading={auth.connecting}>
1724
Sign in to Datadog
1825
</Button>
@@ -21,47 +28,92 @@ export function ConnectScreen({ auth }: { auth: FlagAuthState }) {
2128
{auth.error}
2229
</Text>
2330
)}
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>
31+
{/* A revocation that failed leaves the grant live at Datadog while this panel is signed out,
32+
so the notice belongs on this screen — it's the one the user lands on after disconnecting. */}
33+
{auth.warning && (
34+
<Text c="orange" size="xs" ta="center">
35+
{auth.warning} You can revoke it from Datadog under Organization Settings → Authorized Applications.
36+
</Text>
3237
)}
3338
</Stack>
3439
</Center>
3540
)
3641
}
3742

43+
/**
44+
* Surfaces overrides already stored on the inspected page while signed out — otherwise this screen is
45+
* all that renders, so an override left from an earlier session keeps affecting the page with nothing
46+
* to explain it. Informational only; everything that mutates overrides lives on the connected tab.
47+
*
48+
* Mounted only while disconnected, so its navigation listeners never run alongside the connected
49+
* tab's own instance of this hook.
50+
*/
51+
function DisconnectedOverridesNotice() {
52+
const { status, overrides } = useInspectedPageOverrides()
53+
const count = Object.keys(overrides).length
54+
55+
if (status !== 'ready' || count === 0) {
56+
return null
57+
}
58+
59+
return (
60+
// Masked despite the surrounding dd-privacy-allow: only a count renders today, but flag keys are
61+
// customer data, so anything added here should stay out of the extension's own Session Replay.
62+
<Alert
63+
color="orange"
64+
w="100%"
65+
data-dd-privacy="mask"
66+
title={`${count} override${count === 1 ? '' : 's'} active on this page`}
67+
>
68+
<Text size="xs">
69+
These are stored in the page and keep applying while you are signed out. Sign in to view and remove them.
70+
</Text>
71+
</Alert>
72+
)
73+
}
74+
3875
export function ConnectionHeader({ auth }: { auth: FlagAuthState }) {
3976
return (
4077
<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}>
78+
{/* The badge opts out of Mantine's default uppercasing: "datad0g.com" and "datadoghq.com"
79+
differ by a zero vs an "o", so caps destroy the one glyph telling staging from production.
80+
Disconnect sits at the far end — it revokes the grant, so a misclick costs a full re-auth. */}
81+
<Group gap="xs" align="center" justify="space-between" wrap="nowrap">
82+
<Badge color="green" variant="light" tt="none">
83+
Connected: {siteLabel(auth.site)}
84+
</Badge>
85+
<Button
86+
size="compact-xs"
87+
variant="light"
88+
color="red"
89+
onClick={auth.disconnect}
90+
loading={auth.disconnecting}
91+
// Disconnect revokes the grant at Datadog before clearing the local session, so guard
92+
// against a second click re-running it against tokens the first click already revoked.
93+
disabled={auth.disconnecting}
94+
>
5195
Disconnect
5296
</Button>
5397
</Group>
54-
{/* Surface disconnect failures here too — otherwise a failed Disconnect looks like a no-op. */}
98+
{/* Surface disconnect failures here too — otherwise a failed Disconnect looks like a no-op.
99+
(A revoke-succeeded-but-grant-live warning can't appear here: it always accompanies a
100+
successful local sign-out, which flips to the ConnectScreen where the notice lives.) */}
55101
{auth.error && (
56-
<Text c="red" size="xs" ta="right">
102+
<Text c="red" size="xs">
57103
{auth.error}
58104
</Text>
59105
)}
60106
</Stack>
61107
)
62108
}
63109

64-
function SiteField() {
110+
// Falls back to the raw site so a stale or hand-edited setting still renders something meaningful
111+
// (getFlagsApiHost is the one that treats an unknown site as an error).
112+
function siteLabel(site: string): string {
113+
return FLAG_SITES.find((entry) => entry.site === site)?.label ?? site
114+
}
115+
116+
function SiteField({ disabled }: { disabled?: boolean }) {
65117
const [{ flagsSite }, setSetting] = useSettings()
66118

67119
return (
@@ -72,6 +124,7 @@ function SiteField() {
72124
value={flagsSite}
73125
onChange={(value) => value && setSetting('flagsSite', value)}
74126
allowDeselect={false}
127+
disabled={disabled}
75128
size="xs"
76129
/>
77130
)
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// Shared helper for the FFE API calls the Flags tab makes (catalog, current user, teams). Centralizes
2+
// the bearer-auth header + response handling that flagsRequests.ts and flagIdentity.ts would
3+
// otherwise each repeat.
4+
5+
// Thrown on a 403 so callers can tell "the token lacks the scope" apart from a real failure (used by
6+
// flagIdentity to degrade the team filter rather than fail the whole tab).
7+
export class ForbiddenError extends Error {}
8+
9+
/**
10+
* GETs a JSON resource from the FFE API with the OAuth bearer token. Throws ForbiddenError on 403 and
11+
* a generic Error on any other non-2xx, prefixing the message with `errorLabel`. Keep customer data
12+
* (e.g. a flag key) out of `errorLabel` — these errors are logged and the panel forwards logs to its
13+
* own telemetry.
14+
*/
15+
export async function fetchFfeJson<T>(url: string, token: string, errorLabel: string): Promise<T> {
16+
const response = await fetch(url, {
17+
headers: {
18+
Authorization: `Bearer ${token}`,
19+
},
20+
})
21+
if (response.status === 403) {
22+
throw new ForbiddenError(`${errorLabel}: 403 ${response.statusText}`)
23+
}
24+
if (!response.ok) {
25+
throw new Error(`${errorLabel}: ${response.status} ${response.statusText}`)
26+
}
27+
return (await response.json()) as T
28+
}

0 commit comments

Comments
 (0)