Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions developer-extension/src/common/toErrorMessage.ts
Original file line number Diff line number Diff line change
@@ -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)
}
16 changes: 6 additions & 10 deletions developer-extension/src/panel/components/panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,9 @@ export function Panel() {
<Tabs.Tab value={PanelTabs.Replay}>
<Text>Live replay</Text>
</Tabs.Tab>
{settings.datadogMode && (
<Tabs.Tab value={PanelTabs.Flags}>
<Text>Feature Flags</Text>
</Tabs.Tab>
)}
<Tabs.Tab value={PanelTabs.Flags}>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Image In the Settings tab when we override a value we show this icon. Would it be possible to do the same here? If on the page we are, we have overriden a FF we could show the icon?

@kellyw1806 kellyw1806 Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, was thinking about this but i think it might not be worth the cost? it would need the override state at Panel level, so the extension would read the inspected page on every navigation for every user, including everyone who never opens Flags. That might be a lot of extension-wide overhead? i'm also going to add an alert on the Auth login page so that if there are overrides that exist, it will let you know. maybe that can help as well. having the override remain across disconnect and session restarts is intended, but wanted to add some sort of warning to users

image

<Text>Feature Flags</Text>
</Tabs.Tab>
<Tabs.Tab
value={PanelTabs.Settings}
rightSection={
Expand Down Expand Up @@ -98,11 +96,9 @@ export function Panel() {
<Tabs.Panel value={PanelTabs.Replay} className={classes.tab}>
<ReplayTab />
</Tabs.Panel>
{settings.datadogMode && (
<Tabs.Panel value={PanelTabs.Flags} className={classes.tab}>
<FlagsTab />
</Tabs.Panel>
)}
<Tabs.Panel value={PanelTabs.Flags} className={classes.tab}>
<FlagsTab />
</Tabs.Panel>
<Tabs.Panel value={PanelTabs.Settings} className={classes.tab}>
<SettingsTab />
</Tabs.Panel>
Expand Down
5 changes: 5 additions & 0 deletions developer-extension/src/panel/components/tabBase.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<Center h="100%" className="dd-privacy-allow">
<Stack align="center" gap="md" maw={460} px="md">
<DisconnectedOverridesNotice />
<Text size="xl" fw={600} ta="center">
Authenticate with Datadog to access your feature flags
</Text>
{/* 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. */}
<Box w="100%">
{/* 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. */}
<SiteField disabled={auth.connecting} />
</Box>
<Button color="violet" onClick={auth.connect} loading={auth.connecting}>
Sign in to Datadog
</Button>
Expand All @@ -21,47 +28,92 @@ export function ConnectScreen({ auth }: { auth: FlagAuthState }) {
{auth.error}
</Text>
)}

<Anchor component="button" type="button" size="xs" c="dimmed" onClick={() => setAdvancedOpen((open) => !open)}>
{advancedOpen ? '− Hide advanced' : 'Advanced: site'}
</Anchor>
{advancedOpen && (
<Stack gap="sm" style={{ width: '100%' }}>
<SiteField />
</Stack>
{/* 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 && (
<Text c="orange" size="xs" ta="center">
{auth.warning} You can revoke it from Datadog under Organization Settings → Authorized Applications.
</Text>
)}
</Stack>
</Center>
)
}

/**
* 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()
Comment thread
kellyw1806 marked this conversation as resolved.
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.
<Alert
color="orange"
w="100%"
data-dd-privacy="mask"
title={`${count} override${count === 1 ? '' : 's'} active on this page`}
>
<Text size="xs">
These are stored in the page and keep applying while you are signed out. Sign in to view and remove them.
</Text>
</Alert>
)
}

export function ConnectionHeader({ auth }: { auth: FlagAuthState }) {
return (
<Stack gap={4}>
<Group justify="space-between">
<Group gap="xs">
<Badge color="green" variant="light">
Connected via OAuth
</Badge>
<Text c="dimmed" size="xs">
{auth.site}
</Text>
</Group>
<Button size="compact-xs" variant="subtle" color="gray" onClick={auth.disconnect}>
{/* The badge opts out of Mantine's default uppercasing: "datad0g.com" and "datadoghq.com"
differ by a zero vs an "o", so caps destroy the one glyph telling staging from production.
Disconnect sits at the far end — it revokes the grant, so a misclick costs a full re-auth. */}
<Group gap="xs" align="center" justify="space-between" wrap="nowrap">
<Badge color="green" variant="light" tt="none">
Connected: {siteLabel(auth.site)}
</Badge>
<Button
size="compact-xs"
variant="light"
color="red"
onClick={auth.disconnect}
loading={auth.disconnecting}
// Disconnect revokes the grant at Datadog before clearing the local session, so guard
// against a second click re-running it against tokens the first click already revoked.
disabled={auth.disconnecting}
>
Disconnect
</Button>
</Group>
{/* Surface disconnect failures here too — otherwise a failed Disconnect looks like a no-op. */}
{/* Surface disconnect failures here too — otherwise a failed Disconnect looks like a no-op.
(A revoke-succeeded-but-grant-live warning can't appear here: it always accompanies a
successful local sign-out, which flips to the ConnectScreen where the notice lives.) */}
{auth.error && (
<Text c="red" size="xs" ta="right">
<Text c="red" size="xs">
{auth.error}
</Text>
)}
</Stack>
)
}

function SiteField() {
// Falls back to the raw site so a stale or hand-edited setting still renders something meaningful
// (getFlagsApiHost is the one that treats an unknown site as an error).
function siteLabel(site: string): string {
return FLAG_SITES.find((entry) => entry.site === site)?.label ?? site
}

function SiteField({ disabled }: { disabled?: boolean }) {
const [{ flagsSite }, setSetting] = useSettings()

return (
Expand All @@ -72,6 +124,7 @@ function SiteField() {
value={flagsSite}
onChange={(value) => value && setSetting('flagsSite', value)}
allowDeselect={false}
disabled={disabled}
size="xs"
/>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Shared helper for the FFE API calls the Flags tab makes (catalog, current user, teams). Centralizes
// the bearer-auth header + response handling that flagsRequests.ts and flagIdentity.ts would
// otherwise each repeat.

// Thrown on a 403 so callers can tell "the token lacks the scope" apart from a real failure (used by
// flagIdentity to degrade the team filter rather than fail the whole tab).
export class ForbiddenError extends Error {}

/**
* GETs a JSON resource from the FFE API with the OAuth bearer token. Throws ForbiddenError on 403 and
* a generic Error on any other non-2xx, prefixing the message with `errorLabel`. Keep customer data
* (e.g. a flag key) out of `errorLabel` — these errors are logged and the panel forwards logs to its
* own telemetry.
*/
export async function fetchFfeJson<T>(url: string, token: string, errorLabel: string): Promise<T> {
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
},
})
if (response.status === 403) {
throw new ForbiddenError(`${errorLabel}: 403 ${response.statusText}`)
}
if (!response.ok) {
throw new Error(`${errorLabel}: ${response.status} ${response.statusText}`)
}
return (await response.json()) as T
}
Loading
Loading