From 850a642cbfb4842f71a26ba53c45279ed0a55da5 Mon Sep 17 00:00:00 2001 From: Kelly Date: Thu, 30 Jul 2026 12:47:46 -0400 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20[FFL-2857]=20add=20team=20filtering?= =?UTF-8?q?=20and=20token=20revocation=20to=20the=20flags=20tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/common/toErrorMessage.ts | 5 + .../src/panel/components/panel.tsx | 16 +- .../src/panel/components/tabBase.module.css | 5 + .../tabs/flagsTab/connectScreen.tsx | 63 +++--- .../panel/components/tabs/flagsTab/ffeApi.ts | 28 +++ .../tabs/flagsTab/flagCatalogList.tsx | 100 ++++++++-- .../tabs/flagsTab/flagFilterBar.tsx | 181 +++++++++++++++++- .../tabs/flagsTab/flagIdentity.spec.ts | 129 +++++++++++++ .../components/tabs/flagsTab/flagIdentity.ts | 98 ++++++++++ .../components/tabs/flagsTab/flagsContext.tsx | 14 +- .../tabs/flagsTab/flagsRequests.spec.ts | 43 ++++- .../components/tabs/flagsTab/flagsRequests.ts | 40 +++- .../components/tabs/flagsTab/flagsTab.tsx | 49 +++-- .../tabs/flagsTab/manualOverrideForm.tsx | 3 +- .../components/tabs/flagsTab/oauth.spec.ts | 171 ++++++++++++++--- .../panel/components/tabs/flagsTab/oauth.ts | 110 ++++++++--- .../components/tabs/flagsTab/useFlagAuth.ts | 30 ++- .../tabs/flagsTab/useFlagCatalog.ts | 3 +- .../tabs/flagsTab/useFlagCatalogView.spec.ts | 81 ++++++++ .../tabs/flagsTab/useFlagCatalogView.ts | 55 ++++-- .../tabs/flagsTab/useFlagIdentity.ts | 71 +++++++ 21 files changed, 1140 insertions(+), 155 deletions(-) create mode 100644 developer-extension/src/common/toErrorMessage.ts create mode 100644 developer-extension/src/panel/components/tabs/flagsTab/ffeApi.ts create mode 100644 developer-extension/src/panel/components/tabs/flagsTab/flagIdentity.spec.ts create mode 100644 developer-extension/src/panel/components/tabs/flagsTab/flagIdentity.ts create mode 100644 developer-extension/src/panel/components/tabs/flagsTab/useFlagCatalogView.spec.ts create mode 100644 developer-extension/src/panel/components/tabs/flagsTab/useFlagIdentity.ts diff --git a/developer-extension/src/common/toErrorMessage.ts b/developer-extension/src/common/toErrorMessage.ts new file mode 100644 index 0000000000..1ce86f218e --- /dev/null +++ b/developer-extension/src/common/toErrorMessage.ts @@ -0,0 +1,5 @@ +// Turns an unknown caught value into a displayable string: an Error's message, or the value coerced +// to a string. One place to change how errors read across the panel. +export function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/developer-extension/src/panel/components/panel.tsx b/developer-extension/src/panel/components/panel.tsx index 6656dd1f8e..467861ccd3 100644 --- a/developer-extension/src/panel/components/panel.tsx +++ b/developer-extension/src/panel/components/panel.tsx @@ -54,11 +54,9 @@ export function Panel() { Live replay - {settings.datadogMode && ( - - Feature Flags - - )} + + Feature Flags + - {settings.datadogMode && ( - - - - )} + + + diff --git a/developer-extension/src/panel/components/tabBase.module.css b/developer-extension/src/panel/components/tabBase.module.css index fbab67f9e1..f925e30595 100644 --- a/developer-extension/src/panel/components/tabBase.module.css +++ b/developer-extension/src/panel/components/tabBase.module.css @@ -4,6 +4,11 @@ .topContainer { margin: 0; + /* Sit above the scrolling content and cast a soft shadow onto it, so a long list reads as scrolling + *under* the header instead of looking cut off at the top edge. Applies to every tab's top bar. */ + position: relative; + z-index: 1; + box-shadow: 0 4px 8px -6px rgba(0, 0, 0, 0.25); } .leftContainer { diff --git a/developer-extension/src/panel/components/tabs/flagsTab/connectScreen.tsx b/developer-extension/src/panel/components/tabs/flagsTab/connectScreen.tsx index 83228de118..6fda983ff2 100644 --- a/developer-extension/src/panel/components/tabs/flagsTab/connectScreen.tsx +++ b/developer-extension/src/panel/components/tabs/flagsTab/connectScreen.tsx @@ -1,18 +1,23 @@ -import { Anchor, Badge, Button, Center, Group, Select, Stack, Text } from '@mantine/core' -import React, { useState } from 'react' +import { Badge, Box, Button, Center, Group, Select, Stack, Text } from '@mantine/core' +import React 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 (
Authenticate with Datadog to access your feature flags + {/* Pick the site before signing in: it selects which Datadog OAuth server + FFE API the flow + talks to (see FLAG_SITES), so it must be set before the Sign in button runs that flow. */} + + {/* Locked while signing in: the chosen site is baked into the OAuth flow already running, so + switching mid-flow would point the resulting token at a different environment. */} + + @@ -21,14 +26,12 @@ export function ConnectScreen({ auth }: { auth: FlagAuthState }) { {auth.error} )} - - setAdvancedOpen((open) => !open)}> - {advancedOpen ? '− Hide advanced' : 'Advanced: site'} - - {advancedOpen && ( - - - + {/* A revocation that failed leaves the grant live at Datadog while this panel is signed out, + so the notice belongs on this screen — it's the one the user lands on after disconnecting. */} + {auth.warning && ( + + {auth.warning} You can revoke it from Datadog under Organization Settings → Authorized Applications. + )}
@@ -38,22 +41,31 @@ export function ConnectScreen({ auth }: { auth: FlagAuthState }) { export function ConnectionHeader({ auth }: { auth: FlagAuthState }) { return ( - - - - Connected via OAuth - - - {auth.site} - - - - {/* Surface disconnect failures here too — otherwise a failed Disconnect looks like a no-op. */} + {/* Surface disconnect failures here too — otherwise a failed Disconnect looks like a no-op. + (A revoke-succeeded-but-grant-live warning can't appear here: it always accompanies a + successful local sign-out, which flips to the ConnectScreen where the notice lives.) */} {auth.error && ( - + {auth.error} )} @@ -61,7 +73,7 @@ export function ConnectionHeader({ auth }: { auth: FlagAuthState }) { ) } -function SiteField() { +function SiteField({ disabled }: { disabled?: boolean }) { const [{ flagsSite }, setSetting] = useSettings() return ( @@ -72,6 +84,7 @@ function SiteField() { value={flagsSite} onChange={(value) => value && setSetting('flagsSite', value)} allowDeselect={false} + disabled={disabled} size="xs" /> ) diff --git a/developer-extension/src/panel/components/tabs/flagsTab/ffeApi.ts b/developer-extension/src/panel/components/tabs/flagsTab/ffeApi.ts new file mode 100644 index 0000000000..14a4ed6b9f --- /dev/null +++ b/developer-extension/src/panel/components/tabs/flagsTab/ffeApi.ts @@ -0,0 +1,28 @@ +// Shared helper for the FFE API calls the Flags tab makes (catalog, current user, teams). Centralizes +// the bearer-auth header + response handling that flagsRequests.ts and flagIdentity.ts would +// otherwise each repeat. + +// Thrown on a 403 so callers can tell "the token lacks the scope" apart from a real failure (used by +// flagIdentity to degrade the team filter rather than fail the whole tab). +export class ForbiddenError extends Error {} + +/** + * GETs a JSON resource from the FFE API with the OAuth bearer token. Throws ForbiddenError on 403 and + * a generic Error on any other non-2xx, prefixing the message with `errorLabel`. Keep customer data + * (e.g. a flag key) out of `errorLabel` — these errors are logged and the panel forwards logs to its + * own telemetry. + */ +export async function fetchFfeJson(url: string, token: string, errorLabel: string): Promise { + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${token}`, + }, + }) + if (response.status === 403) { + throw new ForbiddenError(`${errorLabel}: 403 ${response.statusText}`) + } + if (!response.ok) { + throw new Error(`${errorLabel}: ${response.status} ${response.statusText}`) + } + return (await response.json()) as T +} diff --git a/developer-extension/src/panel/components/tabs/flagsTab/flagCatalogList.tsx b/developer-extension/src/panel/components/tabs/flagsTab/flagCatalogList.tsx index bae8e54626..7a54583d4e 100644 --- a/developer-extension/src/panel/components/tabs/flagsTab/flagCatalogList.tsx +++ b/developer-extension/src/panel/components/tabs/flagsTab/flagCatalogList.tsx @@ -1,6 +1,19 @@ -import { ActionIcon, Box, Button, Code, CopyButton, Group, Loader, Space, Text, Tooltip } from '@mantine/core' +import { + ActionIcon, + Anchor, + Box, + Button, + Code, + CopyButton, + Group, + Loader, + Space, + Stack, + Text, + Tooltip, +} from '@mantine/core' import { IconArrowBackUp, IconCopy } from '@tabler/icons-react' -import React, { type ReactNode } from 'react' +import React, { useLayoutEffect, useRef, useState, type ReactNode } from 'react' import type { CatalogFlag } from './flagsRequests' import { useFlagsContext } from './flagsContext' import { validateOverrideValue } from './flagTypes' @@ -29,7 +42,7 @@ export function FlagCatalogBody() { - + ) } @@ -113,20 +126,25 @@ function FlagRow({ - + {flag.name} - + {flag.description && } + {overridden && ( @@ -169,6 +187,56 @@ function FlagRow({ ) } +// Free-text descriptions can run long. Show a single line by default with a "Show more" toggle that +// expands the rest inline. The toggle only appears when the one-line clamp actually hides something — +// measured rather than guessed from length, since a short description can still wrap and a long one +// might fit. +function FlagDescription({ description }: { description: string }) { + const [expanded, setExpanded] = useState(false) + const [overflowing, setOverflowing] = useState(false) + const textRef = useRef(null) + + // Measure whether the collapsed description overflows one line, so we know to offer "Show more". + // Skip while expanded — the clamp is off then, so a measurement would read as "fits" and wrongly + // hide the toggle; `overflowing` keeps its collapsed value. The ResizeObserver re-measures when the + // panel width changes, so narrowing the DevTools panel surfaces a newly-clamped description's toggle. + useLayoutEffect(() => { + const el = textRef.current + if (!el || expanded) { + return + } + const measure = () => setOverflowing(el.scrollHeight > el.clientHeight) + measure() + const observer = new ResizeObserver(measure) + observer.observe(el) + return () => observer.disconnect() + }, [description, expanded]) + + return ( + // Extra top margin gives the description a touch more separation from the key above it than the + // name↔key gap, so the row reads as "title/key" then "description". + + + {description} + + {overflowing && ( + // Accent color in both states so it reads as the row's action. A hair smaller than the + // description text and sitting right beneath it, so the two read as clearly distinct. + setExpanded((value) => !value)} + style={{ display: 'inline-block', marginTop: 0 }} + > + {expanded ? 'Show less' : 'Show more'} + + )} + + ) +} + function FlagKey({ value }: { value: string }) { return ( @@ -179,6 +247,9 @@ function FlagKey({ value }: { value: string }) { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', + // Negate the chip's own horizontal padding so the key text lines up with the flag name above. + paddingInline: 6, + marginLeft: -6, }} > {value} @@ -186,7 +257,14 @@ function FlagKey({ value }: { value: string }) { {({ copied, copy }) => ( - + {/* Flip to violet on copy for a moment of feedback, then back to neutral grey. */} + diff --git a/developer-extension/src/panel/components/tabs/flagsTab/flagFilterBar.tsx b/developer-extension/src/panel/components/tabs/flagsTab/flagFilterBar.tsx index c6295f94d8..9ca1503cdc 100644 --- a/developer-extension/src/panel/components/tabs/flagsTab/flagFilterBar.tsx +++ b/developer-extension/src/panel/components/tabs/flagsTab/flagFilterBar.tsx @@ -1,19 +1,34 @@ -import { Group, MultiSelect, Stack, TagsInput, TextInput } from '@mantine/core' -import { IconSearch } from '@tabler/icons-react' -import React from 'react' +import { + Box, + Checkbox, + Combobox, + Group, + InputBase, + MultiSelect, + Stack, + Switch, + TagsInput, + TextInput, + Tooltip, + useCombobox, +} from '@mantine/core' +import { IconChevronRight, IconSearch } from '@tabler/icons-react' +import React, { useState } from 'react' import { FLAG_TYPES, FLAG_TYPE_CONFIG } from './flagTypes' import { useFlagsContext } from './flagsContext' +import type { FlagIdentityState } from './useFlagIdentity' +import type { FlagCatalogView } from './useFlagCatalogView' // Type is a fixed set, so its options are static. There's no tags endpoint and we only load a page // at a time, so the Tag filter can't show every tag — instead it offers `tagSuggestions` (tags seen -// on pages loaded so far) as autocomplete while still accepting any typed tag. Search/type/tags are -// all applied server-side (see useFlagCatalog). +// on pages loaded so far) as autocomplete while still accepting any typed tag. Search, type, tags, +// "My feature flags" (created_by) and "My teams" (team: tags) are all applied server-side. export function FlagFilterBar() { - const { view, tagSuggestions } = useFlagsContext() + const { view, tagSuggestions, identity } = useFlagsContext() const typeOptions = FLAG_TYPES.map((type) => ({ value: type, label: FLAG_TYPE_CONFIG[type].label })) return ( - + } @@ -21,7 +36,11 @@ export function FlagFilterBar() { onChange={(event) => view.setSearch(event.currentTarget.value)} size="xs" /> - + {/* Bottom-aligned so the toggle sits on the same baseline as the labelled selects; the selects + flex-grow and wrap so the row stays tidy in the narrow devtools panel. */} + + + ) } + +function MyFlagsSwitch({ view, identity }: { view: FlagCatalogView; identity: FlagIdentityState }) { + // No user id → the created_by filter can't match anything, so disable the toggle rather than + // offer one whose only effect is to empty the list. + const unavailable = !identity.loading && !identity.userId + + return ( + + {/* Bordered rounded rectangle so the toggle reads as a filter chip matching the Type/Tags boxes. + The Box also lets the tooltip fire while the Switch itself is disabled. */} + + view.setMyFlagsOnly(event.currentTarget.checked)} + /> + + + ) +} + +// Show the search field only past this many teams; a shorter list doesn't need filtering. Matches +// the Datadog web UI's team filter, whose threshold is its default page of 10 plus a small buffer. +const TEAM_SEARCH_THRESHOLD = 12 + +// A checkbox dropdown rather than a chip multiselect: the closed control shows a compact "N teams +// selected" summary (so it never grows tall), and the open list checks the selected teams and floats +// them to the top. +function MyTeamsSelect({ view, identity }: { view: FlagCatalogView; identity: FlagIdentityState }) { + const selected = view.teamFilter + const [search, setSearch] = useState('') + const combobox = useCombobox({ + onDropdownClose: () => { + combobox.resetSelectedOption() + setSearch('') + }, + }) + + // Snapshot a "selected first" ordering when the dropdown opens, so toggling a team mid-list doesn't + // make it jump under the cursor. Recomputed on each open. + const [ordered, setOrdered] = useState([]) + + // Disabled while there's nothing to pick: still loading, no teams, or the token can't read teams. + const disabled = identity.teamHandles.length === 0 + + // Explain a disabled control — no permission to read teams, the lookup failed, or the user is in + // none. Stay silent while still loading, or when there are teams to pick. + const tooltipLabel = identity.teamsForbidden + ? "You don't have permission to view teams" + : identity.teamsUnavailable + ? "Couldn't load your teams — try reconnecting" + : !identity.loading && identity.teamHandles.length === 0 + ? "You're not in any teams" + : null + + const toggle = (handle: string) => + view.setTeamFilter(selected.includes(handle) ? selected.filter((h) => h !== handle) : [...selected, handle]) + + const openWithOrder = () => { + if (!combobox.dropdownOpened) { + const inSelection = identity.teamHandles.filter((handle) => selected.includes(handle)) + const rest = identity.teamHandles.filter((handle) => !selected.includes(handle)) + setOrdered([...inSelection, ...rest]) + } + combobox.toggleDropdown() + } + + // Fall back to the raw handles until the first open populates `ordered`. + const list = ordered.length > 0 ? ordered : identity.teamHandles + const searchable = identity.teamHandles.length > TEAM_SEARCH_THRESHOLD + const query = search.trim().toLowerCase() + const options = list + // Always keep selected teams in the list, even when they don't match the search, so a selection + // never disappears out from under the user (matches the web UI's team filter). + .filter((handle) => selected.includes(handle) || !query || handle.toLowerCase().includes(query)) + .map((handle) => ( + + {/* Wrap long handles instead of overflowing the narrow dropdown. */} + + + {handle} + + + )) + + return ( + + + + + } + rightSection={} + rightSectionPointerEvents="none" + onClick={openWithOrder} + > + {selected.length > 0 ? `My Teams · ${selected.length}` : 'My Teams'} + + + + {searchable && ( + setSearch(event.currentTarget.value)} + placeholder="Search teams" + /> + )} + + {options.length > 0 ? options : No teams match} + + + + + + ) +} diff --git a/developer-extension/src/panel/components/tabs/flagsTab/flagIdentity.spec.ts b/developer-extension/src/panel/components/tabs/flagsTab/flagIdentity.spec.ts new file mode 100644 index 0000000000..8d477f13dd --- /dev/null +++ b/developer-extension/src/panel/components/tabs/flagsTab/flagIdentity.spec.ts @@ -0,0 +1,129 @@ +import { fetchCurrentUserId, fetchFlagIdentity, fetchMyTeamHandles } from './flagIdentity' + +describe('flagIdentity', () => { + // Routes each request to a handler keyed by a substring of the path, so a test only has to + // describe the endpoints it cares about. + function mockEndpoints(handlers: Record Response>) { + const requests: string[] = [] + spyOn(globalThis, 'fetch').and.callFake((input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + requests.push(url) + for (const [fragment, respond] of Object.entries(handlers)) { + if (url.includes(fragment)) { + return Promise.resolve(respond()) + } + } + return Promise.resolve(new Response('not found', { status: 404, statusText: 'Not Found' })) + }) + return requests + } + + function json(body: unknown, init?: ResponseInit) { + return new Response(JSON.stringify(body), init) + } + + describe('fetchCurrentUserId', () => { + it('returns the user UUID from the response id', async () => { + const requests = mockEndpoints({ '/api/v2/current_user': () => json({ data: { id: 'user-uuid' } }) }) + + expect(await fetchCurrentUserId('tok', 'datad0g.com')).toBe('user-uuid') + expect(requests[0]).toBe('https://dd.datad0g.com/api/v2/current_user') + }) + + it('returns null when the response omits the id', async () => { + mockEndpoints({ '/api/v2/current_user': () => json({ data: {} }) }) + expect(await fetchCurrentUserId('tok', 'datad0g.com')).toBeNull() + }) + + it('throws on a non-ok response', async () => { + mockEndpoints({ '/api/v2/current_user': () => json({}, { status: 500, statusText: 'Server Error' }) }) + await expectAsync(fetchCurrentUserId('tok', 'datad0g.com')).toBeRejectedWithError(/failed: 500/) + }) + }) + + describe('fetchMyTeamHandles', () => { + it('requests only the caller’s teams and returns sorted handles', async () => { + const requests = mockEndpoints({ + '/api/v2/team': () => + json({ data: [{ attributes: { handle: 'zebra' } }, { attributes: { handle: 'alpha' } }] }), + }) + + expect(await fetchMyTeamHandles('tok', 'datad0g.com')).toEqual(['alpha', 'zebra']) + expect(requests[0]).toContain('filter%5Bme%5D=true') + }) + + it('dedupes handles repeated within the page', async () => { + mockEndpoints({ + '/api/v2/team': () => json({ data: [{ attributes: { handle: 'a' } }, { attributes: { handle: 'a' } }] }), + }) + expect(await fetchMyTeamHandles('tok', 'datad0g.com')).toEqual(['a']) + }) + + it('skips entries without a handle', async () => { + mockEndpoints({ '/api/v2/team': () => json({ data: [{ attributes: {} }, {}] }) }) + expect(await fetchMyTeamHandles('tok', 'datad0g.com')).toEqual([]) + }) + }) + + describe('fetchFlagIdentity', () => { + it('returns both facts when the token can read teams', async () => { + mockEndpoints({ + '/api/v2/current_user': () => json({ data: { id: 'user-uuid' } }), + '/api/v2/team': () => json({ data: [{ attributes: { handle: 'my-squad' } }] }), + }) + + expect(await fetchFlagIdentity('tok', 'datad0g.com')).toEqual({ + userId: 'user-uuid', + teamHandles: ['my-squad'], + teamsForbidden: false, + teamsUnavailable: false, + }) + }) + + // The teams endpoint requires the teams_read permission, which the OAuth client may not have + // been granted. That has to degrade to "team filter unavailable", not to a failed identity. + it('reports teamsForbidden on a 403 from the teams endpoint, keeping the user id', async () => { + mockEndpoints({ + '/api/v2/current_user': () => json({ data: { id: 'user-uuid' } }), + '/api/v2/team': () => json({ errors: ['Forbidden'] }, { status: 403, statusText: 'Forbidden' }), + }) + + expect(await fetchFlagIdentity('tok', 'datad0g.com')).toEqual({ + userId: 'user-uuid', + teamHandles: [], + teamsForbidden: true, + teamsUnavailable: false, + }) + }) + + // A non-403 team failure (network/server) is a genuine lookup failure, distinct from an empty + // membership — teamsUnavailable, not teamsForbidden, so the UI says "couldn't load". + it('reports teamsUnavailable (not teamsForbidden) for a non-403 team failure', async () => { + mockEndpoints({ + '/api/v2/current_user': () => json({ data: { id: 'user-uuid' } }), + '/api/v2/team': () => json({}, { status: 500, statusText: 'Server Error' }), + }) + + expect(await fetchFlagIdentity('tok', 'datad0g.com')).toEqual({ + userId: 'user-uuid', + teamHandles: [], + teamsForbidden: false, + teamsUnavailable: true, + }) + }) + + it('keeps the team handles when only the user lookup fails', async () => { + mockEndpoints({ + '/api/v2/current_user': () => json({}, { status: 403, statusText: 'Forbidden' }), + '/api/v2/team': () => json({ data: [{ attributes: { handle: 'my-squad' } }] }), + }) + + expect(await fetchFlagIdentity('tok', 'datad0g.com')).toEqual({ + userId: null, + teamHandles: ['my-squad'], + teamsForbidden: false, + teamsUnavailable: false, + }) + }) + }) +}) diff --git a/developer-extension/src/panel/components/tabs/flagsTab/flagIdentity.ts b/developer-extension/src/panel/components/tabs/flagsTab/flagIdentity.ts new file mode 100644 index 0000000000..cbc7afca56 --- /dev/null +++ b/developer-extension/src/panel/components/tabs/flagsTab/flagIdentity.ts @@ -0,0 +1,98 @@ +// Fetches who the signed-in user is, so the catalog can offer the webapp's two identity-scoped +// filters: "My feature flags" (flags whose creator is the signed-in user) and "My teams" (flags +// tagged `team:` for a team the user belongs to). +// +// The feature-flag API carries no notion of "me" — it filters by creator UUID and by `team:` +// tag, both of which the caller has to supply. So the two facts below come from Datadog's org +// endpoints rather than from FFE: +// +// GET /api/v2/current_user — permissions=OPEN(), so any valid OAuth access token works. This is +// why "My feature flags" needs no scope beyond the ones we already ask +// for. `data.id` is the user's UUID, the same value the flag API +// returns as `created_by`. +// GET /api/v2/team — gated on the user's own Datadog permission to read teams, not on any +// token scope, so we don't request one for it. `filter[me]=true` +// narrows to the caller's own teams. +// +// A user without permission to read teams gets a 403 here. That's an expected state, not an error: +// the team filter is then reported unavailable and the rest of the tab carries on. + +import { fetchFfeJson, ForbiddenError } from './ffeApi' +import { getFlagsApiHost } from './oauth' + +export interface FlagIdentity { + /** UUID of the signed-in user, or null when it couldn't be resolved. */ + userId: string | null + /** Handles of the teams the signed-in user belongs to, sorted for stable display. */ + teamHandles: string[] + /** True when the team lookup was refused because the user lacks permission to read teams. */ + teamsForbidden: boolean + /** True when the team lookup failed for another reason (network/server), distinct from an empty membership. */ + teamsUnavailable: boolean +} + +interface RawCurrentUserResponse { + data?: { id?: string } +} + +interface RawTeamResponse { + data?: Array<{ attributes?: { handle?: string } }> +} + +/** + * Returns the signed-in user's UUID, or null when the response omits it. + */ +export async function fetchCurrentUserId(token: string, site: string): Promise { + const body = await fetchFfeJson( + `https://${getFlagsApiHost(site)}/api/v2/current_user`, + token, + 'Current user request failed' + ) + return body.data?.id ?? null +} + +// The teams endpoint caps `page[size]` at 100; a user in more than 100 teams is unrealistic, so a +// single page covers every real case. +const TEAM_PAGE_SIZE = 100 + +/** + * Returns the handles of the teams the signed-in user belongs to. Throws a ForbiddenError (surfaced + * by fetchFlagIdentity as `teamsForbidden`) when the user isn't allowed to read teams. + */ +export async function fetchMyTeamHandles(token: string, site: string): Promise { + const params = new URLSearchParams({ + 'filter[me]': 'true', + // Ask only for the handle: it's the only field the `team:` tag match needs, and a + // narrower response keeps customer team metadata out of the extension. + 'fields[team]': 'handle', + 'page[size]': String(TEAM_PAGE_SIZE), + }) + const body = await fetchFfeJson( + `https://${getFlagsApiHost(site)}/api/v2/team?${params.toString()}`, + token, + 'Teams request failed' + ) + + const handles = (body.data ?? []) + .map((team) => team.attributes?.handle) + .filter((handle): handle is string => !!handle) + return Array.from(new Set(handles)).sort((a, b) => a.localeCompare(b)) +} + +/** + * Resolves both identity facts, tolerating the absence of either. Neither filter is essential to the + * tab, so a failure downgrades the affected filter instead of failing the whole catalog: the user + * lookup falling over leaves `userId` null, and a refused team lookup sets `teamsForbidden`. + */ +export async function fetchFlagIdentity(token: string, site: string): Promise { + const [user, teams] = await Promise.allSettled([fetchCurrentUserId(token, site), fetchMyTeamHandles(token, site)]) + + const teamsForbidden = teams.status === 'rejected' && teams.reason instanceof ForbiddenError + return { + userId: user.status === 'fulfilled' ? user.value : null, + teamHandles: teams.status === 'fulfilled' ? teams.value : [], + teamsForbidden, + // A non-403 rejection is a genuine lookup failure, not "no teams". + teamsUnavailable: teams.status === 'rejected' && !teamsForbidden, + } +} diff --git a/developer-extension/src/panel/components/tabs/flagsTab/flagsContext.tsx b/developer-extension/src/panel/components/tabs/flagsTab/flagsContext.tsx index 0a0ad132c3..29a3c95d41 100644 --- a/developer-extension/src/panel/components/tabs/flagsTab/flagsContext.tsx +++ b/developer-extension/src/panel/components/tabs/flagsTab/flagsContext.tsx @@ -1,9 +1,11 @@ import React, { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react' +import { toErrorMessage } from '../../../../common/toErrorMessage' import type { CatalogFlag } from './flagsRequests' import { getOverride, type FlagOverride, type FlagOverrides } from './inspectedPageFlags' import type { FlagAuthState } from './useFlagAuth' import { useFlagCatalog, type FlagCatalogState } from './useFlagCatalog' import { useFlagCatalogView, type FlagCatalogView } from './useFlagCatalogView' +import { useFlagIdentity, type FlagIdentityState } from './useFlagIdentity' import { useInspectedPageOverrides, type FlagPageStatus } from './useInspectedPageOverrides' import { useOverriddenFlags } from './useOverriddenFlags' @@ -11,6 +13,8 @@ import { useOverriddenFlags } from './useOverriddenFlags' // tab and its components render state and invoke actions without prop-drilling. export interface FlagsContextValue { view: FlagCatalogView + // Signed-in user + team handles backing the "My feature flags" and "My teams" filters. + identity: FlagIdentityState catalog: FlagCatalogState // Inspected-page override state (see useInspectedPageOverrides). overrideStatus: FlagPageStatus @@ -49,7 +53,10 @@ export function useFlagsContext(): FlagsContextValue { * and its components consume this via useFlagsContext and stay focused on rendering. */ export function FlagsProvider({ auth, children }: { auth: FlagAuthState; children: ReactNode }) { - const view = useFlagCatalogView() + // Identity resolves first: the catalog view needs the signed-in user's UUID for the "My feature + // flags" (created_by) filter, and the filter bar needs the team handles for "My teams". + const identity = useFlagIdentity(auth) + const view = useFlagCatalogView(identity.userId) const catalog = useFlagCatalog(auth, view.request) const { setPage } = view const { status, error, overrides, devtoolsEnabled, setOverride, clearOverride, clearAll, reloadPage } = @@ -77,6 +84,7 @@ export function FlagsProvider({ auth, children }: { auth: FlagAuthState; childre overriddenCatalogFlags.find((flag) => flag.key === key) ?? { key, name: key, + description: '', type: overrides[key].type, variants: [], tags: [], @@ -126,7 +134,7 @@ export function FlagsProvider({ auth, children }: { auth: FlagAuthState; childre // serialized, so a success here means the current stored state is good. setMutationError(null) }) - .catch((error: unknown) => setMutationError(error instanceof Error ? error.message : String(error))) + .catch((error: unknown) => setMutationError(toErrorMessage(error))) .finally(() => setWritesInFlight((count) => count - 1)) }, []) @@ -147,6 +155,7 @@ export function FlagsProvider({ auth, children }: { auth: FlagAuthState; childre const value = useMemo( () => ({ view, + identity, catalog, overrideStatus: status, overrideError: error, @@ -166,6 +175,7 @@ export function FlagsProvider({ auth, children }: { auth: FlagAuthState; childre }), [ view, + identity, catalog, status, error, diff --git a/developer-extension/src/panel/components/tabs/flagsTab/flagsRequests.spec.ts b/developer-extension/src/panel/components/tabs/flagsTab/flagsRequests.spec.ts index 96cd1d5787..07c55070ab 100644 --- a/developer-extension/src/panel/components/tabs/flagsTab/flagsRequests.spec.ts +++ b/developer-extension/src/panel/components/tabs/flagsTab/flagsRequests.spec.ts @@ -3,7 +3,15 @@ import { fetchFlagCatalog, fetchFlagsByKeys } from './flagsRequests' describe('flagsRequests', () => { describe('fetchFlagCatalog', () => { - const baseRequest: FlagCatalogRequest = { page: 1, pageSize: 20, search: '', typeFilter: [], tagFilter: [] } + const baseRequest: FlagCatalogRequest = { + page: 1, + pageSize: 20, + search: '', + typeFilter: [], + tagFilter: [], + teamFilter: [], + createdBy: null, + } function mockResponse(body: unknown) { return spyOn(globalThis, 'fetch').and.returnValue(Promise.resolve(new Response(JSON.stringify(body)))) @@ -13,9 +21,11 @@ describe('flagsRequests', () => { const sampleFlag: CatalogFlag = { key: 'flag-a', name: 'Flag A', + description: 'Controls the new checkout', type: 'BOOLEAN', variants: [{ name: 'on', value: true }], tags: ['x'], + createdBy: 'user-uuid', } const sampleTotal = 42 const spy = mockResponse({ @@ -24,10 +34,12 @@ describe('flagsRequests', () => { attributes: { key: sampleFlag.key, name: sampleFlag.name, + description: sampleFlag.description, value_type: sampleFlag.type, // The API returns variant values as strings; parseVariantValue turns them back. variants: sampleFlag.variants.map(({ name, value }) => ({ name, value: String(value) })), tags: sampleFlag.tags, + created_by: sampleFlag.createdBy, }, }, ], @@ -44,6 +56,7 @@ describe('flagsRequests', () => { expect(url.searchParams.get('is_archived')).toBe('false') expect((requestInit.headers as Record).Authorization).toBe('Bearer tok') expect(page.total).toBe(sampleTotal) + // sampleFlag round-trips including description + createdBy (from attributes.description/created_by). expect(page.flags).toEqual([sampleFlag]) }) @@ -56,6 +69,8 @@ describe('flagsRequests', () => { search: 'checkout', typeFilter: ['BOOLEAN', 'STRING'], tagFilter: ['team:x', 'beta'], + teamFilter: [], + createdBy: null, }) const [requestUrl] = spy.calls.argsFor(0) as [string, RequestInit] @@ -65,6 +80,28 @@ describe('flagsRequests', () => { expect(url.searchParams.getAll('tags')).toEqual(['team:x', 'beta']) }) + it('sends "My feature flags" as created_by and "My teams" as team: tags', async () => { + const spy = mockResponse({ data: [], meta: { page: { total: 0 } } }) + + await fetchFlagCatalog('tok', 'datad0g.com', { + ...baseRequest, + tagFilter: ['beta'], + teamFilter: ['alpha', 'gamma'], + createdBy: 'user-uuid', + }) + + const url = new URL(spy.calls.argsFor(0)[0] as string) + expect(url.searchParams.get('created_by')).toBe('user-uuid') + // Regular tags and team tags ride the same `tags` param; the server splits them by prefix. + expect(url.searchParams.getAll('tags')).toEqual(['beta', 'team:alpha', 'team:gamma']) + }) + + it('omits created_by when "My feature flags" is off', async () => { + const spy = mockResponse({ data: [], meta: { page: { total: 0 } } }) + await fetchFlagCatalog('tok', 'datad0g.com', baseRequest) + expect(new URL(spy.calls.argsFor(0)[0] as string).searchParams.has('created_by')).toBe(false) + }) + it('omits the search param when the term is empty', async () => { const spy = mockResponse({ data: [], meta: { page: { total: 0 } } }) await fetchFlagCatalog('tok', 'datad0g.com', baseRequest) @@ -167,13 +204,15 @@ describe('flagsRequests', () => { expect(flags[0].name).toBe('First') }) - it('falls back to the key for a missing name and defaults tags/variants', async () => { + it('falls back to the key for a missing name and defaults description/tags/variants', async () => { mockResponse({ data: [{ attributes: { key: 'no-name', value_type: 'STRING' } }], meta: { page: { total: 1 } } }) const { flags } = await fetchFlagCatalog('tok', 'datad0g.com', baseRequest) expect(flags[0].name).toBe('no-name') + expect(flags[0].description).toBe('') expect(flags[0].tags).toEqual([]) expect(flags[0].variants).toEqual([]) + expect(flags[0].createdBy).toBeUndefined() }) it('tolerates a response that omits data/meta, falling total back to the page length', async () => { diff --git a/developer-extension/src/panel/components/tabs/flagsTab/flagsRequests.ts b/developer-extension/src/panel/components/tabs/flagsTab/flagsRequests.ts index f2b7dc18aa..b15f1c2f20 100644 --- a/developer-extension/src/panel/components/tabs/flagsTab/flagsRequests.ts +++ b/developer-extension/src/panel/components/tabs/flagsTab/flagsRequests.ts @@ -1,24 +1,39 @@ +import { fetchFfeJson } from './ffeApi' import { FLAG_TYPES, parseTypedString, type FlagType } from './flagTypes' import { getFlagsApiHost } from './oauth' export interface CatalogFlag { key: string name: string + // Free-text description authored in the Datadog UI. Empty when the flag has none. + description: string type: FlagType // Parsed value of each variant (any JSON value); see parseVariantValue. variants: Array<{ name: string; value: unknown }> tags: string[] + // UUID of the user who created the flag, as returned by the API. Undefined for flags created by a + // service account or an integration, which don't carry a user UUID. Compared against the signed-in + // user's UUID to drive the "My feature flags" filter. + createdBy?: string } // Filters + pagination sent to the server so the FFE endpoint does the work — the extension never // loads the whole catalog. The endpoint applies all of these itself: `search` matches name/key/tags, -// `tags` are AND-ed, `value_type` is OR-ed (see dd-source ffe-service). `page` is 1-based. +// `tags` are AND-ed, `value_type` is OR-ed, `created_by` is an IN-list, and `team:` tags are +// OR-ed among themselves then AND-ed with regular tags (see dd-source ffe-service). `page` is 1-based. export interface FlagCatalogRequest { page: number pageSize: number search: string typeFilter: string[] tagFilter: string[] + // Team handles for the "My teams" filter. Sent as `tags=team:` — the server OR-s team tags + // among themselves and AND-s them with the regular `tagFilter`. + teamFilter: string[] + // The signed-in user's UUID when "My feature flags" is on, else null. Sent as `created_by` so the + // server returns only flags this user created. Filtering here (not client-side) is required: we + // load one page at a time, so a client filter would only ever see the current page. + createdBy: string | null } // One page of results plus the server's total count (for pagination). @@ -31,9 +46,11 @@ interface RawFeatureFlag { attributes: { key: string name?: string + description?: string value_type: FlagType variants?: Array<{ name: string; value: string }> tags?: string[] + created_by?: string } } @@ -91,6 +108,15 @@ export function fetchFlagCatalog(token: string, site: string, request: FlagCatal for (const tag of request.tagFilter) { url.searchParams.append('tags', tag) } + // "My teams": team handles ride the same `tags` param as `team:` (the server OR-s team + // tags among themselves, then AND-s them with the regular tags above). + for (const handle of request.teamFilter) { + url.searchParams.append('tags', `team:${handle}`) + } + // "My feature flags": restrict to flags created by the signed-in user. + if (request.createdBy) { + url.searchParams.set('created_by', request.createdBy) + } return fetchFlagPage(url, token, 'Failed to fetch flag catalog') } @@ -124,15 +150,7 @@ export async function fetchFlagsByKeys(token: string, site: string, keys: string // falls back to the resource count when the server omits `meta.page.total` (e.g. a partial/legacy // response, or the by-key lookup which never sends pagination fields). async function fetchFlagPage(url: URL, token: string, errorLabel: string): Promise { - const response = await fetch(url.toString(), { - headers: { - Authorization: `Bearer ${token}`, - }, - }) - if (!response.ok) { - throw new Error(`${errorLabel}: ${response.status} ${response.statusText}`) - } - const body = (await response.json()) as RawFeatureFlagsResponse + const body = await fetchFfeJson(url.toString(), token, errorLabel) const resources = Array.isArray(body?.data) ? body.data : [] return { flags: mapResources(resources), @@ -151,12 +169,14 @@ function mapResources(resources: RawFeatureFlag[]): CatalogFlag[] { byKey.set(attributes.key, { key: attributes.key, name: attributes.name || attributes.key, + description: attributes.description ?? '', type: attributes.value_type, variants: (attributes.variants ?? []).map((variant) => ({ name: variant.name, value: parseVariantValue(attributes.value_type, variant.value), })), tags: attributes.tags ?? [], + createdBy: attributes.created_by, }) } return Array.from(byKey.values()) diff --git a/developer-extension/src/panel/components/tabs/flagsTab/flagsTab.tsx b/developer-extension/src/panel/components/tabs/flagsTab/flagsTab.tsx index 7c5b82701e..dfabefc75c 100644 --- a/developer-extension/src/panel/components/tabs/flagsTab/flagsTab.tsx +++ b/developer-extension/src/panel/components/tabs/flagsTab/flagsTab.tsx @@ -1,4 +1,4 @@ -import { Alert, Anchor, Box, Button, Code, Group, Pagination, Space } from '@mantine/core' +import { Alert, Anchor, Box, Button, Code, Group, Pagination, Space, Title } from '@mantine/core' import React, { useState } from 'react' import { TabBase } from '../../tabBase' import { ConnectScreen, ConnectionHeader } from './connectScreen' @@ -53,9 +53,13 @@ function ConnectedFlagsTab({ auth }: { auth: FlagAuthState }) { return ( + // No px here: the TabBase Container already insets by `md`, so the header lines up with the + // catalog rows below (which get their `md` from their own Box). No dd-privacy-allow either — + // the header/filter/catalog render customer flag names, values, and tags, which must stay + // masked in the extension's own Session Replay. + + Feature Flag Overrides + @@ -107,6 +111,32 @@ function ConnectedFlagsTab({ auth }: { auth: FlagAuthState }) { )} + setAddOpen((open) => !open)}> + {addOpen ? '− Hide custom override' : '+ Add a custom override'} + + {addOpen && ( + <> + + + + )} + + + {/* Sticky footer: the apply/refresh actions stay visible without scrolling to the end of a long + catalog, so a user can't miss them. Scheme-aware background + top divider/shadow so it reads + as a layer over the scrolling content in both light and dark mode. */} +