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}
-
-
-
{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. */}
+
-
-
- setAddOpen((open) => !open)}>
- {addOpen ? '− Hide custom override' : '+ Add a custom override'}
-
- {addOpen && (
- <>
-
-
- >
- )}
)
diff --git a/developer-extension/src/panel/components/tabs/flagsTab/manualOverrideForm.tsx b/developer-extension/src/panel/components/tabs/flagsTab/manualOverrideForm.tsx
index 397412a366..76cffee318 100644
--- a/developer-extension/src/panel/components/tabs/flagsTab/manualOverrideForm.tsx
+++ b/developer-extension/src/panel/components/tabs/flagsTab/manualOverrideForm.tsx
@@ -1,5 +1,6 @@
import { Box, Button, Group, JsonInput, SegmentedControl, Space, Stack, Switch, Text, TextInput } from '@mantine/core'
import React, { useState } from 'react'
+import { toErrorMessage } from '../../../../common/toErrorMessage'
import { useFlagsContext } from './flagsContext'
import {
FLAG_TYPES,
@@ -38,7 +39,7 @@ export function ManualOverrideForm() {
try {
value = parseFormValue(type, type === 'BOOLEAN' ? booleanValue : textValue)
} catch (err) {
- setError(err instanceof Error ? err.message : String(err))
+ setError(toErrorMessage(err))
return
}
const validationError = validateOverrideValue(type, value)
diff --git a/developer-extension/src/panel/components/tabs/flagsTab/oauth.spec.ts b/developer-extension/src/panel/components/tabs/flagsTab/oauth.spec.ts
index e9e105f824..fbff50fc5c 100644
--- a/developer-extension/src/panel/components/tabs/flagsTab/oauth.spec.ts
+++ b/developer-extension/src/panel/components/tabs/flagsTab/oauth.spec.ts
@@ -5,18 +5,41 @@ import {
getValidAccessToken,
loadStoredTokens,
loginWithOAuth,
+ revokeAndClearTokens,
sha256,
storeTokens,
} from './oauth'
describe('oauth', () => {
+ // In-memory chrome.storage.session so token read/write/remove work in the karma browser.
+ function mockSessionStorage() {
+ const previousChrome = (globalThis as any).chrome
+ const store: Record = {}
+ ;(globalThis as any).chrome = {
+ storage: {
+ session: {
+ get: (key: string) => Promise.resolve({ [key]: store[key] }),
+ set: (items: Record) => {
+ Object.assign(store, items)
+ return Promise.resolve()
+ },
+ remove: (key: string) => {
+ delete store[key]
+ return Promise.resolve()
+ },
+ },
+ },
+ }
+ registerCleanupTask(async () => {
+ await clearStoredTokens()
+ ;(globalThis as any).chrome = previousChrome
+ })
+ }
+
describe('getFlagsApiHost', () => {
- it('maps each site to its frontend host (US1/EU1 → app, staging → dd, regional sites as-is)', () => {
+ it('maps each site to its frontend host (US1 → app, staging → dd)', () => {
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', () => {
@@ -36,14 +59,14 @@ describe('oauth', () => {
// 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) {
+ function mockChromeIdentity(makeRedirect: (params: { state: string }, url: string) => string) {
const previousChrome = (globalThis as any).chrome
;(globalThis as any).chrome = {
identity: {
getRedirectURL: () => 'https://ext-id.chromiumapp.org/',
launchWebAuthFlow: ({ url }: { url: string }) => {
const state = new URL(url).searchParams.get('state')!
- return Promise.resolve(makeRedirect({ state }))
+ return Promise.resolve(makeRedirect({ state }, url))
},
},
}
@@ -70,6 +93,26 @@ describe('oauth', () => {
expect(tokens.accessToken).toBe('tok')
})
+ it('sends the prod client id for a prod site and the staging client id for staging', async () => {
+ const clientIdByHost: Record = {}
+ mockChromeIdentity(({ state }, url) => {
+ const requestUrl = new URL(url)
+ clientIdByHost[requestUrl.hostname] = requestUrl.searchParams.get('client_id')
+ // Omit `domain` so the flow proceeds to the (stubbed) token exchange.
+ return `https://ext-id.chromiumapp.org/?code=abc&state=${state}`
+ })
+ // Fresh Response per call — two logins each read the token-exchange body once.
+ spyOn(globalThis, 'fetch').and.callFake(() =>
+ Promise.resolve(new Response(JSON.stringify({ access_token: 'tok', expires_in: 3600 })))
+ )
+
+ await loginWithOAuth('datadoghq.com') // US1 (prod)
+ await loginWithOAuth('datad0g.com') // staging
+
+ expect(clientIdByHost['app.datadoghq.com']).toBe('2c19b57d-118a-4f52-bcfb-709503a68290')
+ expect(clientIdByHost['dd.datad0g.com']).toBe('13c94d15-067d-4263-a309-be4811141419')
+ })
+
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(
@@ -79,34 +122,114 @@ describe('oauth', () => {
const tokens = await loginWithOAuth('datad0g.com')
expect(tokens.accessToken).toBe('tok')
})
- })
- describe('getValidAccessToken', () => {
- beforeEach(() => {
- // In-memory chrome.storage.session so token read/write/remove work in the karma browser.
+ it('requests only the feature-flag scopes', async () => {
+ const requestedScopes: string[] = []
+ mockChromeIdentity(({ state }, url) => {
+ requestedScopes.push(new URL(url).searchParams.get('scope')!)
+ return `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 })))
+ )
+
+ await loginWithOAuth('datad0g.com')
+ expect(requestedScopes.length).toBe(1)
+ expect(requestedScopes[0].split(' ')).toEqual([
+ 'feature_flag_config_read',
+ 'feature_flag_environment_config_read',
+ ])
+ })
+
+ it('surfaces a popup failure without a second attempt', async () => {
+ let attempts = 0
const previousChrome = (globalThis as any).chrome
- const store: Record = {}
;(globalThis as any).chrome = {
- storage: {
- session: {
- get: (key: string) => Promise.resolve({ [key]: store[key] }),
- set: (items: Record) => {
- Object.assign(store, items)
- return Promise.resolve()
- },
- remove: (key: string) => {
- delete store[key]
- return Promise.resolve()
- },
+ identity: {
+ getRedirectURL: () => 'https://ext-id.chromiumapp.org/',
+ launchWebAuthFlow: () => {
+ attempts += 1
+ return Promise.reject(new Error('The user did not approve access.'))
},
},
}
- registerCleanupTask(async () => {
- await clearStoredTokens()
+ registerCleanupTask(() => {
;(globalThis as any).chrome = previousChrome
})
+
+ await expectAsync(loginWithOAuth('datad0g.com')).toBeRejectedWithError(/did not approve/)
+ expect(attempts).toBe(1)
})
+ it('surfaces an authorization error from the redirect without a second attempt', async () => {
+ let attempts = 0
+ mockChromeIdentity(({ state }) => {
+ attempts += 1
+ return `https://ext-id.chromiumapp.org/?error=access_denied&state=${state}`
+ })
+
+ await expectAsync(loginWithOAuth('datad0g.com')).toBeRejectedWithError(/access_denied/)
+ expect(attempts).toBe(1)
+ })
+ })
+
+ describe('revokeAndClearTokens', () => {
+ beforeEach(mockSessionStorage)
+
+ it('revokes the refresh token and clears local tokens', async () => {
+ await storeTokens({ accessToken: 'a1', refreshToken: 'r1', expiresAt: Date.now() + 10 * 60_000 })
+ const fetchSpy = spyOn(globalThis, 'fetch').and.returnValue(Promise.resolve(new Response('', { status: 200 })))
+
+ expect(await revokeAndClearTokens('datad0g.com')).toEqual({ revoked: true })
+ expect(await loadStoredTokens()).toBeNull()
+
+ const [url, init] = fetchSpy.calls.argsFor(0) as [string, RequestInit]
+ expect(url).toBe('https://dd.datad0g.com/oauth2/v1/revoke')
+ expect((init.headers as Record).Authorization).toBe('Bearer a1')
+ const body = new URLSearchParams(init.body as string)
+ // Revoking the refresh token cascades to the access tokens minted from it; revoking only the
+ // access token would leave the grant renewable.
+ expect(body.get('token')).toBe('r1')
+ expect(body.get('token_type_hint')).toBe('refresh_token')
+ })
+
+ it('revokes the access token when there is no refresh token', async () => {
+ await storeTokens({ accessToken: 'a1', expiresAt: Date.now() + 10 * 60_000 })
+ const fetchSpy = spyOn(globalThis, 'fetch').and.returnValue(Promise.resolve(new Response('', { status: 200 })))
+
+ expect(await revokeAndClearTokens('datad0g.com')).toEqual({ revoked: true })
+ const body = new URLSearchParams((fetchSpy.calls.argsFor(0)[1] as RequestInit).body as string)
+ expect(body.get('token')).toBe('a1')
+ expect(body.get('token_type_hint')).toBe('access_token')
+ })
+
+ it('still clears local tokens when the revocation is refused', async () => {
+ await storeTokens({ accessToken: 'a1', refreshToken: 'r1', expiresAt: Date.now() + 10 * 60_000 })
+ spyOn(globalThis, 'fetch').and.returnValue(Promise.resolve(new Response('', { status: 400 })))
+
+ expect(await revokeAndClearTokens('datad0g.com')).toEqual({ revoked: false })
+ expect(await loadStoredTokens()).toBeNull()
+ })
+
+ it('still clears local tokens when the network fails', async () => {
+ await storeTokens({ accessToken: 'a1', refreshToken: 'r1', expiresAt: Date.now() + 10 * 60_000 })
+ spyOn(globalThis, 'fetch').and.returnValue(Promise.reject(new TypeError('Failed to fetch')))
+
+ expect(await revokeAndClearTokens('datad0g.com')).toEqual({ revoked: false })
+ expect(await loadStoredTokens()).toBeNull()
+ })
+
+ it('reports success without a request when there is nothing left to revoke', async () => {
+ const fetchSpy = spyOn(globalThis, 'fetch')
+
+ expect(await revokeAndClearTokens('datad0g.com')).toEqual({ revoked: true })
+ expect(fetchSpy).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('getValidAccessToken', () => {
+ beforeEach(mockSessionStorage)
+
it('returns null when nothing is stored', async () => {
expect(await getValidAccessToken('datad0g.com')).toBeNull()
})
diff --git a/developer-extension/src/panel/components/tabs/flagsTab/oauth.ts b/developer-extension/src/panel/components/tabs/flagsTab/oauth.ts
index 64f1229974..5fa3b9abab 100644
--- a/developer-extension/src/panel/components/tabs/flagsTab/oauth.ts
+++ b/developer-extension/src/panel/components/tabs/flagsTab/oauth.ts
@@ -3,12 +3,21 @@
//
// The client is a PUBLIC client (no secret), so PKCE is the only client proof. Tokens live in
// chrome.storage.session (cleared when the browser session ends) — never persisted to disk.
-// See FFL-2596 / the OAuth CLI client `13c94d15-067d-4263-a309-be4811141419` (staging).
+// See FFL-2596. Staging and prod use separate registered OAuth clients — see getClientId.
import { mockable } from '../../../../../../packages/browser-core/src/tools/mockable'
-const CLIENT_ID = '13c94d15-067d-4263-a309-be4811141419'
-const SCOPES = ['feature_flag_config_read', 'feature_flag_environment_config_read']
+// Staging (datad0g.com) and prod have separate registered OAuth clients. The prod client is
+// replicated across all prod DCs, so every non-staging site shares it. Both are public PKCE clients.
+const STAGING_CLIENT_ID = '13c94d15-067d-4263-a309-be4811141419'
+const PROD_CLIENT_ID = '2c19b57d-118a-4f52-bcfb-709503a68290'
+
+function getClientId(site: string): string {
+ return site === 'datad0g.com' ? STAGING_CLIENT_ID : PROD_CLIENT_ID
+}
+// The only scopes we request. GET /api/v2/team needs no scope — team access is gated on the user's
+// Datadog permissions, not the token's scopes — so the "My teams" filter works without teams_read.
+const REQUIRED_SCOPES = ['feature_flag_config_read', 'feature_flag_environment_config_read']
const TOKENS_STORAGE_KEY = 'flagsOAuthTokens'
// Refresh a bit before the token actually expires to avoid racing the clock on a slow request.
const EXPIRY_SKEW_MS = 60_000
@@ -35,20 +44,13 @@ export interface FlagSite {
label: string
}
-// The Datadog sites the Flags tab can connect to, each paired with the frontend host that serves
-// its OAuth endpoints and FFE API. The site is chosen from this fixed list (a Select in the UI), so
-// there's no free-text host to validate against phishing — the value is always one of these. Host
-// subdomains mirror the canonical builder in browser-rum-core's getSessionReplayUrl.ts: US1 and EU1
-// get `app.`, staging gets `dd.`, and the remaining sites are already their own host.
+// The Datadog sites the Flags tab can connect to, each paired with the frontend host that serves its
+// OAuth endpoints and FFE API. Chosen from this fixed list (a Select in the UI) so there's no
+// free-text host to validate against phishing — the value is always one of these. Trimmed to US1 +
+// Staging for now; the prod OAuth client is still replicating to the other DCs, so add them back here
+// (with the same subdomain scheme — US1/EU1 `app.`, staging `dd.`, regional sites as-is) once it's live.
export const FLAG_SITES: FlagSite[] = [
{ site: 'datadoghq.com', host: 'app.datadoghq.com', label: 'US1 (datadoghq.com)' },
- { site: 'us3.datadoghq.com', host: 'us3.datadoghq.com', label: 'US3 (us3.datadoghq.com)' },
- { site: 'us5.datadoghq.com', host: 'us5.datadoghq.com', label: 'US5 (us5.datadoghq.com)' },
- { site: 'datadoghq.eu', host: 'app.datadoghq.eu', label: 'EU1 (datadoghq.eu)' },
- { site: 'ap1.datadoghq.com', host: 'ap1.datadoghq.com', label: 'AP1 (ap1.datadoghq.com)' },
- { site: 'ap2.datadoghq.com', host: 'ap2.datadoghq.com', label: 'AP2 (ap2.datadoghq.com)' },
- { site: 'ddog-gov.com', host: 'ddog-gov.com', label: 'US1-FED (ddog-gov.com)' },
- { site: 'us2.ddog-gov.com', host: 'us2.ddog-gov.com', label: 'US2-FED (us2.ddog-gov.com)' },
{ site: 'datad0g.com', host: 'dd.datad0g.com', label: 'Staging (datad0g.com)' },
]
@@ -135,7 +137,11 @@ async function requestToken(host: string, body: URLSearchParams, fallbackRefresh
* Runs the interactive OAuth flow: opens Datadog's login/consent screen, then exchanges the
* returned authorization code for tokens. Returns the tokens (caller is responsible for storing).
*/
-export async function loginWithOAuth(site: string): Promise {
+export function loginWithOAuth(site: string): Promise {
+ return authorize(site, REQUIRED_SCOPES)
+}
+
+async function authorize(site: string, scopes: string[]): Promise {
const host = getFlagsApiHost(site)
const redirectUri = chrome.identity.getRedirectURL()
const { verifier, challenge } = await generatePkce()
@@ -143,9 +149,9 @@ export async function loginWithOAuth(site: string): Promise {
const authUrl = new URL(`https://${host}/oauth2/v1/authorize`)
authUrl.searchParams.set('response_type', 'code')
- authUrl.searchParams.set('client_id', CLIENT_ID)
+ authUrl.searchParams.set('client_id', getClientId(site))
authUrl.searchParams.set('redirect_uri', redirectUri)
- authUrl.searchParams.set('scope', SCOPES.join(' '))
+ authUrl.searchParams.set('scope', scopes.join(' '))
authUrl.searchParams.set('code_challenge', challenge)
authUrl.searchParams.set('code_challenge_method', 'S256')
authUrl.searchParams.set('state', state)
@@ -159,9 +165,15 @@ export async function loginWithOAuth(site: string): Promise {
}
const returned = new URL(redirectResponse)
+ // Validate the CSRF state first — before acting on ANY other callback param, including `error` —
+ // so a forged or mismatched callback can't drive our error handling with attacker-controlled params.
+ if (returned.searchParams.get('state') !== state) {
+ throw new Error('State mismatch — aborting for safety')
+ }
const errorParam = returned.searchParams.get('error')
if (errorParam) {
- throw new Error(`Authorization failed: ${returned.searchParams.get('error_description') ?? errorParam}`)
+ const description = returned.searchParams.get('error_description') ?? errorParam
+ throw new Error(`Authorization failed: ${description}`)
}
// Datadog appends `domain` to the redirect, naming the site the user actually authenticated
// against (bare site form, e.g. "datad0g.com"). `site` is our source of truth for every host we
@@ -171,9 +183,6 @@ export async function loginWithOAuth(site: string): Promise {
if (returnedDomain && returnedDomain.toLowerCase() !== site) {
throw new Error(`Authenticated against "${returnedDomain}" but "${site}" was selected — aborting login`)
}
- if (returned.searchParams.get('state') !== state) {
- throw new Error('State mismatch — aborting for safety')
- }
const code = returned.searchParams.get('code')
if (!code) {
throw new Error('No authorization code returned')
@@ -185,7 +194,7 @@ export async function loginWithOAuth(site: string): Promise {
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
- client_id: CLIENT_ID,
+ client_id: getClientId(site),
code_verifier: verifier,
})
)
@@ -197,7 +206,7 @@ function refreshTokens(site: string, refreshToken: string): Promise
new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
- client_id: CLIENT_ID,
+ client_id: getClientId(site),
}),
refreshToken
)
@@ -216,6 +225,59 @@ export async function clearStoredTokens(): Promise {
await chrome.storage.session.remove(TOKENS_STORAGE_KEY)
}
+/**
+ * Ends the connection: revokes the grant at Datadog (RFC 7009), then drops the local tokens.
+ *
+ * Clearing local storage alone would only make this extension forget the tokens — the grant itself
+ * would stay live until it expired, and any copy of the refresh token would keep working. Revoking
+ * the refresh token kills the renewable part of the grant; the short-lived access token (also dropped
+ * locally here) is left to expire on its own — RFC 7009 only *recommends*, not requires, that
+ * revoking a token invalidate related ones, so we don't rely on immediate cascade.
+ *
+ * Returns whether the revocation succeeded. Local tokens are cleared either way: a user who asked to
+ * disconnect must end up disconnected here even if Datadog can't be reached, so a failure is reported
+ * as "the grant may still be active" rather than by leaving them signed in. Only a failure to clear
+ * the local tokens rejects — that one has to reach the caller, since the panel would otherwise report
+ * a disconnection that a reopened panel would immediately contradict.
+ */
+export async function revokeAndClearTokens(site: string): Promise<{ revoked: boolean }> {
+ const revoked = await tryRevokeGrant(site)
+ await clearStoredTokens()
+ return { revoked }
+}
+
+async function tryRevokeGrant(site: string): Promise {
+ try {
+ // The revoke endpoint authenticates the caller with a valid access token, so refresh first if the
+ // stored one has aged out. Revoke the refresh token when we have one — that kills the renewable
+ // part of the grant (revoking only the access token would leave it renewable); the access token
+ // itself is short-lived and also cleared locally.
+ const accessToken = await getValidAccessToken(site)
+ const tokens = await loadStoredTokens()
+ if (!accessToken || !tokens) {
+ // Nothing usable left to revoke — getValidAccessToken already cleared a dead session.
+ return true
+ }
+
+ const response = await fetch(`https://${getFlagsApiHost(site)}/oauth2/v1/revoke`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ Authorization: `Bearer ${accessToken}`,
+ },
+ body: new URLSearchParams({
+ token: tokens.refreshToken ?? tokens.accessToken,
+ token_type_hint: tokens.refreshToken ? 'refresh_token' : 'access_token',
+ client_id: getClientId(site),
+ }).toString(),
+ })
+ return response.ok
+ } catch {
+ // Network failure, or a refresh that couldn't complete: nothing more we can do server-side.
+ return false
+ }
+}
+
/**
* Whether a stored token represents a live connection: still valid, or still refreshable (the
* refresh itself happens lazily at fetch time). An expired token with no refresh token is dead.
diff --git a/developer-extension/src/panel/components/tabs/flagsTab/useFlagAuth.ts b/developer-extension/src/panel/components/tabs/flagsTab/useFlagAuth.ts
index 603d471e3d..ed14a7489d 100644
--- a/developer-extension/src/panel/components/tabs/flagsTab/useFlagAuth.ts
+++ b/developer-extension/src/panel/components/tabs/flagsTab/useFlagAuth.ts
@@ -1,14 +1,18 @@
import { useCallback, useEffect, useState } from 'react'
import { createLogger } from '../../../../common/logger'
+import { toErrorMessage } from '../../../../common/toErrorMessage'
import { useSettings } from '../../../hooks/useSettings'
-import { clearStoredTokens, isTokenUsable, loadStoredTokens, loginWithOAuth, storeTokens } from './oauth'
+import { isTokenUsable, loadStoredTokens, loginWithOAuth, revokeAndClearTokens, storeTokens } from './oauth'
const logger = createLogger('useFlagAuth')
export interface FlagAuthState {
isConnected: boolean
connecting: boolean
+ disconnecting: boolean
error: string | null
+ /** Set when disconnecting locally succeeded but revoking the grant at Datadog did not. */
+ warning: string | null
site: string
connect: () => void
disconnect: () => void
@@ -23,7 +27,9 @@ export function useFlagAuth(): FlagAuthState {
const [connected, setConnected] = useState(false)
const [connecting, setConnecting] = useState(false)
+ const [disconnecting, setDisconnecting] = useState(false)
const [error, setError] = useState(null)
+ const [warning, setWarning] = useState(null)
useEffect(() => {
let cancelled = false
@@ -45,12 +51,13 @@ export function useFlagAuth(): FlagAuthState {
const connect = useCallback(() => {
setConnecting(true)
setError(null)
+ setWarning(null)
loginWithOAuth(flagsSite)
.then((tokens) => storeTokens(tokens))
.then(() => setConnected(true))
.catch((err: unknown) => {
logger.error('OAuth login failed:', err)
- setError(err instanceof Error ? err.message : String(err))
+ setError(toErrorMessage(err))
setConnected(false)
})
.finally(() => setConnecting(false))
@@ -58,21 +65,34 @@ export function useFlagAuth(): FlagAuthState {
const disconnect = useCallback(() => {
setError(null)
+ setWarning(null)
+ setDisconnecting(true)
// Only drop the connected state once the tokens are actually gone: if removal fails the
// credentials are still stored and a reopened panel would load them again, so reporting
// "disconnected" here would make the Disconnect button silently lie.
- clearStoredTokens()
- .then(() => setConnected(false))
+ revokeAndClearTokens(flagsSite)
+ .then(({ revoked }) => {
+ setConnected(false)
+ if (!revoked) {
+ // The local session is gone, so the tab is genuinely disconnected — but the grant may
+ // still be live at Datadog, which only the user can clear (Organization Settings →
+ // Authorized Applications). Say so instead of implying a clean revocation.
+ setWarning('Signed out locally, but the Datadog authorization could not be revoked.')
+ }
+ })
.catch((err: unknown) => {
logger.error('Error while clearing tokens', err)
setError('Could not disconnect — please try again.')
})
- }, [])
+ .finally(() => setDisconnecting(false))
+ }, [flagsSite])
return {
isConnected: connected,
connecting,
+ disconnecting,
error,
+ warning,
site: flagsSite,
connect,
disconnect,
diff --git a/developer-extension/src/panel/components/tabs/flagsTab/useFlagCatalog.ts b/developer-extension/src/panel/components/tabs/flagsTab/useFlagCatalog.ts
index ee9a6ffca3..28ef8bd9b2 100644
--- a/developer-extension/src/panel/components/tabs/flagsTab/useFlagCatalog.ts
+++ b/developer-extension/src/panel/components/tabs/flagsTab/useFlagCatalog.ts
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
import { createLogger } from '../../../../common/logger'
+import { toErrorMessage } from '../../../../common/toErrorMessage'
import type { CatalogFlag, FlagCatalogRequest } from './flagsRequests'
import { fetchFlagCatalog } from './flagsRequests'
import { getValidAccessToken } from './oauth'
@@ -66,7 +67,7 @@ export function useFlagCatalog(auth: FlagAuthState, request: FlagCatalogRequest)
logger.error('Error while fetching flag catalog:', err)
setFlags([])
setTotal(0)
- setError(err instanceof Error ? err.message : String(err))
+ setError(toErrorMessage(err))
}
})
.finally(() => {
diff --git a/developer-extension/src/panel/components/tabs/flagsTab/useFlagCatalogView.spec.ts b/developer-extension/src/panel/components/tabs/flagsTab/useFlagCatalogView.spec.ts
new file mode 100644
index 0000000000..2b51d93331
--- /dev/null
+++ b/developer-extension/src/panel/components/tabs/flagsTab/useFlagCatalogView.spec.ts
@@ -0,0 +1,81 @@
+import React, { act } from 'react'
+import { createRoot } from 'react-dom/client'
+import { registerCleanupTask } from '../../../../../../packages/browser-core/test'
+import type { FlagCatalogView } from './useFlagCatalogView'
+import { useFlagCatalogView } from './useFlagCatalogView'
+
+// Filtering happens server-side now (see flagsRequests.spec for the URL serialization), so these tests
+// cover what the hook itself owns: turning filter/search/pagination state into the server `request`.
+describe('useFlagCatalogView', () => {
+ // Mounts the hook in a throwaway component and exposes its latest return value.
+ function mountHook(currentUserId: string | null) {
+ const container = document.createElement('div')
+ const root = createRoot(container)
+ let latest: FlagCatalogView
+ function Probe() {
+ latest = useFlagCatalogView(currentUserId)
+ return null
+ }
+ act(() => root.render(React.createElement(Probe)))
+ registerCleanupTask(() => act(() => root.unmount()))
+ return () => latest
+ }
+
+ it('starts on page 1 with empty filters and no created_by', () => {
+ const view = mountHook(null)()
+ expect(view.request).toEqual({
+ page: 1,
+ pageSize: view.pageSize,
+ search: '',
+ typeFilter: [],
+ tagFilter: [],
+ teamFilter: [],
+ createdBy: null,
+ })
+ })
+
+ describe('"My feature flags" -> created_by', () => {
+ it('sets created_by to the signed-in user while toggled on', () => {
+ const get = mountHook('me')
+ act(() => get().setMyFlagsOnly(true))
+ expect(get().request.createdBy).toBe('me')
+ act(() => get().setMyFlagsOnly(false))
+ expect(get().request.createdBy).toBeNull()
+ })
+
+ it('contributes no created_by while the signed-in user is unknown', () => {
+ const get = mountHook(null)
+ act(() => get().setMyFlagsOnly(true))
+ // The toggle reads as on, but with no user there's nothing to filter by — so the request stays open.
+ expect(get().myFlagsOnly).toBe(true)
+ expect(get().request.createdBy).toBeNull()
+ })
+ })
+
+ it('carries selected team handles through to the request', () => {
+ const get = mountHook(null)
+ act(() => get().setTeamFilter(['alpha', 'beta']))
+ expect(get().request.teamFilter).toEqual(['alpha', 'beta'])
+ })
+
+ it('resets to the first page when a filter changes', () => {
+ const get = mountHook(null)
+ act(() => get().setPage(4))
+ expect(get().request.page).toBe(4)
+ act(() => get().setTypeFilter(['BOOLEAN']))
+ expect(get().request.page).toBe(1)
+ })
+
+ it('debounces the search term before putting it in the request', () => {
+ jasmine.clock().install()
+ registerCleanupTask(() => jasmine.clock().uninstall())
+
+ const get = mountHook(null)
+ act(() => get().setSearch('checkout'))
+ // The live value updates immediately, but the request (sent to the server) waits out the debounce.
+ expect(get().search).toBe('checkout')
+ expect(get().request.search).toBe('')
+ act(() => jasmine.clock().tick(400))
+ expect(get().request.search).toBe('checkout')
+ })
+})
diff --git a/developer-extension/src/panel/components/tabs/flagsTab/useFlagCatalogView.ts b/developer-extension/src/panel/components/tabs/flagsTab/useFlagCatalogView.ts
index 4856410249..7afc9061ee 100644
--- a/developer-extension/src/panel/components/tabs/flagsTab/useFlagCatalogView.ts
+++ b/developer-extension/src/panel/components/tabs/flagsTab/useFlagCatalogView.ts
@@ -13,6 +13,10 @@ export interface FlagCatalogView {
setTypeFilter: (value: string[]) => void
tagFilter: string[]
setTagFilter: (value: string[]) => void
+ myFlagsOnly: boolean
+ setMyFlagsOnly: (value: boolean) => void
+ teamFilter: string[]
+ setTeamFilter: (value: string[]) => void
page: number
setPage: (value: number) => void
pageSize: number
@@ -23,12 +27,18 @@ export interface FlagCatalogView {
/**
* Owns the catalog's search/filter/pagination state and turns it into a server request. Filtering
* and pagination happen server-side (see useFlagCatalog), so this holds no flag data itself.
+ *
+ * `currentUserId` backs the "My feature flags" filter (server-side `created_by`). It's null until the
+ * separate identity fetch resolves, and stays null if that fetch fails — so while the user is unknown
+ * the toggle adds no filter (the UI disables it rather than silently emptying the list).
*/
-export function useFlagCatalogView(): FlagCatalogView {
+export function useFlagCatalogView(currentUserId: string | null): FlagCatalogView {
const [search, setSearchState] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
const [typeFilter, setTypeFilterState] = useState([])
const [tagFilter, setTagFilterState] = useState([])
+ const [myFlagsOnly, setMyFlagsOnlyState] = useState(false)
+ const [teamFilter, setTeamFilterState] = useState([])
const [page, setPage] = useState(1)
// Feed the server the debounced term, not the live one.
@@ -37,28 +47,43 @@ export function useFlagCatalogView(): FlagCatalogView {
return () => clearTimeout(id)
}, [search])
+ // Identity loads on its own request, separate from connecting, so just after connect `currentUserId`
+ // can still be null — and it stays null if that request failed. So filter by creator only when the
+ // toggle is on AND we actually know the user. Deriving it here (instead of inside the memo) keeps the
+ // request unchanged while the toggle is off, so identity arriving a moment later doesn't refetch.
+ const createdBy = myFlagsOnly && currentUserId ? currentUserId : null
const request = useMemo(
- () => ({ page, pageSize: CATALOG_PAGE_SIZE, search: debouncedSearch, typeFilter, tagFilter }),
- [page, debouncedSearch, typeFilter, tagFilter]
+ () => ({
+ page,
+ pageSize: CATALOG_PAGE_SIZE,
+ search: debouncedSearch,
+ typeFilter,
+ tagFilter,
+ teamFilter,
+ createdBy,
+ }),
+ [page, debouncedSearch, typeFilter, tagFilter, teamFilter, createdBy]
)
// Any filter/search change resets to the first page so results aren't hidden on an out-of-range page.
+ const withPageReset =
+ (setState: (value: T) => void) =>
+ (value: T) => {
+ setState(value)
+ setPage(1)
+ }
+
return {
search,
- setSearch: (value) => {
- setSearchState(value)
- setPage(1)
- },
+ setSearch: withPageReset(setSearchState),
typeFilter,
- setTypeFilter: (value) => {
- setTypeFilterState(value)
- setPage(1)
- },
+ setTypeFilter: withPageReset(setTypeFilterState),
tagFilter,
- setTagFilter: (value) => {
- setTagFilterState(value)
- setPage(1)
- },
+ setTagFilter: withPageReset(setTagFilterState),
+ myFlagsOnly,
+ setMyFlagsOnly: withPageReset(setMyFlagsOnlyState),
+ teamFilter,
+ setTeamFilter: withPageReset(setTeamFilterState),
page,
setPage,
pageSize: CATALOG_PAGE_SIZE,
diff --git a/developer-extension/src/panel/components/tabs/flagsTab/useFlagIdentity.ts b/developer-extension/src/panel/components/tabs/flagsTab/useFlagIdentity.ts
new file mode 100644
index 0000000000..a005467ee5
--- /dev/null
+++ b/developer-extension/src/panel/components/tabs/flagsTab/useFlagIdentity.ts
@@ -0,0 +1,71 @@
+import { useEffect, useState } from 'react'
+import { createLogger } from '../../../../common/logger'
+import type { FlagIdentity } from './flagIdentity'
+import { fetchFlagIdentity } from './flagIdentity'
+import { getValidAccessToken } from './oauth'
+import type { FlagAuthState } from './useFlagAuth'
+
+const logger = createLogger('useFlagIdentity')
+
+const NO_IDENTITY: FlagIdentity = { userId: null, teamHandles: [], teamsForbidden: false, teamsUnavailable: false }
+
+export interface FlagIdentityState extends FlagIdentity {
+ loading: boolean
+}
+
+/**
+ * Loads the signed-in user's id + team handles for the "My feature flags"/"My teams" filters. Runs
+ * independently of the catalog, so the catalog loads fine even when identity is still pending or fails.
+ *
+ * Failures are deliberately silent — identity only powers two optional filters, so a failed lookup
+ * just leaves them disabled. It also doesn't disconnect on a bad token (the catalog fetch owns that,
+ * to avoid a double disconnect).
+ */
+export function useFlagIdentity(auth: FlagAuthState): FlagIdentityState {
+ const { isConnected, site } = auth
+
+ const [identity, setIdentity] = useState(NO_IDENTITY)
+ // Start `true` when connected so the first render (before the fetch effect runs) reads as "loading"
+ // rather than "resolved but empty" — otherwise the My-flags toggle briefly shows the unavailable state.
+ const [loading, setLoading] = useState(isConnected)
+
+ useEffect(() => {
+ if (!isConnected) {
+ setIdentity(NO_IDENTITY)
+ setLoading(false)
+ return
+ }
+
+ let cancelled = false
+ setLoading(true)
+
+ const load = async (): Promise => {
+ const token = await getValidAccessToken(site)
+ return token ? fetchFlagIdentity(token, site) : NO_IDENTITY
+ }
+
+ load()
+ .then((loaded) => {
+ if (!cancelled) {
+ setIdentity(loaded)
+ }
+ })
+ .catch((err: unknown) => {
+ if (!cancelled) {
+ logger.error('Error while fetching flag identity:', err)
+ setIdentity(NO_IDENTITY)
+ }
+ })
+ .finally(() => {
+ if (!cancelled) {
+ setLoading(false)
+ }
+ })
+
+ return () => {
+ cancelled = true
+ }
+ }, [isConnected, site])
+
+ return { ...identity, loading }
+}