Skip to content

Commit e4698ab

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 e4698ab

15 files changed

Lines changed: 1116 additions & 93 deletions

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

Lines changed: 30 additions & 15 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>
@@ -47,11 +50,22 @@ export function ConnectionHeader({ auth }: { auth: FlagAuthState }) {
4750
{auth.site}
4851
</Text>
4952
</Group>
50-
<Button size="compact-xs" variant="subtle" color="gray" onClick={auth.disconnect}>
53+
<Button
54+
size="compact-xs"
55+
variant="subtle"
56+
color="red"
57+
onClick={auth.disconnect}
58+
loading={auth.disconnecting}
59+
// Disconnect revokes the grant at Datadog before clearing the local session, so guard
60+
// against a second click re-running it against tokens the first click already revoked.
61+
disabled={auth.disconnecting}
62+
>
5163
Disconnect
5264
</Button>
5365
</Group>
54-
{/* Surface disconnect failures here too — otherwise a failed Disconnect looks like a no-op. */}
66+
{/* Surface disconnect failures here too — otherwise a failed Disconnect looks like a no-op.
67+
(A revoke-succeeded-but-grant-live warning can't appear here: it always accompanies a
68+
successful local sign-out, which flips to the ConnectScreen where the notice lives.) */}
5569
{auth.error && (
5670
<Text c="red" size="xs" ta="right">
5771
{auth.error}
@@ -61,7 +75,7 @@ export function ConnectionHeader({ auth }: { auth: FlagAuthState }) {
6175
)
6276
}
6377

64-
function SiteField() {
78+
function SiteField({ disabled }: { disabled?: boolean }) {
6579
const [{ flagsSite }, setSetting] = useSettings()
6680

6781
return (
@@ -72,6 +86,7 @@ function SiteField() {
7286
value={flagsSite}
7387
onChange={(value) => value && setSetting('flagsSite', value)}
7488
allowDeselect={false}
89+
disabled={disabled}
7590
size="xs"
7691
/>
7792
)
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 flagCatalog.ts and flagIdentity.ts would otherwise
3+
// 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: 69 additions & 6 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'
@@ -113,20 +126,23 @@ 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={{
120135
borderBottom: '1px solid var(--mantine-color-gray-1)',
121136
backgroundColor: overridden ? 'var(--mantine-color-violet-0)' : undefined,
122137
}}
123138
>
124-
<Box style={{ minWidth: 0, flex: 1 }}>
139+
<Stack gap={6} style={{ minWidth: 0, flex: 1 }}>
125140
<Text size="sm" fw={600} truncate>
126141
{flag.name}
127142
</Text>
128143
<FlagKey value={flag.key} />
129-
</Box>
144+
{flag.description && <FlagDescription description={flag.description} />}
145+
</Stack>
130146
<Group gap="xs" wrap="wrap" justify="flex-end" style={{ flexShrink: 0, maxWidth: '55%' }}>
131147
{overridden && (
132148
<Tooltip label="Revert override">
@@ -169,6 +185,49 @@ function FlagRow({
169185
)
170186
}
171187

188+
// Free-text descriptions can run long. Show a single line by default with a "Show more" toggle that
189+
// expands the rest inline. The toggle only appears when the one-line clamp actually hides something —
190+
// measured rather than guessed from length, since a short description can still wrap and a long one
191+
// might fit.
192+
function FlagDescription({ description }: { description: string }) {
193+
const [expanded, setExpanded] = useState(false)
194+
const [overflowing, setOverflowing] = useState(false)
195+
const textRef = useRef<HTMLParagraphElement>(null)
196+
197+
// Measured while collapsed (expanded isn't a dependency): once we know the text overflows one line,
198+
// the toggle stays available so "Show less" is still offered after expanding.
199+
useLayoutEffect(() => {
200+
const el = textRef.current
201+
if (el) {
202+
setOverflowing(el.scrollHeight > el.clientHeight)
203+
}
204+
}, [description])
205+
206+
return (
207+
// Extra top margin gives the description a touch more separation from the key above it than the
208+
// name↔key gap, so the row reads as "title/key" then "description".
209+
<Box mt={4}>
210+
<Text ref={textRef} size="xs" c="dimmed" lineClamp={expanded ? undefined : 1}>
211+
{description}
212+
</Text>
213+
{(overflowing || expanded) && (
214+
// Accent color in both states so it reads as the row's action. A hair smaller than the
215+
// description text and sitting right beneath it, so the two read as clearly distinct.
216+
<Anchor
217+
component="button"
218+
type="button"
219+
fz={10}
220+
c="violet"
221+
onClick={() => setExpanded((value) => !value)}
222+
style={{ display: 'inline-block', marginTop: 0 }}
223+
>
224+
{expanded ? 'Show less' : 'Show more'}
225+
</Anchor>
226+
)}
227+
</Box>
228+
)
229+
}
230+
172231
function FlagKey({ value }: { value: string }) {
173232
return (
174233
<Group gap={4} wrap="nowrap" style={{ minWidth: 0 }}>
@@ -179,6 +238,10 @@ function FlagKey({ value }: { value: string }) {
179238
overflow: 'hidden',
180239
textOverflow: 'ellipsis',
181240
whiteSpace: 'nowrap',
241+
paddingInline: 6,
242+
// Pull the chip left by its own padding so the key TEXT lines up with the flag name above,
243+
// while the grey box keeps its inner breathing room.
244+
marginLeft: -6,
182245
}}
183246
>
184247
{value}

0 commit comments

Comments
 (0)