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..2b630c8751 100644
--- a/developer-extension/src/panel/components/tabs/flagsTab/connectScreen.tsx
+++ b/developer-extension/src/panel/components/tabs/flagsTab/connectScreen.tsx
@@ -1,18 +1,25 @@
-import { Anchor, Badge, Button, Center, Group, Select, Stack, Text } from '@mantine/core'
-import React, { useState } from 'react'
+import { Alert, 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 { useInspectedPageOverrides } from './useInspectedPageOverrides'
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,39 +28,78 @@ 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.
+
)}
)
}
+/**
+ * Surfaces overrides already stored on the inspected page while signed out — otherwise this screen is
+ * all that renders, so an override left from an earlier session keeps affecting the page with nothing
+ * to explain it. Informational only; everything that mutates overrides lives on the connected tab.
+ *
+ * Mounted only while disconnected, so its navigation listeners never run alongside the connected
+ * tab's own instance of this hook.
+ */
+function DisconnectedOverridesNotice() {
+ const { status, overrides } = useInspectedPageOverrides()
+ const count = Object.keys(overrides).length
+
+ if (status !== 'ready' || count === 0) {
+ return null
+ }
+
+ return (
+ // Masked despite the surrounding dd-privacy-allow: only a count renders today, but flag keys are
+ // customer data, so anything added here should stay out of the extension's own Session Replay.
+
+
+ These are stored in the page and keep applying while you are signed out. Sign in to view and remove them.
+
+
+ )
+}
+
export function ConnectionHeader({ auth }: { auth: FlagAuthState }) {
return (
-
-
-
- Connected via OAuth
-
-
- {auth.site}
-
-
-
{overridden && (
@@ -142,10 +157,9 @@ function FlagRow({
) : (
flag.variants.map((variant) => {
const isActive = overridden && valuesEqual(override.value, variant.value)
- // The catalog falls back to the raw string when a variant doesn't parse as its
- // declared type (see parseVariantValue) — writing that through would violate the
- // same contract validateOverrideValue enforces for manual overrides. `allowNull` keeps
- // a legitimate JSON `null` variant applyable (a raw-string type mismatch still fails).
+ // The catalog keeps an unparseable variant as its raw string (see parseVariantValue), and
+ // writing that through would break the override type contract. `allowNull` keeps a
+ // legitimate JSON `null` variant applyable.
const validationError = validateOverrideValue(flag.type, variant.value, { allowNull: true })
return (
(null)
+
+ // Skipped while expanded: the clamp is off then, so a measurement would read as "fits" and wrongly
+ // hide the toggle. The ResizeObserver re-measures when the panel width changes.
+ 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 (
+
+
+ {description}
+
+ {overflowing && (
+ setExpanded((value) => !value)}
+ style={{ display: 'inline-block', marginTop: 0 }}
+ >
+ {expanded ? 'Show less' : 'Show more'}
+
+ )}
+
+ )
+}
+
function FlagKey({ value }: { value: string }) {
return (
@@ -179,6 +238,9 @@ function FlagKey({ value }: { value: string }) {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
+ // Negates the chip's own padding so the key lines up with the flag name above.
+ paddingInline: 6,
+ marginLeft: -6,
}}
>
{value}
@@ -186,7 +248,13 @@ function FlagKey({ value }: { value: string }) {
{({ copied, copy }) => (
-
+
diff --git a/developer-extension/src/panel/components/tabs/flagsTab/flagFilterBar.tsx b/developer-extension/src/panel/components/tabs/flagsTab/flagFilterBar.tsx
index c6295f94d8..4c73e7627b 100644
--- a/developer-extension/src/panel/components/tabs/flagsTab/flagFilterBar.tsx
+++ b/developer-extension/src/panel/components/tabs/flagsTab/flagFilterBar.tsx
@@ -1,19 +1,35 @@
-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).
+/**
+ * Every filter here is applied server-side. Tags are the exception to the "options are known" rule:
+ * there's no tags endpoint and we load a page at a time, so `tagSuggestions` only autocompletes tags
+ * seen so far while still accepting any typed tag.
+ */
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 +37,10 @@ export function FlagFilterBar() {
onChange={(event) => view.setSearch(event.currentTarget.value)}
size="xs"
/>
-
+ {/* Bottom-aligned so the toggle shares a baseline with the labelled selects. */}
+
+
+
)
}
+
+function MyFlagsSwitch({ view, identity }: { view: FlagCatalogView; identity: FlagIdentityState }) {
+ // Without a user id the created_by filter can only ever empty the list, so disable it.
+ const unavailable = !identity.loading && !identity.userId
+
+ return (
+
+ {/* Reads as a filter chip matching the Type/Tags boxes, and lets the tooltip fire while the
+ Switch itself is disabled. */}
+
+ view.setMyFlagsOnly(event.currentTarget.checked)}
+ />
+
+
+ )
+}
+
+// Matches the web UI's team filter: its default page of 10, plus a small buffer.
+const TEAM_SEARCH_THRESHOLD = 12
+
+/**
+ * A checkbox dropdown rather than a chip multiselect, so the closed control stays a compact "N teams
+ * selected" summary instead of growing tall in the narrow panel.
+ */
+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 on open, so toggling a team doesn't make it jump under the
+ // cursor. Recomputed on each open.
+ const [ordered, setOrdered] = useState([])
+
+ const disabled = identity.teamHandles.length === 0
+
+ // Explains a disabled control, but stays silent while 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()
+ }
+
+ const list = ordered.length > 0 ? ordered : identity.teamHandles
+ const searchable = identity.teamHandles.length > TEAM_SEARCH_THRESHOLD
+ const query = search.trim().toLowerCase()
+ const options = list
+ // Selected teams survive the search filter, so a selection never disappears under the user.
+ .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..03970bf028
--- /dev/null
+++ b/developer-extension/src/panel/components/tabs/flagsTab/flagIdentity.ts
@@ -0,0 +1,83 @@
+// Fetches who the signed-in user is, so the catalog can offer the webapp's two identity-scoped
+// filters: "My feature flags" (creator is the signed-in user) and "My teams" (flags tagged
+// `team:`). The feature-flag API has no notion of "me" — it filters by creator UUID and by
+// tag, both of which the caller must supply — so these come from Datadog's org endpoints instead.
+//
+// Neither endpoint needs a token scope beyond the ones we already request: current_user is OPEN(),
+// and team access is gated on the user's own Datadog permissions.
+
+import { fetchFfeJson, ForbiddenError } from './ffeApi'
+import { getFlagsApiHost } from './oauth'
+
+export interface FlagIdentity {
+ userId: string | null
+ teamHandles: string[]
+ teamsForbidden: boolean
+ teamsUnavailable: boolean
+}
+
+interface RawCurrentUserResponse {
+ data?: { id?: string }
+}
+
+interface RawTeamResponse {
+ data?: Array<{ attributes?: { handle?: string } }>
+}
+
+/**
+ * Returns the signed-in user's UUID (the same value the flag API returns as `created_by`), 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 endpoint caps `page[size]` at 100; membership in more than 100 teams is unrealistic, so one
+// page covers every real case.
+const TEAM_PAGE_SIZE = 100
+
+/**
+ * Returns the handles of the teams the signed-in user belongs to. Requests only the handle field —
+ * it's all the `team:` tag match needs, and it keeps other team metadata out of the
+ * extension. Throws a ForbiddenError (surfaced as `teamsForbidden`) when the user can't read teams.
+ */
+export async function fetchMyTeamHandles(token: string, site: string): Promise {
+ const params = new URLSearchParams({
+ 'filter[me]': 'true',
+ '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/flagTypes.ts b/developer-extension/src/panel/components/tabs/flagsTab/flagTypes.ts
index 191ecabda2..7611ea848c 100644
--- a/developer-extension/src/panel/components/tabs/flagsTab/flagTypes.ts
+++ b/developer-extension/src/panel/components/tabs/flagsTab/flagTypes.ts
@@ -1,27 +1,23 @@
// Shared flag and override types plus the per-type value logic (labels, parsing, validation) they
-// drive. One home for everything that is specific to a flag's value type, imported by both the
+// drive. One home for everything specific to a flag's value type, imported by both the
// request/catalog layer and the inspected-page override layer.
-// Feature-flag value types, in display order (drives the catalog's Type filter).
+/** Feature-flag value types, in display order (drives the catalog's Type filter). */
export const FLAG_TYPES = ['BOOLEAN', 'STRING', 'INTEGER', 'NUMERIC', 'JSON'] as const
-// The value type of a feature flag (and of an override for it), derived from FLAG_TYPES so the list
-// stays the single source of truth.
+/** The value type of a feature flag, and of an override for it. */
export type FlagType = (typeof FLAG_TYPES)[number]
interface FlagTypeConfig {
- // Display label matching the webapp's Type filter (NUMERIC shows as "Number").
+ /** Display label matching the webapp's Type filter (NUMERIC shows as "Number"). */
label: string
- // The JS `typeof` an override of this type must have (the DatadogDevtools wrapper would otherwise
- // throw at resolve time); used by validateOverrideValue.
+ /** The JS `typeof` an override must have, or the DatadogDevtools wrapper throws at resolve time. */
expectedJsType: 'boolean' | 'string' | 'number' | 'object'
- // Error copy shown when the manual form fails to parse input of this type. BOOLEAN never fails
- // (its Switch yields a real boolean), so its message is unreachable but kept for completeness.
+ /** Error copy for the manual form. BOOLEAN's is unreachable (its Switch yields a real boolean). */
parseErrorMessage: string
}
-// Per-type display + validation metadata in one descriptor: FLAG_TYPES stays the ordered list, while
-// this is the single exhaustive source of everything specific to each type.
+/** Everything specific to each type; FLAG_TYPES stays the ordered list. */
export const FLAG_TYPE_CONFIG = {
BOOLEAN: { label: 'Boolean', expectedJsType: 'boolean', parseErrorMessage: 'Enter true or false' },
STRING: { label: 'String', expectedJsType: 'string', parseErrorMessage: 'Enter a value' },
@@ -36,23 +32,23 @@ export const FLAG_TYPE_CONFIG = {
export type TypedParseResult = { ok: true; value: unknown } | { ok: false }
-// Structural parsing rules for a flag value string, shared between the catalog (API variant values,
-// always strings, tolerant of malformed input) and the manual override form (user input, rejects
-// malformed input). BOOLEAN is excluded: the API sends it as the strings 'true'/'false' while the
-// form already gets a JS boolean from its Switch control, so there's no shared string-parsing rule
-// for it. Callers decide what an `{ ok: false }` result means for them (fall back vs. reject).
+/**
+ * Parses a flag value string, shared between the catalog (API variant values, which tolerate
+ * malformed input) and the manual override form (which rejects it) — callers decide what
+ * `{ ok: false }` means for them. BOOLEAN is excluded: the API sends 'true'/'false' strings while
+ * the form's Switch already yields a JS boolean, so there's no shared rule for it.
+ */
export function parseTypedString(type: Exclude, raw: string): TypedParseResult {
switch (type) {
case 'INTEGER': {
- // Require the whole (trimmed) string to be an integer within the safe range, so a value like
- // "5abc" or 9007199254740993 isn't silently rounded or truncated.
+ // The whole trimmed string must be a safe integer, so "5abc" or 9007199254740993 isn't
+ // silently truncated or rounded.
const trimmed = raw.trim()
const parsed = Number(trimmed)
return /^[+-]?\d+$/.test(trimmed) && Number.isSafeInteger(parsed) ? { ok: true, value: parsed } : { ok: false }
}
case 'NUMERIC': {
- // Require a non-empty (trimmed) string that parses fully to a finite number — Number('') is 0
- // and Number(' ') is also 0, so an all-whitespace input must not be treated as valid.
+ // Reject empty/whitespace explicitly — Number('') and Number(' ') are both 0.
const trimmed = raw.trim()
const parsed = Number(trimmed)
return trimmed !== '' && Number.isFinite(parsed) ? { ok: true, value: parsed } : { ok: false }
@@ -70,13 +66,12 @@ export function parseTypedString(type: Exclude, raw: string
/**
* Validates an already-parsed override value against its declared type, returning an error message
- * or null if valid. This is the value-level counterpart to parseTypedString (which turns a string
- * into a value): the catalog's variant-click path validates the catalog value directly, and the
- * manual form validates after parseTypedString produces a value.
+ * or null. The value-level counterpart to parseTypedString: the catalog's variant-click path
+ * validates its value directly, and the manual form validates what parseTypedString produced.
*
- * `allowNull` accepts `null` (a valid JSON value that real flag variants can use) — the catalog
- * passes it so a JSON `null` variant stays applyable; the manual-entry form leaves it off so a
- * hand-typed empty value is still rejected. Either way a non-JSON `null` still fails the type check.
+ * `allowNull` accepts `null`, a valid JSON value real variants can use — the catalog passes it so a
+ * JSON `null` variant stays applyable, the form leaves it off so an empty value is rejected. A
+ * non-JSON `null` still fails the type check either way.
*/
export function validateOverrideValue(
type: FlagType,
@@ -86,10 +81,10 @@ export function validateOverrideValue(
if (value === null && !allowNull) {
return 'Value cannot be null'
}
+ // The API can return a value_type outside our union (a compile-time assumption, not a runtime
+ // guarantee — see parseVariantValue), so reject rather than crash on a missing descriptor.
const config: FlagTypeConfig | undefined = FLAG_TYPE_CONFIG[type]
if (!config) {
- // The catalog API can return a value_type outside our union (a compile-time assumption, not a
- // runtime guarantee — see parseVariantValue); reject rather than crash on a missing descriptor.
return `Unsupported flag type: ${type}`
}
if (typeof value !== config.expectedJsType) {
@@ -101,9 +96,7 @@ export function validateOverrideValue(
return null
}
-// The display label for a flag type, tolerant of a value_type the API added that we don't model yet:
-// falls back to the raw type rather than dereferencing a missing descriptor (same graceful handling
-// as validateOverrideValue).
+/** Display label for a flag type, falling back to the raw type for one we don't model yet. */
export function flagTypeLabel(type: FlagType): string {
const config: FlagTypeConfig | undefined = FLAG_TYPE_CONFIG[type]
return config ? config.label : type
diff --git a/developer-extension/src/panel/components/tabs/flagsTab/flagsContext.tsx b/developer-extension/src/panel/components/tabs/flagsTab/flagsContext.tsx
index 0a0ad132c3..8797854ad0 100644
--- a/developer-extension/src/panel/components/tabs/flagsTab/flagsContext.tsx
+++ b/developer-extension/src/panel/components/tabs/flagsTab/flagsContext.tsx
@@ -1,29 +1,30 @@
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'
-// The connected Flags tab's state + actions, shared with every component below the provider so the
-// tab and its components render state and invoke actions without prop-drilling.
+/** The connected Flags tab's state + actions, shared below the provider to avoid 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
overrideError: string | null
overrides: FlagOverrides
devtoolsEnabled: boolean
- // The overridden flags' catalog data (pinned "Local overrides" section) and the current page minus
- // those (so they don't render twice).
+ /** Overridden flags (pinned "Local overrides" section) and the current page minus those. */
overriddenFlags: CatalogFlag[]
bottomFlags: CatalogFlag[]
tagSuggestions: string[]
totalPages: number
- // Whether a refresh is needed/in flight to (re)apply overrides, and the last mutation failure.
+ /** Whether a refresh is needed/in flight to (re)apply overrides, and the last mutation failure. */
pendingReload: boolean
writesInFlight: number
mutationError: string | null
@@ -45,11 +46,14 @@ export function useFlagsContext(): FlagsContextValue {
/**
* Owns the connected Flags tab's orchestration: loads the catalog, tracks inspected-page overrides,
- * resolves the overridden-flag metadata, and exposes the apply/revert/clear/reload actions. The tab
- * and its components consume this via useFlagsContext and stay focused on rendering.
+ * resolves the overridden-flag metadata, and exposes the apply/revert/clear/reload actions, so the
+ * components below stay focused on rendering.
*/
export function FlagsProvider({ auth, children }: { auth: FlagAuthState; children: ReactNode }) {
- const view = useFlagCatalogView()
+ // Resolves first: the catalog view needs the user's UUID for "My feature flags", 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 } =
@@ -65,9 +69,8 @@ export function FlagsProvider({ auth, children }: { auth: FlagAuthState; childre
}
}, [view.page, totalPages, setPage])
- // "Local overrides" is its own always-visible section above the paginated list. Fetch each
- // overridden flag by key so it shows regardless of which catalog page it's on, then fall back to a
- // minimal row for any key that no longer resolves to a flag (so it can still be reverted).
+ // Fetched by key so an overridden flag shows regardless of which catalog page it's on, with a
+ // minimal row as fallback for a key that no longer resolves (so it can still be reverted).
const overrideKeys = useMemo(() => Object.keys(overrides), [overrides])
const overriddenCatalogFlags = useOverriddenFlags(auth, overrideKeys)
const overriddenFlags = useMemo(
@@ -77,6 +80,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: [],
@@ -84,15 +88,14 @@ export function FlagsProvider({ auth, children }: { auth: FlagAuthState; childre
),
[overrideKeys, overriddenCatalogFlags, overrides]
)
- // Drop overridden flags from the paginated list so they don't show twice (they're in the top section).
+ // Dropped from the paginated list so they don't show twice.
const bottomFlags = useMemo(
() => catalog.flags.filter((flag) => !getOverride(overrides, flag.key)),
[catalog.flags, overrides]
)
- // Progressive tag suggestions: there's no tags endpoint and we only load a page at a time, so the
- // Tag filter's autocomplete is built from the tags seen on pages loaded so far. `team:*` tags are
- // excluded (they'd drive a separate team filter); users can still type any tag not yet seen.
+ // No tags endpoint exists, so autocomplete accumulates the tags seen on pages loaded so far.
+ // `team:*` tags are excluded — they drive the separate team filter.
const [tagSuggestions, setTagSuggestions] = useState([])
useEffect(() => {
setTagSuggestions((previous) => {
@@ -113,20 +116,18 @@ export function FlagsProvider({ auth, children }: { auth: FlagAuthState; childre
const [writesInFlight, setWritesInFlight] = useState(0)
const [mutationError, setMutationError] = useState(null)
- // Each override write is an async read-modify-write to the inspected page's localStorage. Track how
- // many are in flight so the reload button stays disabled until they settle — reloading earlier would
- // boot the DatadogDevtools wrapper with stale overrides that hadn't been written yet.
+ // Tracks in-flight writes so the reload button stays disabled until they settle — reloading earlier
+ // would boot the wrapper with overrides that hadn't been written yet.
const runMutation = useCallback((write: Promise) => {
setMutationError(null)
setWritesInFlight((count) => count + 1)
write
.then(() => {
setPendingReload(true)
- // Clear a failure from an earlier queued write that this later one supersedes — mutations are
- // serialized, so a success here means the current stored state is good.
+ // Mutations are serialized, so a success here supersedes an earlier queued write's failure.
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 +148,7 @@ export function FlagsProvider({ auth, children }: { auth: FlagAuthState; childre
const value = useMemo(
() => ({
view,
+ identity,
catalog,
overrideStatus: status,
overrideError: error,
@@ -166,6 +168,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..729dd92a54 100644
--- a/developer-extension/src/panel/components/tabs/flagsTab/flagsRequests.ts
+++ b/developer-extension/src/panel/components/tabs/flagsTab/flagsRequests.ts
@@ -1,27 +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
+ description: string
type: FlagType
- // Parsed value of each variant (any JSON value); see parseVariantValue.
+ /** Parsed value of each variant (any JSON value); see parseVariantValue. */
variants: Array<{ name: string; value: unknown }>
tags: string[]
+ /** Undefined for flags created by a service account or integration, which carry no user UUID. */
+ 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.
+/**
+ * Filters + pagination sent to the server so the FFE endpoint does the work — the extension never
+ * loads the whole catalog. The server applies all of these itself: `search` matches name/key/tags,
+ * `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.
+ *
+ * Filtering server-side is required, not an optimization: we load one page at a time, so a
+ * client-side filter would only ever see the current page.
+ */
export interface FlagCatalogRequest {
page: number
pageSize: number
search: string
typeFilter: string[]
tagFilter: string[]
+ teamFilter: string[]
+ createdBy: string | null
}
-// One page of results plus the server's total count (for pagination).
+/** One page of results plus the server's total count (for pagination). */
export interface FlagCatalogPage {
flags: CatalogFlag[]
total: number
@@ -31,9 +43,11 @@ interface RawFeatureFlag {
attributes: {
key: string
name?: string
+ description?: string
value_type: FlagType
variants?: Array<{ name: string; value: string }>
tags?: string[]
+ created_by?: string
}
}
@@ -42,13 +56,16 @@ interface RawFeatureFlagsResponse {
meta?: { page?: { total?: number } }
}
-// Variant values come back from the API as strings regardless of the flag's declared type. Falls
-// back to the raw string on unparseable input so one malformed variant can't blow up the mapping
-// of the entire catalog.
+/**
+ * Variant values come back as strings regardless of the flag's declared type. Tolerant by design:
+ * anything unparseable is kept as its raw string so one malformed variant can't blow up the mapping
+ * of the entire catalog. That includes an unknown `value_type` (a compile-time assumption, not a
+ * runtime guarantee), which must not reach parseTypedString — its switch has no default.
+ */
function parseVariantValue(type: FlagType, rawValue: string): unknown {
if (type === 'BOOLEAN') {
- // Only the exact strings count; anything else (e.g. "True", "falsex", "") is malformed and
- // kept raw rather than silently collapsing to false.
+ // Only the exact strings count; anything else ("True", "falsex", "") is malformed and kept raw
+ // rather than silently collapsing to false.
if (rawValue === 'true') {
return true
}
@@ -58,13 +75,8 @@ function parseVariantValue(type: FlagType, rawValue: string): unknown {
return rawValue
}
if (!FLAG_TYPES.includes(type)) {
- // The server returned a value_type outside the known union (which is a compile-time
- // assumption, not a runtime guarantee) — keep the raw string rather than passing it to
- // parseTypedString (whose switch has no default and would return undefined), consistent with
- // never letting one odd variant blow up the mapping.
return rawValue
}
- // The catalog is tolerant: a variant that doesn't parse is kept as its raw string.
const result = parseTypedString(type, rawValue)
return result.ok ? result.value : rawValue
}
@@ -79,8 +91,8 @@ export function fetchFlagCatalog(token: string, site: string, request: FlagCatal
const url = new URL(`https://${getFlagsApiHost(site)}/api/ui/ffe/feature-flags`)
url.searchParams.set('page[limit]', String(request.pageSize))
url.searchParams.set('page[offset]', String((request.page - 1) * request.pageSize))
- // Active flags only: with archived included, an archived and an active flag can share a key and
- // land on the same page, which would render as duplicate rows and collide React keys.
+ // Active only: an archived and an active flag can share a key and land on the same page, which
+ // would render as duplicate rows and collide React keys.
url.searchParams.set('is_archived', 'false')
if (request.search) {
url.searchParams.set('search', request.search)
@@ -91,22 +103,28 @@ export function fetchFlagCatalog(token: string, site: string, request: FlagCatal
for (const tag of request.tagFilter) {
url.searchParams.append('tags', tag)
}
+ for (const handle of request.teamFilter) {
+ url.searchParams.append('tags', `team:${handle}`)
+ }
+ if (request.createdBy) {
+ url.searchParams.set('created_by', request.createdBy)
+ }
return fetchFlagPage(url, token, 'Failed to fetch flag catalog')
}
/**
* Fetches a specific set of flags by exact key, one request per key (the endpoint's `key` filter is
- * exact and single-valued — there's no batched lookup). Used for the "Local overrides" section, which
- * must show overridden flags even when they're not on the current catalog page. Keys with no match
- * (deleted, or a hand-entered override for a non-existent flag) are simply absent from the result.
+ * exact and single-valued — there's no batched lookup). Used for the "Local overrides" section,
+ * which must show overridden flags even when they're not on the current catalog page.
+ *
+ * Settles per key rather than Promise.all: one key failing transiently must not discard the flags
+ * that did resolve, or the whole section collapses to bare fallback rows. Keys with no match
+ * (deleted, or an override for a non-existent flag) are simply absent, and the caller falls back to
+ * a minimal row. Error labels omit the key — it's customer data, kept out of logs.
*/
export async function fetchFlagsByKeys(token: string, site: string, keys: string[]): Promise {
const host = getFlagsApiHost(site)
- // Settle per key rather than Promise.all: one key failing transiently (e.g. a rate limit) must not
- // discard the flags that did resolve — otherwise the whole "Local overrides" section collapses to
- // bare fallback rows until the key set changes. A dropped key just falls back to a minimal row in
- // the caller. (The error label omits the key regardless — it's customer data, kept out of logs.)
const results = await Promise.allSettled(
keys.map(async (key) => {
const url = new URL(`https://${host}/api/ui/ffe/feature-flags`)
@@ -119,20 +137,14 @@ export async function fetchFlagsByKeys(token: string, site: string, keys: string
return results.flatMap((result) => (result.status === 'fulfilled' ? result.value : []))
}
-// Shared request/response handling for both fetchFlagCatalog and fetchFlagsByKeys: run the request,
-// tolerate a response that omits/mistypes `data`, and map its resources into CatalogFlag[]. `total`
-// 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).
+/**
+ * Shared request/response handling for both fetch functions: run the request, tolerate a response
+ * that omits or mistypes `data`, and map its resources. `total` falls back to the resource count
+ * when the server omits `meta.page.total` (a partial response, or the by-key lookup which sends no
+ * 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),
@@ -140,9 +152,8 @@ async function fetchFlagPage(url: URL, token: string, errorLabel: string): Promi
}
}
+/** Maps raw resources to CatalogFlag, deduping by key (collisions would break React keys). */
function mapResources(resources: RawFeatureFlag[]): CatalogFlag[] {
- // Dedupe by key as a cheap safety net against a flag appearing twice on a page (see is_archived
- // note above); collisions would otherwise break React keys (`key={flag.key}`). Keep the first.
const byKey = new Map()
for (const { attributes } of resources) {
if (byKey.has(attributes.key)) {
@@ -151,12 +162,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..f402ac535b 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'
@@ -21,9 +21,8 @@ export function FlagsTab() {
)
}
- // Remount the provider on site change so filters, pagination, overrides, and accumulated tag
- // suggestions don't carry over from a previously-connected org — stale filters would misleadingly
- // empty the new catalog, and stale suggestions would leak the old org's tags into autocomplete.
+ // Remount on site change so nothing carries over from a previously-connected org — stale filters
+ // would misleadingly empty the new catalog, and stale suggestions would leak the old org's tags.
return (
@@ -53,9 +52,11 @@ function ConnectedFlagsTab({ auth }: { auth: FlagAuthState }) {
return (
+ // No dd-privacy-allow: this renders customer flag names, values, and tags, which must stay
+ // masked in the extension's own Session Replay.
+
+ Feature Flag Overrides
+
@@ -107,6 +108,30 @@ function ConnectedFlagsTab({ auth }: { auth: FlagAuthState }) {
)}
+ setAddOpen((open) => !open)}>
+ {addOpen ? '− Hide custom override' : '+ Add a custom override'}
+
+ {addOpen && (
+ <>
+
+
+ >
+ )}
+
+
+ {/* Sticky so the apply/refresh actions stay visible without scrolling past a long catalog. */}
+ 0 || (overrideCount === 0 && !pendingReload)}
>
Refresh Page
-
-
- setAddOpen((open) => !open)}>
- {addOpen ? '− Hide custom override' : '+ Add a custom override'}
-
- {addOpen && (
- <>
-
-
- >
- )}
)
diff --git a/developer-extension/src/panel/components/tabs/flagsTab/inspectedPageFlags.ts b/developer-extension/src/panel/components/tabs/flagsTab/inspectedPageFlags.ts
index dc18aaff9d..3bc0b74a3c 100644
--- a/developer-extension/src/panel/components/tabs/flagsTab/inspectedPageFlags.ts
+++ b/developer-extension/src/panel/components/tabs/flagsTab/inspectedPageFlags.ts
@@ -4,16 +4,18 @@ import type { FlagType } from './flagTypes'
const logger = createLogger('inspectedPageFlags')
-// Contract shared with @datadog/openfeature-browser's DatadogDevtools wrapper.
-// Keep these in sync with that package: the wrapper reads OVERRIDES_KEY once on
-// initialize() and writes DEVTOOLS_MARKER_KEY when it is composed into the provider stack.
+// Contract shared with @datadog/openfeature-browser's DatadogDevtools wrapper. Keep these in sync
+// with that package: the wrapper reads OVERRIDES_KEY once on initialize() and writes
+// DEVTOOLS_MARKER_KEY when it is composed into the provider stack.
export const OVERRIDES_KEY = 'dd.dd_flag.overrides'
export const DEVTOOLS_MARKER_KEY = 'dd.dd_flag.devtools'
export interface FlagOverride {
type: FlagType
- // Any JSON value — objects/arrays are `object`; `null` is allowed (a flag/variant value can be
- // null). The manual-entry form still rejects null via validateOverrideValue.
+ /**
+ * Any JSON value — objects/arrays are `object`, and `null` is allowed (a variant value can be
+ * null). The manual-entry form still rejects null via validateOverrideValue.
+ */
value: boolean | string | number | object | null
}
@@ -25,22 +27,24 @@ export interface FlagState {
}
/**
- * Look up an override by key using own-property membership — a plain `overrides[key]` would
- * return inherited members (e.g. Object.prototype.constructor) for flags named "constructor",
- * "toString", etc., making them wrongly appear overridden.
+ * Looks up an override by own-property membership — a plain `overrides[key]` would return inherited
+ * members (e.g. Object.prototype.constructor) for flags named "constructor" or "toString", making
+ * them wrongly appear overridden.
*/
export function getOverride(overrides: FlagOverrides, key: string): FlagOverride | undefined {
return Object.prototype.hasOwnProperty.call(overrides, key) ? overrides[key] : undefined
}
-// The page's localStorage isn't exclusively ours to write — a hand-edited value, or a different
-// version of the DatadogDevtools wrapper, could leave an entry that isn't a FlagOverride shape
-// (e.g. `null`). Drop those rather than let `override.value` crash the panel when rendered. A
-// wrong-typed-but-shaped entry is kept so it stays visible and removable (the provider rejects it).
function isFlagOverride(value: unknown): value is FlagOverride {
return typeof value === 'object' && value !== null && 'type' in value && 'value' in value
}
+/**
+ * Drops entries that aren't FlagOverride-shaped. The page's localStorage isn't exclusively ours — a
+ * hand-edited value or a different wrapper version could leave something that would crash the panel
+ * on render. A wrong-typed-but-shaped entry is kept so it stays visible and removable (the provider
+ * rejects it anyway).
+ */
export function sanitizeOverrides(overrides: Record): FlagOverrides {
const sanitized: FlagOverrides = {}
for (const [key, entry] of Object.entries(overrides)) {
@@ -51,10 +55,10 @@ export function sanitizeOverrides(overrides: Record): FlagOverr
return sanitized
}
-// Shared read/normalize prelude for every inspected-window eval: parse the overrides map from
-// localStorage and tolerate malformed/absent/mistyped storage, leaving a normalized `overrides`
-// object in scope. Defined once so the read and mutation paths can't interpret storage differently
-// as the DatadogDevtools contract evolves.
+// Shared prelude for every inspected-window eval: parses the overrides map from localStorage,
+// tolerating malformed/absent/mistyped storage, and leaves a normalized `overrides` in scope.
+// Defined once so the read and mutation paths can't interpret storage differently as the contract
+// evolves.
const READ_OVERRIDES_PRELUDE = `
let overrides = {}
try {
@@ -66,8 +70,12 @@ const READ_OVERRIDES_PRELUDE = `
`
/**
- * Reads the current overrides and enablement marker straight from the inspected page's
- * localStorage. The page is the single source of truth — we never cache it elsewhere.
+ * Reads the current overrides and enablement marker straight from the inspected page's localStorage
+ * — the page is the single source of truth, never cached elsewhere.
+ *
+ * Returns null on a transient eval failure (the page navigating or busy) rather than an empty state,
+ * so the caller keeps its last good values instead of blanking the overrides and flashing the
+ * "not detected" warning.
*/
export async function readFlagState(): Promise {
try {
@@ -78,18 +86,16 @@ export async function readFlagState(): Promise {
`)) as FlagState
return { overrides: sanitizeOverrides(raw.overrides ?? {}), devtoolsEnabled: !!raw.devtoolsEnabled }
} catch (error) {
- // A transient eval failure (the inspected page navigating/reloading, or busy) is NOT the same as
- // "no overrides and no wrapper". Return null so the caller keeps its last good state rather than
- // blanking the overrides and flashing the "not detected" warning.
logger.error('Error while reading flag overrides:', error)
return null
}
}
-// Reads, mutates, and writes back the overrides map in a single inspected-window round trip, then
-// returns the resulting map so the caller can update its state without a second read. Splitting this
-// into a separate read then write would let a page navigation land between the two, applying the
-// previous origin's overrides on top of the new origin's storage.
+/**
+ * Reads, mutates, and writes back the overrides map in a single round trip, returning the resulting
+ * map so the caller needs no follow-up read. Kept as one eval because splitting it would let a page
+ * navigation land between read and write, applying the previous origin's overrides to the new one.
+ */
async function applyOverrideStatement(statement: string): Promise> {
return (await evalInWindow(`
${READ_OVERRIDES_PRELUDE}
@@ -100,9 +106,9 @@ async function applyOverrideStatement(statement: string): Promise> {
- // Parse the override from JSON *data* rather than interpolating it as an object literal, so a value
- // property named "__proto__" stays real data instead of the object-literal prototype setter (which
- // would silently drop it and persist {}).
+ // Parse the override from JSON *data* rather than interpolating an object literal, so a value
+ // property named "__proto__" stays real data instead of the prototype setter (which would silently
+ // drop it and persist {}).
const overrideJson = JSON.stringify(JSON.stringify(override))
return applyOverrideStatement(`overrides[${JSON.stringify(key)}] = JSON.parse(${overrideJson})`)
}
@@ -115,8 +121,10 @@ export function clearAllOverrides(): Promise> {
return applyOverrideStatement('overrides = {}')
}
-// Reloads the inspected page so the DatadogDevtools wrapper re-reads localStorage and (re)applies the
-// current overrides. Overrides are written immediately; this is only how they take effect.
+/**
+ * Reloads the inspected page so the wrapper re-reads localStorage. Overrides are written
+ * immediately; this is only how they take effect.
+ */
export function reloadInspectedPage(): void {
chrome.devtools.inspectedWindow.reload({})
}
diff --git a/developer-extension/src/panel/components/tabs/flagsTab/manualOverrideForm.tsx b/developer-extension/src/panel/components/tabs/flagsTab/manualOverrideForm.tsx
index 397412a366..564ef16861 100644
--- a/developer-extension/src/panel/components/tabs/flagsTab/manualOverrideForm.tsx
+++ b/developer-extension/src/panel/components/tabs/flagsTab/manualOverrideForm.tsx
@@ -1,5 +1,18 @@
-import { Box, Button, Group, JsonInput, SegmentedControl, Space, Stack, Switch, Text, TextInput } from '@mantine/core'
+import {
+ Box,
+ Button,
+ Code,
+ 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,
@@ -9,26 +22,50 @@ import {
validateOverrideValue,
type FlagType,
} from './flagTypes'
-import type { FlagOverride } from './inspectedPageFlags'
+import { getOverride, type FlagOverride } from './inspectedPageFlags'
export function ManualOverrideForm() {
- const { catalog, applyOverride } = useFlagsContext()
+ const { catalog, overrides, applyOverride, mutationError } = useFlagsContext()
const [flagKey, setFlagKey] = useState('')
const [type, setType] = useState('BOOLEAN')
const [booleanValue, setBooleanValue] = useState(true)
const [textValue, setTextValue] = useState('')
const [error, setError] = useState(null)
+ // The last override submitted, used to report the outcome inline: the form sits below a long
+ // catalog, so both the tab-level alert and the "Local overrides" section are easily offscreen from
+ // here, and without this Apply looks like it did nothing.
+ const [submitted, setSubmitted] = useState<{ key: string; override: FlagOverride } | null>(null)
+
+ const trimmedKey = flagKey.trim()
+ const existingOverride = getOverride(overrides, trimmedKey)
+ // Derived from the stored overrides rather than the applyOverride call, so it reflects what the
+ // page actually holds. A write failure leaves this false and surfaces mutationError instead.
+ // Type is compared alongside value: re-submitting a stored INTEGER 1 as NUMERIC would otherwise
+ // look already-applied and report success for a write that never landed.
+ const stored = submitted && getOverride(overrides, submitted.key)
+ const applied =
+ submitted !== null &&
+ stored?.type === submitted.override.type &&
+ valuesEqual(stored?.value, submitted.override.value)
+
+ /** Wraps a setter so any edit drops the previous outcome, rather than leaving it next to changed input. */
+ function edit(set: (value: T) => void) {
+ return (value: T) => {
+ setSubmitted(null)
+ setError(null)
+ set(value)
+ }
+ }
function submit() {
setError(null)
- const trimmedKey = flagKey.trim()
+ setSubmitted(null)
if (!trimmedKey) {
setError('Flag key is required')
return
}
- // If the key matches a flag on the loaded page, catch a type mismatch early — the provider would
- // otherwise silently reject it at resolve time. (A flag not on the current page is treated as
- // unknown here; the provider still rejects a true mismatch.)
+ // Catch a type mismatch early — the provider would otherwise silently reject it at resolve time.
+ // A flag not on the current page is treated as unknown; the provider still rejects a true mismatch.
const catalogFlag = catalog.flags.find((flag) => flag.key === trimmedKey)
if (catalogFlag && catalogFlag.type !== type) {
setError(`"${trimmedKey}" is a ${flagTypeLabel(catalogFlag.type)} flag in the catalog — use that type instead`)
@@ -38,7 +75,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)
@@ -47,6 +84,7 @@ export function ManualOverrideForm() {
return
}
applyOverride(trimmedKey, { type, value })
+ setSubmitted({ key: trimmedKey, override: { type, value } })
setError(null)
}
@@ -56,8 +94,11 @@ export function ManualOverrideForm() {
label="Flag key"
placeholder="my-flag"
value={flagKey}
- onChange={(event) => setFlagKey(event.currentTarget.value)}
+ onChange={(event) => edit(setFlagKey)(event.currentTarget.value)}
size="xs"
+ // Re-applying an existing key replaces its value rather than failing, so say so up front
+ // instead of letting the change look like a no-op.
+ description={existingOverride ? 'Already overridden — applying replaces the current value.' : undefined}
/>
@@ -68,7 +109,7 @@ export function ManualOverrideForm() {
color="violet"
size="xs"
value={type}
- onChange={(value) => setType(value)}
+ onChange={(value) => edit(setType)(value)}
data={FLAG_TYPES.map((flagType) => ({ value: flagType, label: flagType }))}
/>
@@ -77,17 +118,24 @@ export function ManualOverrideForm() {
setBooleanValue(event.currentTarget.checked)}
+ onChange={(event) => edit(setBooleanValue)(event.currentTarget.checked)}
color="violet"
/>
) : type === 'JSON' ? (
-
+
) : (
setTextValue(event.currentTarget.value)}
+ onChange={(event) => edit(setTextValue)(event.currentTarget.value)}
size="xs"
/>
)}
@@ -98,6 +146,21 @@ export function ManualOverrideForm() {
)}
+ {/* Gated on `submitted` so a failed write from elsewhere in the tab (a catalog row) doesn't
+ light up this form — that one belongs to the tab-level alert. */}
+ {submitted !== null &&
+ (applied ? (
+
+ Override set for {submitted.key} — refresh the page to apply it.
+
+ ) : (
+ mutationError && (
+
+ {mutationError}
+
+ )
+ ))}
+
Apply override
@@ -107,13 +170,20 @@ export function ManualOverrideForm() {
)
}
+// Structural comparison: override values are JSON, so an OBJECT/JSON override needs more than ===.
+function valuesEqual(a: unknown, b: unknown): boolean {
+ return JSON.stringify(a) === JSON.stringify(b)
+}
+
+/**
+ * Parses form input into an override value. Strict, unlike the catalog: input that doesn't parse is
+ * a user error, so it throws rather than falling back to the raw string.
+ */
function parseFormValue(type: FlagType, raw: boolean | string): FlagOverride['value'] {
// The Switch already hands us a real boolean, so BOOLEAN has no string to parse.
if (type === 'BOOLEAN') {
return Boolean(raw)
}
- // The form is strict (unlike the catalog): a value that doesn't parse is a user error, so reject
- // it with the type's message rather than falling back to the raw string.
const result = parseTypedString(type, String(raw))
if (!result.ok) {
throw new Error(FLAG_TYPE_CONFIG[type].parseErrorMessage)
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..8cb78da096 100644
--- a/developer-extension/src/panel/components/tabs/flagsTab/oauth.ts
+++ b/developer-extension/src/panel/components/tabs/flagsTab/oauth.ts
@@ -1,22 +1,29 @@
-// OAuth (authorization_code + PKCE) against Datadog's first-party OAuth server, used to fetch
-// the feature-flag catalog without asking the user to paste API/App keys.
-//
-// 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).
+// OAuth (authorization_code + PKCE) against Datadog's first-party OAuth server, used to fetch the
+// feature-flag catalog without asking the user to paste API/App keys. The client is PUBLIC (no
+// secret), so PKCE is the only client proof. Tokens live in chrome.storage.session — never on disk.
+// See FFL-2596.
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']
+// Separate registered clients. The prod one 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
+}
+// GET /api/v2/team needs no scope (it's gated on the user's Datadog permissions), so "My teams"
+// 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.
+// Refresh slightly early so a slow request can't race the clock.
const EXPIRY_SKEW_MS = 60_000
export interface OAuthTokens {
accessToken: string
refreshToken?: string
- // Absolute epoch-ms timestamp at which accessToken stops being valid.
+ /** Absolute epoch-ms timestamp at which accessToken stops being valid. */
expiresAt: number
}
@@ -35,20 +42,15 @@ 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. A fixed list rather than a free-text host, so
+ * there's no user-entered domain to validate against phishing.
+ *
+ * Trimmed to US1 + Staging while the prod OAuth client replicates to the other DCs; add them back
+ * with the same subdomain scheme (US1/EU1 `app.`, staging `dd.`, regional sites as-is).
+ */
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)' },
]
@@ -79,9 +81,10 @@ function randomBase64Url(byteLength: number): string {
return base64UrlEncode(bytes.buffer)
}
-// Exported and wrapped with mockable() so tests can stub the hash: crypto.subtle is only exposed in
-// a secure context, which some CI browsers (mobile devices reached over http) don't provide. The
-// extension itself runs on a chrome-extension:// origin, which is always a secure context.
+/**
+ * Exported and mockable so tests can stub the hash: crypto.subtle needs a secure context, which some
+ * CI browsers lack. The extension itself always has one (chrome-extension:// origin).
+ */
export function sha256(data: BufferSource): Promise {
return crypto.subtle.digest('SHA-256', data)
}
@@ -92,9 +95,11 @@ async function generatePkce(): Promise<{ verifier: string; challenge: string }>
return { verifier, challenge: base64UrlEncode(digest) }
}
-// `fallbackRefreshToken` carries the previous refresh token forward: refresh responses often omit
-// `refresh_token` when the server doesn't rotate it, and dropping it would force a full re-login on
-// the next expiry.
+/**
+ * Normalizes a token response. `fallbackRefreshToken` carries the previous refresh token forward:
+ * refresh responses omit `refresh_token` when the server doesn't rotate it, and dropping it would
+ * force a full re-login at the next expiry.
+ */
function toTokens(raw: RawTokenResponse, fallbackRefreshToken?: string): OAuthTokens {
return {
accessToken: raw.access_token,
@@ -103,9 +108,10 @@ function toTokens(raw: RawTokenResponse, fallbackRefreshToken?: string): OAuthTo
}
}
-// Thrown by requestToken on a non-ok response. `invalidGrant` marks the RFC 6749 `invalid_grant`
-// case — the refresh token itself is dead (expired/revoked/reused) — as opposed to a transient
-// failure (5xx, rate limit) that shouldn't be treated the same way.
+/**
+ * Thrown by requestToken on a non-ok response. `invalidGrant` marks the RFC 6749 case where the
+ * refresh token itself is dead (expired/revoked/reused), as opposed to a transient 5xx or rate limit.
+ */
class TokenRequestError extends Error {
constructor(
message: string,
@@ -135,7 +141,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 +153,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,21 +169,22 @@ export async function loginWithOAuth(site: string): Promise {
}
const returned = new URL(redirectResponse)
+ // Check CSRF state before acting on ANY other param, including `error`, so a forged callback can't
+ // drive our error handling with attacker-controlled values.
+ 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
- // talk to, so if they disagree we abort rather than store tokens that would later be used
- // against a different site than the one they were issued for.
+ // Datadog appends `domain`, naming the site actually authenticated against. If it disagrees with
+ // our selection, abort rather than store tokens that would be used against a different site.
const returnedDomain = returned.searchParams.get('domain')
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 +196,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 +208,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 +227,57 @@ export async function clearStoredTokens(): Promise {
await chrome.storage.session.remove(TOKENS_STORAGE_KEY)
}
+/**
+ * Ends the connection: revokes the grant at Datadog, then drops the local tokens. Clearing locally
+ * alone would only make this extension forget them — the grant would stay live and any copy of the
+ * refresh token would keep working. Revocation follows https://datatracker.ietf.org/doc/html/rfc7009.
+ *
+ * Returns whether the revocation succeeded. Local tokens are cleared either way, so a user who asked
+ * to disconnect ends up disconnected even if Datadog is unreachable; the caller reports a failure as
+ * "the grant may still be active". Only a failure to clear locally rejects — the panel would
+ * otherwise claim a disconnection that reopening it would contradict.
+ */
+export async function revokeAndClearTokens(site: string): Promise<{ revoked: boolean }> {
+ const revoked = await tryRevokeGrant(site)
+ await clearStoredTokens()
+ return { revoked }
+}
+
+/**
+ * Revokes the refresh token, since that's the renewable part of the grant — revoking only the access
+ * token would leave the grant able to mint new ones. The short-lived access token is left to expire
+ * on its own, and dropped locally by the caller.
+ *
+ * Refreshes first: the revoke endpoint authenticates the caller with a Bearer token.
+ */
+async function tryRevokeGrant(site: string): Promise {
+ try {
+ 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.
@@ -224,10 +286,9 @@ export function isTokenUsable(tokens: OAuthTokens | null): boolean {
return !!tokens && (tokens.expiresAt > Date.now() || !!tokens.refreshToken)
}
-// Shared in-flight refresh. Each catalog request triggers a getValidAccessToken call, so several can
-// run at once; the refresh token is single-use (the server rotates it), so overlapping refreshes
-// must share one request. Otherwise the loser would replay a spent token, get invalid_grant, and
-// wipe the tokens the winner just stored — disconnecting the user mid-session.
+// Shared in-flight refresh. The refresh token is single-use (the server rotates it), so overlapping
+// refreshes must share one request — otherwise the loser replays a spent token, gets invalid_grant,
+// and wipes the tokens the winner just stored, disconnecting the user mid-session.
let pendingRefresh: Promise | null = null
/**
@@ -240,16 +301,15 @@ export async function getValidAccessToken(site: string): Promise
if (!tokens) {
return null
}
- // Refresh early (skew) only when we can actually refresh. Without a refresh token, applying the
- // skew would discard the last 60s of a still-valid token and force an unnecessary reconnect.
+ // Apply the skew only when we can actually refresh; otherwise it would discard the last 60s of a
+ // still-valid token and force an unnecessary reconnect.
const skew = tokens.refreshToken ? EXPIRY_SKEW_MS : 0
if (Date.now() < tokens.expiresAt - skew) {
return tokens.accessToken
}
if (tokens.refreshToken) {
try {
- // Coalesce concurrent refreshes into a single request (see pendingRefresh). Capture the shared
- // promise in a local so the `finally` clearing pendingRefresh can't race the await below.
+ // Capture the shared promise in a local so the `finally` below can't race the await.
const refresh = (pendingRefresh ??= refreshTokens(site, tokens.refreshToken)
.then(async (refreshed) => {
await storeTokens(refreshed)
@@ -260,14 +320,13 @@ export async function getValidAccessToken(site: string): Promise
}))
return (await refresh).accessToken
} catch (err) {
- // A dead refresh token (invalid_grant) means the session is over — drop it and reconnect.
+ // A dead refresh token means the session is over — drop it and reconnect.
if (err instanceof TokenRequestError && err.invalidGrant) {
await clearStoredTokens()
return null
}
- // Transient failure (network blip, 5xx). If we were only refreshing early — still inside the
- // skew window, so the current token hasn't actually expired — keep using it rather than
- // failing the caller; the refresh will be retried on the next call.
+ // Transient failure while refreshing early: the current token hasn't actually expired, so keep
+ // using it and retry on the next call.
if (Date.now() < tokens.expiresAt) {
return tokens.accessToken
}
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..0eb603a9ba 100644
--- a/developer-extension/src/panel/components/tabs/flagsTab/useFlagCatalogView.ts
+++ b/developer-extension/src/panel/components/tabs/flagsTab/useFlagCatalogView.ts
@@ -2,8 +2,6 @@ import { useEffect, useMemo, useState } from 'react'
import type { FlagCatalogRequest } from './flagsRequests'
const CATALOG_PAGE_SIZE = 20
-// Wait out a typing burst before sending a search to the server, so we don't fire a request per
-// keystroke. Short enough to still feel responsive.
const SEARCH_DEBOUNCE_MS = 400
export interface FlagCatalogView {
@@ -13,6 +11,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,42 +25,60 @@ 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.
useEffect(() => {
const id = setTimeout(() => setDebouncedSearch(search), SEARCH_DEBOUNCE_MS)
return () => clearTimeout(id)
}, [search])
+ // Derived outside the memo so the request stays unchanged while the toggle is off — otherwise
+ // identity resolving a moment after connect would trigger a needless 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 }
+}
diff --git a/developer-extension/src/panel/components/tabs/flagsTab/useInspectedPageOverrides.ts b/developer-extension/src/panel/components/tabs/flagsTab/useInspectedPageOverrides.ts
index 41971ff27c..b3b1ca7afa 100644
--- a/developer-extension/src/panel/components/tabs/flagsTab/useInspectedPageOverrides.ts
+++ b/developer-extension/src/panel/components/tabs/flagsTab/useInspectedPageOverrides.ts
@@ -9,9 +9,8 @@ import {
writeOverride,
} from './inspectedPageFlags'
-// After a (re)load the DatadogDevtools wrapper's initialize() runs asynchronously, so its marker can
-// be briefly absent on a page that *does* have the provider. When navigation finishes we re-check for
-// it over this window — resolving early the moment it appears — instead of immediately flashing the
+// The wrapper's initialize() runs asynchronously after a (re)load, so its marker can be briefly
+// absent on a page that does have it. Re-check over this window instead of immediately flashing the
// "not detected" warning. Counted in ticks rather than wall-clock so a clock change can't skew it.
const SETTLE_INTERVAL_MS = 250
const SETTLE_TIMEOUT_MS = 2500
@@ -37,10 +36,10 @@ function delay(ms: number): Promise {
return new Promise((resolve) => setTimeout(resolve, ms))
}
-// Resolves the inspected page's status by polling for the provider marker over the settle window
-// (see SETTLE_* above), reporting each transition through `setState` and bailing as soon as
-// `isCancelled()` flips (a newer navigation, a newer settle, or unmount). It resolves early the
-// instant the marker appears; otherwise it decides once the window elapses:
+// Resolves the inspected page's status by polling for the provider marker over the settle window,
+// reporting each transition through `setState` and bailing as soon as `isCancelled()` flips (a newer
+// navigation, a newer settle, or unmount). Resolves early the instant the marker appears; otherwise
+// decides once the window elapses:
// - a read shows the marker -> ready, devtoolsEnabled: true
// - window elapses, good read, no marker -> ready, devtoolsEnabled: false (wrapper genuinely absent)
// - only the final read failed -> keep the last good state (an earlier read succeeded)
@@ -66,8 +65,8 @@ async function settleFlagState(
if (next) {
setState({ status: 'ready', overrides: next.overrides, devtoolsEnabled: false, error: null })
} else if (lastRead) {
- // The final read failed but an earlier one succeeded — commit that last good *full* state, so
- // a devtoolsEnabled left over from before this settle (e.g. the previous page) can't linger.
+ // Commit the last good *full* state, so a devtoolsEnabled left over from a previous page
+ // can't linger.
setState({
status: 'ready',
overrides: lastRead.overrides,
@@ -96,11 +95,13 @@ async function settleFlagState(
}
/**
- * Tracks the inspected page's overrides as an explicit lifecycle — `loading | ready | error` —
- * driven by its navigation events. This avoids the "DatadogDevtools not detected" warning flashing
- * when applying an override reloads the page: while a navigation is in flight we stay `loading`
- * (warning hidden, writes blocked), and once it finishes we re-check for the provider marker over a
- * short settle window (the wrapper initializes asynchronously) before deciding it's absent.
+ * Tracks the inspected page's overrides as an explicit lifecycle — `loading | ready | error` — driven
+ * by its navigation events. This avoids the "DatadogDevtools not detected" warning flashing when
+ * applying an override reloads the page: while a navigation is in flight we stay `loading` (warning
+ * hidden, writes blocked), and once it finishes we re-check for the marker over a short settle window
+ * before deciding it's absent.
+ *
+ * Assumes a single mounted instance — the mutation queue only serializes writes within one hook.
*/
export function useInspectedPageOverrides(): OverridesController {
const [state, setState] = useState({
@@ -113,20 +114,17 @@ export function useInspectedPageOverrides(): OverridesController {
const mutationQueue = useRef>(Promise.resolve())
// Cancels an in-flight settle when a newer navigation (or unmount) supersedes it.
const cancelSettle = useRef<() => void>(noop)
- // Monotonic id guarding the post-write state update against a navigation that lands mid-write:
- // bumped before each write applies its result and on navigation start, so a write still in flight
- // when a new page loads can't clobber it with the previous origin's overrides.
+ // Bumped before each write and on navigation start, so a write still in flight when a new page
+ // loads can't clobber it with the previous origin's overrides.
const readSeq = useRef(0)
// Flipped false on unmount so an async write's follow-up read can't setState on a torn-down hook.
const mounted = useRef(true)
- // Mirror of status for the write guard (read from event handlers, outside render). Also set
+ // Mirror of status for the write guard, since event handlers read it outside render. Set
// synchronously on navigation start so a queued write can't slip through before the re-render.
const statusRef = useRef(state.status)
statusRef.current = state.status
- // Polls the page and resolves the status (see settleFlagState). A fresh `cancelled` flag per call
- // — flipped by cancelSettle on the next navigation, the next settle, or unmount — supersedes an
- // in-flight settle.
+ // A fresh `cancelled` flag per call supersedes any in-flight settle.
const settle = useCallback(() => {
cancelSettle.current()
let cancelled = false
@@ -136,9 +134,10 @@ export function useInspectedPageOverrides(): OverridesController {
void settleFlagState(setState, () => cancelled)
}, [])
- // Read once on mount, then drive the state machine off the inspected page's navigation lifecycle:
- // going `loading` the moment a top-frame navigation starts hides the provider warning and blocks
- // writes, and onCompleted/onErrorOccurred re-reads once the document has swapped in.
+ // Known limitation (accepted): terminal events aren't correlated to a specific navigation — the
+ // webNavigation API exposes no id spanning onBeforeNavigate→onCompleted. In a rare overlapping-
+ // navigation race a stale terminal event could settle to `ready` mid-nav; it self-corrects on the
+ // next read and the write guard limits exposure.
useEffect(() => {
mounted.current = true
settle()
@@ -157,11 +156,6 @@ export function useInspectedPageOverrides(): OverridesController {
statusRef.current = 'loading'
setState((prev) => ({ ...prev, status: 'loading', error: null }))
}
- // Known limitation (accepted): terminal events aren't correlated to a specific navigation — the
- // webNavigation API exposes no id spanning onBeforeNavigate→onCompleted. In a rare overlapping-
- // navigation race (e.g. a redirecting page) a stale terminal event could settle to `ready` mid-nav;
- // it self-corrects on the next read and the write guard limits exposure, so we don't add
- // navigation-id tracking here.
const onNavigationSettled = (details: { tabId: number; frameId: number }) => {
if (isInspectedTopFrame(details)) {
settle()
@@ -180,10 +174,12 @@ export function useInspectedPageOverrides(): OverridesController {
}
}, [settle])
- // Queue each mutation so it runs after the previous one settles, and block writes while the page is
- // navigating so a read-modify-write can't land on a different origin's storage than the one shown.
- // The guard is re-checked when the queued write actually runs — not just when enqueued — because a
- // write can reach the front of the queue after a navigation has started.
+ /**
+ * Queues each mutation behind the previous one, and blocks writes while the page is navigating so a
+ * read-modify-write can't land on a different origin's storage than the one shown. The guard is
+ * re-checked when the write actually runs — not just when enqueued — because a write can reach the
+ * front of the queue after a navigation has started.
+ */
const enqueue = useCallback((mutate: () => Promise>) => {
if (statusRef.current !== 'ready') {
return Promise.reject(new Error(PAGE_LOADING_MESSAGE))
@@ -192,9 +188,8 @@ export function useInspectedPageOverrides(): OverridesController {
if (statusRef.current !== 'ready') {
throw new Error(PAGE_LOADING_MESSAGE)
}
- // Bump the read sequence before the write; a navigation that starts while it's in flight bumps
- // readSeq too, so the resulting-state update below is dropped rather than landing on the new
- // origin. The write returns the resulting map directly, so no follow-up read is needed.
+ // A navigation starting mid-write bumps readSeq too, so the update below is dropped rather than
+ // landing on the new origin. The write returns the resulting map, so no follow-up read needed.
const seq = ++readSeq.current
const overrides = await mutate()
if (mounted.current && seq === readSeq.current) {