Skip to content

Commit 850a642

Browse files
kellyw1806claude
andcommitted
✨ [FFL-2857] add team filtering and token revocation to the flags tab
Filter the flag catalog by owning team (teams_read scope + flag identity), and revoke the OAuth grant at Datadog on disconnect rather than only clearing local tokens. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8ba13be commit 850a642

21 files changed

Lines changed: 1140 additions & 155 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: 38 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,23 @@
1-
import { Anchor, Badge, Button, Center, Group, Select, Stack, Text } from '@mantine/core'
2-
import React, { useState } from 'react'
1+
import { 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'
55
import { FLAG_SITES } from './oauth'
66

77
export function ConnectScreen({ auth }: { auth: FlagAuthState }) {
8-
const [advancedOpen, setAdvancedOpen] = useState(false)
9-
108
return (
119
<Center h="100%" className="dd-privacy-allow">
1210
<Stack align="center" gap="md" maw={460} px="md">
1311
<Text size="xl" fw={600} ta="center">
1412
Authenticate with Datadog to access your feature flags
1513
</Text>
14+
{/* Pick the site before signing in: it selects which Datadog OAuth server + FFE API the flow
15+
talks to (see FLAG_SITES), so it must be set before the Sign in button runs that flow. */}
16+
<Box w="100%">
17+
{/* Locked while signing in: the chosen site is baked into the OAuth flow already running, so
18+
switching mid-flow would point the resulting token at a different environment. */}
19+
<SiteField disabled={auth.connecting} />
20+
</Box>
1621
<Button color="violet" onClick={auth.connect} loading={auth.connecting}>
1722
Sign in to Datadog
1823
</Button>
@@ -21,14 +26,12 @@ export function ConnectScreen({ auth }: { auth: FlagAuthState }) {
2126
{auth.error}
2227
</Text>
2328
)}
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>
29+
{/* A revocation that failed leaves the grant live at Datadog while this panel is signed out,
30+
so the notice belongs on this screen — it's the one the user lands on after disconnecting. */}
31+
{auth.warning && (
32+
<Text c="orange" size="xs" ta="center">
33+
{auth.warning} You can revoke it from Datadog under Organization Settings → Authorized Applications.
34+
</Text>
3235
)}
3336
</Stack>
3437
</Center>
@@ -38,30 +41,39 @@ export function ConnectScreen({ auth }: { auth: FlagAuthState }) {
3841
export function ConnectionHeader({ auth }: { auth: FlagAuthState }) {
3942
return (
4043
<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}>
44+
{/* One read-only status badge — "CONNECTED: <site>" — states which site you're connected to
45+
without a separate input-looking field (the auth method is an implementation detail the user
46+
doesn't need). Disconnect is a red button so it reads as the destructive sign-out. */}
47+
<Group gap="xs" align="center">
48+
<Badge color="green" variant="light">
49+
Connected: {auth.site}
50+
</Badge>
51+
<Button
52+
size="compact-xs"
53+
variant="light"
54+
color="red"
55+
onClick={auth.disconnect}
56+
loading={auth.disconnecting}
57+
// Disconnect revokes the grant at Datadog before clearing the local session, so guard
58+
// against a second click re-running it against tokens the first click already revoked.
59+
disabled={auth.disconnecting}
60+
>
5161
Disconnect
5262
</Button>
5363
</Group>
54-
{/* Surface disconnect failures here too — otherwise a failed Disconnect looks like a no-op. */}
64+
{/* Surface disconnect failures here too — otherwise a failed Disconnect looks like a no-op.
65+
(A revoke-succeeded-but-grant-live warning can't appear here: it always accompanies a
66+
successful local sign-out, which flips to the ConnectScreen where the notice lives.) */}
5567
{auth.error && (
56-
<Text c="red" size="xs" ta="right">
68+
<Text c="red" size="xs">
5769
{auth.error}
5870
</Text>
5971
)}
6072
</Stack>
6173
)
6274
}
6375

64-
function SiteField() {
76+
function SiteField({ disabled }: { disabled?: boolean }) {
6577
const [{ flagsSite }, setSetting] = useSettings()
6678

6779
return (
@@ -72,6 +84,7 @@ function SiteField() {
7284
value={flagsSite}
7385
onChange={(value) => value && setSetting('flagsSite', value)}
7486
allowDeselect={false}
87+
disabled={disabled}
7588
size="xs"
7689
/>
7790
)
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+
}

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

Lines changed: 89 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,19 @@
1-
import { ActionIcon, Box, Button, Code, CopyButton, Group, Loader, Space, Text, Tooltip } from '@mantine/core'
1+
import {
2+
ActionIcon,
3+
Anchor,
4+
Box,
5+
Button,
6+
Code,
7+
CopyButton,
8+
Group,
9+
Loader,
10+
Space,
11+
Stack,
12+
Text,
13+
Tooltip,
14+
} from '@mantine/core'
215
import { IconArrowBackUp, IconCopy } from '@tabler/icons-react'
3-
import React, { type ReactNode } from 'react'
16+
import React, { useLayoutEffect, useRef, useState, type ReactNode } from 'react'
417
import type { CatalogFlag } from './flagsRequests'
518
import { useFlagsContext } from './flagsContext'
619
import { validateOverrideValue } from './flagTypes'
@@ -29,7 +42,7 @@ export function FlagCatalogBody() {
2942
<Space h="xs" />
3043
<FlagList
3144
flags={bottomFlags}
32-
borderColor="var(--mantine-color-gray-2)"
45+
borderColor="var(--mantine-color-default-border)"
3346
// `bottomFlags` is the page minus overridden flags (those are pinned above). Only call it "no
3447
// match" when the server total is 0; otherwise this page's flags are all overridden.
3548
emptyMessage={
@@ -57,7 +70,7 @@ export function OverridesSection() {
5770
Local overrides ({overriddenFlags.length})
5871
</Text>
5972
<Space h="xs" />
60-
<FlagList flags={overriddenFlags} borderColor="var(--mantine-color-violet-2)" />
73+
<FlagList flags={overriddenFlags} borderColor="var(--mantine-color-violet-outline)" />
6174
</>
6275
)
6376
}
@@ -113,20 +126,25 @@ function FlagRow({
113126
<Group
114127
justify="space-between"
115128
wrap="nowrap"
116-
align="center"
129+
// Top-align so the variant buttons stay up beside the name/key instead of drifting to the
130+
// vertical middle of a long description.
131+
align="flex-start"
117132
px="sm"
118-
py="xs"
133+
py="sm"
119134
style={{
120-
borderBottom: '1px solid var(--mantine-color-gray-1)',
121-
backgroundColor: overridden ? 'var(--mantine-color-violet-0)' : undefined,
135+
borderBottom: '1px solid var(--mantine-color-default-border)',
136+
// Mantine's scheme-aware subtle tint (same one variant="light" uses): light violet in light
137+
// mode, a muted translucent violet in dark mode — not a full saturated fill.
138+
backgroundColor: overridden ? 'var(--mantine-color-violet-light)' : undefined,
122139
}}
123140
>
124-
<Box style={{ minWidth: 0, flex: 1 }}>
141+
<Stack gap={6} style={{ minWidth: 0, flex: 1 }}>
125142
<Text size="sm" fw={600} truncate>
126143
{flag.name}
127144
</Text>
128145
<FlagKey value={flag.key} />
129-
</Box>
146+
{flag.description && <FlagDescription description={flag.description} />}
147+
</Stack>
130148
<Group gap="xs" wrap="wrap" justify="flex-end" style={{ flexShrink: 0, maxWidth: '55%' }}>
131149
{overridden && (
132150
<Tooltip label="Revert override">
@@ -169,6 +187,56 @@ function FlagRow({
169187
)
170188
}
171189

190+
// Free-text descriptions can run long. Show a single line by default with a "Show more" toggle that
191+
// expands the rest inline. The toggle only appears when the one-line clamp actually hides something —
192+
// measured rather than guessed from length, since a short description can still wrap and a long one
193+
// might fit.
194+
function FlagDescription({ description }: { description: string }) {
195+
const [expanded, setExpanded] = useState(false)
196+
const [overflowing, setOverflowing] = useState(false)
197+
const textRef = useRef<HTMLParagraphElement>(null)
198+
199+
// Measure whether the collapsed description overflows one line, so we know to offer "Show more".
200+
// Skip while expanded — the clamp is off then, so a measurement would read as "fits" and wrongly
201+
// hide the toggle; `overflowing` keeps its collapsed value. The ResizeObserver re-measures when the
202+
// panel width changes, so narrowing the DevTools panel surfaces a newly-clamped description's toggle.
203+
useLayoutEffect(() => {
204+
const el = textRef.current
205+
if (!el || expanded) {
206+
return
207+
}
208+
const measure = () => setOverflowing(el.scrollHeight > el.clientHeight)
209+
measure()
210+
const observer = new ResizeObserver(measure)
211+
observer.observe(el)
212+
return () => observer.disconnect()
213+
}, [description, expanded])
214+
215+
return (
216+
// Extra top margin gives the description a touch more separation from the key above it than the
217+
// name↔key gap, so the row reads as "title/key" then "description".
218+
<Box mt={4}>
219+
<Text ref={textRef} size="xs" lineClamp={expanded ? undefined : 1}>
220+
{description}
221+
</Text>
222+
{overflowing && (
223+
// Accent color in both states so it reads as the row's action. A hair smaller than the
224+
// description text and sitting right beneath it, so the two read as clearly distinct.
225+
<Anchor
226+
component="button"
227+
type="button"
228+
fz={10}
229+
c="violet"
230+
onClick={() => setExpanded((value) => !value)}
231+
style={{ display: 'inline-block', marginTop: 0 }}
232+
>
233+
{expanded ? 'Show less' : 'Show more'}
234+
</Anchor>
235+
)}
236+
</Box>
237+
)
238+
}
239+
172240
function FlagKey({ value }: { value: string }) {
173241
return (
174242
<Group gap={4} wrap="nowrap" style={{ minWidth: 0 }}>
@@ -179,14 +247,24 @@ function FlagKey({ value }: { value: string }) {
179247
overflow: 'hidden',
180248
textOverflow: 'ellipsis',
181249
whiteSpace: 'nowrap',
250+
// Negate the chip's own horizontal padding so the key text lines up with the flag name above.
251+
paddingInline: 6,
252+
marginLeft: -6,
182253
}}
183254
>
184255
{value}
185256
</Code>
186257
<CopyButton value={value}>
187258
{({ copied, copy }) => (
188259
<Tooltip label={copied ? 'Copied' : 'Copy key'} withArrow>
189-
<ActionIcon size="xs" variant="subtle" color="gray" onClick={copy} style={{ flexShrink: 0 }}>
260+
{/* Flip to violet on copy for a moment of feedback, then back to neutral grey. */}
261+
<ActionIcon
262+
size="xs"
263+
variant="subtle"
264+
color={copied ? 'violet' : 'gray'}
265+
onClick={copy}
266+
style={{ flexShrink: 0 }}
267+
>
190268
<IconCopy size={12} />
191269
</ActionIcon>
192270
</Tooltip>

0 commit comments

Comments
 (0)