diff --git a/.changeset/reveal-snippet-component.md b/.changeset/reveal-snippet-component.md new file mode 100644 index 0000000000..02f6d65b24 --- /dev/null +++ b/.changeset/reveal-snippet-component.md @@ -0,0 +1,5 @@ +--- +'@hyperdx/app': patch +--- + +feat(app): mask secrets in API key and MCP install snippets with a shared reveal-to-copy component diff --git a/packages/app/src/components/ClickStackOnboarding/CopySnippet.tsx b/packages/app/src/components/ClickStackOnboarding/CopySnippet.tsx index ae674c749c..bcf34aa2d8 100644 --- a/packages/app/src/components/ClickStackOnboarding/CopySnippet.tsx +++ b/packages/app/src/components/ClickStackOnboarding/CopySnippet.tsx @@ -1,18 +1,44 @@ import { Button, Code, CopyButton, Group, Stack, Text } from '@mantine/core'; import { IconCheck, IconCopy } from '@tabler/icons-react'; +import { RevealSnippet } from '@/components/RevealSnippet/RevealSnippet'; + interface CopySnippetProps { /** Optional heading above the snippet (e.g. "Import block"). */ label?: string; snippet: string; + /** + * Access key inlined in `snippet`. When set, the snippet is masked + * until revealed; omit for a plain, always-visible snippet. + */ + accessKey?: string; } -/** - * Pre-formatted snippet with a copy-to-clipboard button. The copy - * affordance handles its own `Copied` affirmation via Mantine's - * ``. - */ -export function CopySnippet({ label, snippet }: CopySnippetProps) { +/** Pre-formatted snippet with a copy button, masked when `accessKey` is set. */ +export function CopySnippet({ label, snippet, accessKey }: CopySnippetProps) { + if (accessKey) { + return ( + + + + {label ? ( + + {label} + + ) : ( + + )} + + + + + + + + + ); + } + return ( {label ? ( diff --git a/packages/app/src/components/ClickStackOnboarding/DeeplinkInstall.tsx b/packages/app/src/components/ClickStackOnboarding/DeeplinkInstall.tsx index ea742f7801..839acbc314 100644 --- a/packages/app/src/components/ClickStackOnboarding/DeeplinkInstall.tsx +++ b/packages/app/src/components/ClickStackOnboarding/DeeplinkInstall.tsx @@ -8,22 +8,21 @@ interface DeeplinkInstallProps { deeplink: string; fallbackLabel: string; fallbackSnippet: string; + /** Access key to mask in the fallback snippet (the deep link isn't shown). */ + fallbackAccessKey?: string; note?: ReactNode; } /** - * One-click "Add to " deep-link install for hosts that - * support it (Cursor, VS Code + Copilot). The manual fallback - * snippet stays tucked behind a `Manual setup` toggle so the - * primary affordance is always the deep link, with the JSON - * paste-it-yourself path available for users who can't or won't - * use the deep link. + * One-click "Add to " deep-link install (Cursor, VS Code), with a + * manual JSON fallback behind a `Manual setup` toggle. */ export function DeeplinkInstall({ buttonLabel, deeplink, fallbackLabel, fallbackSnippet, + fallbackAccessKey, note, }: DeeplinkInstallProps) { const [manualOpen, setManualOpen] = useState(false); @@ -48,7 +47,11 @@ export function DeeplinkInstall({ {note} - + ); diff --git a/packages/app/src/components/ClickStackOnboarding/McpInstallPanel.tsx b/packages/app/src/components/ClickStackOnboarding/McpInstallPanel.tsx index 25c6bda9f9..a4e36d77ec 100644 --- a/packages/app/src/components/ClickStackOnboarding/McpInstallPanel.tsx +++ b/packages/app/src/components/ClickStackOnboarding/McpInstallPanel.tsx @@ -17,16 +17,8 @@ import { type DeploymentShape, } from './installSnippets'; -/** - * Agent hosts the install panel covers. Five named installs (Claude - * Code, Cursor, VS Code, Codex CLI, OpenCode) plus "Other" as the - * JSON-fallback escape hatch for any other MCP-compatible host - * (Claude Desktop, Continue, Cline, ...). - * - * ChatGPT is intentionally absent: native MCP isn't there yet, and - * bridges are a user-side decision better tracked in the docs than - * in this UI surface. - */ +// ChatGPT is intentionally absent: no native MCP yet, and bridges are a +// user-side decision better tracked in docs than in this UI. type AgentHost = | 'claude-code' | 'cursor' @@ -56,26 +48,13 @@ function isAgentHost(value: string): value is AgentHost { } interface McpInstallPanelProps { - /** - * Deployment shape derived from `useMe()` in the caller. The - * caller is responsible for not mounting this panel until the - * deployment is ready (matching the convention in - * `ApiKeysSection`), so the type here is non-nullable. - */ + /** Non-nullable: the caller mounts this only once the deployment is ready. */ deployment: DeploymentShape; } /** - * Renders the host picker plus the install primitive (CLI command, - * deep link, or JSON block) for the chosen host. Presentational; - * the deployment shape comes in via props so the same component - * can render from any surface that resolves a deployment + access - * key. - * - * The access key is inlined in the rendered snippet to match the - * existing API Keys card pattern, which shows the key in plain - * text. A follow-up will introduce a shared mask + reveal-to-copy - * affordance across every credential surface in Team Settings. + * Host picker plus the install primitive (CLI command, deep link, or JSON + * block) for the chosen host. The access key is masked in every snippet. */ export default function McpInstallPanel({ deployment }: McpInstallPanelProps) { const [host, setHost] = useState('claude-code'); @@ -88,10 +67,8 @@ export default function McpInstallPanel({ deployment }: McpInstallPanelProps) { fullWidth value={host} onChange={value => { - // Narrow the SegmentedControl's `string` callback against - // the CHOICES set so a future out-of-band value cannot - // silently install an invalid host. CHOICES is the source - // of truth for the option list. + // Narrow the string callback so an out-of-band value can't set an + // invalid host. if (isAgentHost(value)) { setHost(value); } @@ -108,7 +85,11 @@ export default function McpInstallPanel({ deployment }: McpInstallPanelProps) { aria-label="MCP host" /> - + ); } @@ -128,9 +109,7 @@ function HostIcon({ id }: { id: AgentHost }) { case 'other': return ; } - // Exhaustiveness check via `satisfies never`: adding a new - // AgentHost variant without extending the switch fails the - // compile here. Defensive `return null` (instead of throwing) + // Exhaustiveness: a new AgentHost variant fails compile here. `return null` // keeps a runtime-only unknown variant from crashing the panel. id satisfies never; return null; @@ -139,15 +118,18 @@ function HostIcon({ id }: { id: AgentHost }) { interface HostInstallProps { host: AgentHost; snippets: BuiltSnippets; + /** Access key masked in every snippet; empty when there's no key. */ + accessKey: string; } -function HostInstall({ host, snippets }: HostInstallProps) { +function HostInstall({ host, snippets, accessKey }: HostInstallProps) { switch (host) { case 'claude-code': return ( ); @@ -158,6 +140,7 @@ function HostInstall({ host, snippets }: HostInstallProps) { deeplink={snippets.cursor} fallbackLabel="Or paste this JSON into Cursor settings > MCP:" fallbackSnippet={snippets.jsonBlock} + fallbackAccessKey={accessKey} /> ); @@ -168,6 +151,7 @@ function HostInstall({ host, snippets }: HostInstallProps) { deeplink={snippets.vscode} fallbackLabel="Or paste this JSON into .vscode/mcp.json:" fallbackSnippet={snippets.jsonBlock} + fallbackAccessKey={accessKey} note={ Requires VS Code 1.99+ with the Copilot Chat MCP feature enabled. @@ -181,6 +165,7 @@ function HostInstall({ host, snippets }: HostInstallProps) { ); @@ -189,6 +174,7 @@ function HostInstall({ host, snippets }: HostInstallProps) { ); @@ -197,12 +183,11 @@ function HostInstall({ host, snippets }: HostInstallProps) { ); } - // Exhaustiveness check via `satisfies never`: adding a new - // AgentHost variant without extending the switch fails the - // compile here. Defensive `return null` (instead of throwing) + // Exhaustiveness: a new AgentHost variant fails compile here. `return null` // keeps a runtime-only unknown variant from crashing the panel. host satisfies never; return null; diff --git a/packages/app/src/components/ClickStackOnboarding/__tests__/McpInstallPanel.test.tsx b/packages/app/src/components/ClickStackOnboarding/__tests__/McpInstallPanel.test.tsx new file mode 100644 index 0000000000..26296585ac --- /dev/null +++ b/packages/app/src/components/ClickStackOnboarding/__tests__/McpInstallPanel.test.tsx @@ -0,0 +1,63 @@ +import { MantineProvider } from '@mantine/core'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import McpInstallPanel from '@/components/ClickStackOnboarding/McpInstallPanel'; +import { defaultRedact } from '@/components/RevealSnippet/RevealSnippet'; + +// A realistic-length access key so defaultRedact keeps a visible prefix and +// masks the rest (matching production, where the key is a UUIDv4). +const ACCESS_KEY = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; + +const DEPLOYMENT = { + apiUrl: 'https://app.example.com/api', + accessKey: ACCESS_KEY, +}; + +function renderPanel() { + return render( + + + , + ); +} + +function codeText(): string { + // Every masked host renders its snippet in the shared code block. + return screen.getByTestId('reveal-snippet-code').textContent ?? ''; +} + +describe('McpInstallPanel secret masking', () => { + it('masks the access key in the default (Claude Code) command snippet', () => { + renderPanel(); + + const text = codeText(); + // The raw key must never appear in the rendered snippet... + expect(text).not.toContain(ACCESS_KEY); + // ...but the masked stand-in (visible prefix + dots) should. + expect(text).toContain(defaultRedact(ACCESS_KEY)); + }); + + it('masks the access key in the JSON fallback (Other) snippet', async () => { + const user = userEvent.setup(); + renderPanel(); + + // Switch to the generic "Other" host, which renders the raw JSON block. + await user.click(screen.getByRole('radio', { name: /other/i })); + + const text = codeText(); + expect(text).not.toContain(ACCESS_KEY); + expect(text).toContain(defaultRedact(ACCESS_KEY)); + }); + + it('reveals the raw key only after an explicit reveal action', async () => { + const user = userEvent.setup(); + renderPanel(); + + expect(codeText()).not.toContain(ACCESS_KEY); + + await user.click(screen.getByRole('button', { name: /reveal key/i })); + + expect(codeText()).toContain(ACCESS_KEY); + }); +}); diff --git a/packages/app/src/components/ClickStackOnboarding/installSnippets.ts b/packages/app/src/components/ClickStackOnboarding/installSnippets.ts index 2285a51b4a..7f440196c0 100644 --- a/packages/app/src/components/ClickStackOnboarding/installSnippets.ts +++ b/packages/app/src/components/ClickStackOnboarding/installSnippets.ts @@ -189,6 +189,12 @@ function buildCodexOneLiner(deployment: DeploymentShape): string { * by the deep-link host's URL parser. Notably, `+` decodes as space * under `application/x-www-form-urlencoded`, which corrupts the * embedded JSON. + * + * Residual by design: the access key is (base64/URL-)encoded into the + * deep-link target so the one-click install can authenticate. Only the + * human-readable JSON fallback is masked; the deep link itself carries the + * key in the DOM href. It is not shoulder-surfable visible text and is + * required for the link to work, so it is intentionally not reveal-gated. */ function buildCursorDeeplink(deployment: DeploymentShape): string { const config = { diff --git a/packages/app/src/components/RevealSnippet/RevealSnippet.module.scss b/packages/app/src/components/RevealSnippet/RevealSnippet.module.scss new file mode 100644 index 0000000000..4818de0123 --- /dev/null +++ b/packages/app/src/components/RevealSnippet/RevealSnippet.module.scss @@ -0,0 +1,29 @@ +.code { + margin: 0; + padding: 12px 14px; + overflow-x: auto; + border-radius: var(--mantine-radius-sm); + background: var(--color-bg-field); + color: var(--mantine-color-text); + font-family: var(--mantine-font-family-monospace); + font-size: var(--mantine-font-size-sm); + line-height: 1.6; + white-space: pre; +} + +.wrap { + white-space: pre-wrap; + overflow-wrap: break-word; +} + +// Shared muted-at-rest treatment for the Reveal and Copy buttons. Uses the +// themed text color (not Mantine "dimmed") so it stays legible in light/dark +// and across brands; both buttons carry `c="inherit"` to pick up this color. +.reveal { + color: var(--color-text-muted); + transition: color 100ms ease; + + &:hover { + color: var(--color-text); + } +} diff --git a/packages/app/src/components/RevealSnippet/RevealSnippet.stories.tsx b/packages/app/src/components/RevealSnippet/RevealSnippet.stories.tsx new file mode 100644 index 0000000000..2c701c89ad --- /dev/null +++ b/packages/app/src/components/RevealSnippet/RevealSnippet.stories.tsx @@ -0,0 +1,374 @@ +import { Box, Card, Group, Paper, Stack, Text } from '@mantine/core'; +import type { Meta, StoryObj } from '@storybook/nextjs'; + +import { RevealSnippet } from './RevealSnippet'; + +const SAMPLE_API_KEY = 'hdx_ing_9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c'; +const SAMPLE_ACCESS_KEY = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; +const SAMPLE_ORG_ID = 'org_abc123def456'; +const SAMPLE_ENDPOINT = 'https://abc123.otel.clickhouse.cloud:4318'; + +const COLLECTOR_YAML = [ + 'exporters:', + ' otlphttp:', + ` endpoint: ${SAMPLE_ENDPOINT}`, + ' headers:', + ` authorization: ${SAMPLE_API_KEY}`, +].join('\n'); + +const AI_PROMPT = [ + 'Instrument this app with OpenTelemetry and export to:', + ` endpoint: ${SAMPLE_ENDPOINT}`, + ` authorization: ${SAMPLE_API_KEY}`, + ` org: ${SAMPLE_ORG_ID}`, +].join('\n'); + +const MCP_JSON = [ + '{', + ' "mcpServers": {', + ' "hyperdx": {', + ' "command": "npx",', + ' "args": ["-y", "@hyperdx/mcp-server"],', + ' "env": {', + ` "HYPERDX_API_KEY": "${SAMPLE_ACCESS_KEY}"`, + ' }', + ' }', + ' }', + '}', +].join('\n'); + +const CLAUDE_CLI_ONELINER = `claude mcp add hyperdx -- npx -y @hyperdx/mcp-server --api-key ${SAMPLE_ACCESS_KEY}`; + +/** + * `RevealSnippet` is a generic, reveal-aware code snippet. You give it the + * snippet with real secrets already inlined plus a set of `[real, redacted]` + * pairs; it masks those values for display until the user reveals them, while + * copy always hands back the real text. + * + * It's a **compound component**. The parts share reveal state via context, so + * the surrounding layout (header row, panel chrome, button placement) is + * entirely up to the caller: + * + * - `RevealSnippet.Code` — the snippet body as a code block (redacted at rest). + * - `RevealSnippet.Reveal` — the show/hide toggle. Renders nothing when there's + * nothing to reveal or `canReveal={false}`. + * - `RevealSnippet.Copy` — copies the real value regardless of reveal state + * (`variant="icon"` or `"button"`). + * - `RevealSnippet.Input` — a self-contained read-only field for a single + * secret (its own inline eye + copy); use it instead of Code/Reveal/Copy. + * + * **Masking rules** (the `secrets` prop): + * - Omitted or empty → nothing is masked; renders as a plain copyable snippet + * (the safe default). + * - `secrets={[value]}` (bare string) → the whole value is masked via + * `defaultRedact` (prefix + dots). Good for a bare credential like an API key. + * - `secrets={[[real, redacted], …]}` → only those substrings are masked. Good + * for a config/prompt where the secret is embedded in surrounding text. + * - Entries can be mixed, and `null` entries are skipped — so + * `secrets={[maybeKey]}` is safe. + * + * This is the shared primitive behind the Team Settings API-key fields and the + * MCP install snippets (CLI command, deep-link JSON fallback, plain JSON). + * + * Use the **Brand** (HyperDX / ClickStack) and **Theme** (Light / Dark) toolbar + * toggles to review every combination. + */ +const meta: Meta = { + title: 'Components/RevealSnippet', + globals: { brand: 'clickstack' }, + component: RevealSnippet, + parameters: { layout: 'padded' }, + decorators: [ + Story => ( + + + + ), + ], +}; + +export default meta; + +type Story = StoryObj; + +/** + * The whole `value` is the secret. Pass it as a bare string in `secrets` and + * the component masks it via `defaultRedact` — the first few chars stay visible + * and the rest becomes dots, padded to the original length so the field width + * doesn't jump on reveal. This is how the Team Settings API-key fields render. + */ +export const ApiKeyField: Story = { + name: 'API key field (Input, whole-value mask)', + render: () => ( + + + Ingestion API key + + + + + + ), +}; + +/** + * A single masked secret embedded in a larger config. Only the API key is + * replaced with its stand-in; the rest of the YAML stays readable. Reveal and + * an icon copy sit in the header row; `Code` scrolls horizontally (`wrap=false`) + * so YAML indentation is preserved. + */ +export const SingleSecretInConfig: Story = { + name: 'Single secret in a config', + render: () => ( + + + + + Collector config + + + + + + + + + + ), +}; + +/** + * Multiple `[real, redacted]` pairs — the API key and the org id are masked + * independently and both restored on reveal. Copy always yields the real text. + * The reveal label is customized (`Reveal secrets` / `Hide secrets`) and the + * copy renders as a labelled button. + */ +export const MultipleSecrets: Story = { + render: () => ( + + + + + AI instrumentation prompt + + + + + + + + + + ), +}; + +/** + * The MCP install shapes the onboarding panel renders, both masking the + * personal access key: a one-line CLI command and a JSON config block. Each is + * an independent `RevealSnippet`, so reveal state is scoped per snippet. + */ +export const McpInstallSnippets: Story = { + name: 'MCP install snippets', + render: () => ( + + + + + + Paste in your terminal: + + + + + + + + + + + + + + + Or paste this JSON into Cursor settings > MCP: + + + + + + + + + + + ), +}; + +/** + * `RevealSnippet.Input` is fully self-contained — it renders its own inline + * reveal eye and copy icon, so it needs no sibling `Reveal` / `Copy`. Stack + * several to show a credentials block (each row scoped to its own value). + */ +export const InputCredentialsBlock: Story = { + name: 'Input — credentials block', + render: () => ( + + + + + Ingestion API key + + + + + + + + Personal access key + + + + + + + + ), +}; + +/** + * The `Reveal` render-prop override: keep the reveal state + "nothing to + * reveal" gating from the component, but render a completely custom control. + * Here the whole header row acts as the toggle. + */ +export const CustomRevealControl: Story = { + name: 'Custom reveal control (render prop)', + render: () => ( + + + + {({ revealed, toggle, label }) => ( + + + Collector config + + + {revealed ? 'Click to hide' : 'Click to reveal'} + + + )} + + + + + ), +}; + +/** + * Reveal disabled (e.g. a non-admin viewer): the snippet stays redacted and the + * `Reveal` toggle renders nothing, but copy is still available — copy hands back + * the real value even when reveal is gated off. + */ +export const RevealDisabled: Story = { + name: 'Reveal disabled (non-admin)', + render: () => ( + + + + + Collector config + + + + + + + + + + ), +}; + +/** + * A nullable secret (`secrets={[null]}`) — e.g. a ClickHouse Cloud deployment + * that has no key to embed. Nothing is masked, the reveal toggle is absent, and + * the snippet behaves like a plain copyable block. Guards against a crash when + * a caller passes `secrets={[maybeSecret]}` and the value is `null`. + */ +export const NullableSecret: Story = { + name: 'Nullable secret (nothing to reveal)', + render: () => ( + + + + + Paste this into your host's MCP config: + + + + + + + + + + ), +}; + +/** + * No secrets at all — behaves like a plain copyable snippet; the reveal toggle + * is absent because there is nothing to unmask. This is the default: with + * `secrets` omitted, the value is shown as-is. `wrap` lets a long + * single line break instead of scrolling. + */ +export const NoSecrets: Story = { + render: () => ( + + + + + Install the SDK + + + + + + + ), +}; diff --git a/packages/app/src/components/RevealSnippet/RevealSnippet.tsx b/packages/app/src/components/RevealSnippet/RevealSnippet.tsx new file mode 100644 index 0000000000..725e10e85d --- /dev/null +++ b/packages/app/src/components/RevealSnippet/RevealSnippet.tsx @@ -0,0 +1,359 @@ +import { createContext, ReactNode, use, useMemo, useState } from 'react'; +import { + ActionIcon, + Button, + CopyButton, + Group, + TextInput, + Tooltip, +} from '@mantine/core'; +import { IconCheck, IconCopy, IconEye, IconEyeOff } from '@tabler/icons-react'; + +import styles from './RevealSnippet.module.scss'; + +/** + * A `[real, redacted]` pair: the real value copy hands back, and the + * shoulder-surfing-safe stand-in shown until revealed — e.g. + * `['hdx_ing_9f8a7b6c...', 'hdx_api_xxx']`. + */ +type Secret = readonly [real: string, redacted: string]; + +/** + * A secret to mask. Either an explicit `[real, redacted]` pair, or a bare + * string (the whole value is the secret) that is auto-redacted via + * `defaultRedact` — so `secrets={[apiKey]}` masks `apiKey` as prefix + dots. + */ +type SecretInput = Secret | string; + +interface RevealSnippetContextValue { + realText: string; + redactedText: string; + revealed: boolean; + /** Toggle between real and redacted. Null when reveal is disabled. */ + toggle: (() => void) | null; + hasSecrets: boolean; +} + +const RevealSnippetContext = createContext( + null, +); + +const NO_SECRETS: readonly Secret[] = []; + +function useRevealSnippet(component: string): RevealSnippetContextValue { + const ctx = use(RevealSnippetContext); + if (!ctx) { + throw new Error(`${component} must be used within `); + } + return ctx; +} + +/** Characters of the value kept visible in the default auto-redaction. */ +const DEFAULT_VISIBLE_PREFIX = 4; + +/** + * Masks a whole value as prefix + dots, padded to the original length so the + * field width doesn't jump on reveal. Applied automatically to bare-string + * `secrets` entries (the whole value is the secret, e.g. an API key). + * + * The visible prefix is clamped to at most half the value so short inputs + * still get masked rather than shown in full (a 4-char value wouldn't be + * redacted at all if the whole thing were kept as prefix). + * + * Exported so tests and callers can assert against the exact masked string + * instead of re-deriving it. + */ +export function defaultRedact(value: string): string { + const visible = Math.min( + DEFAULT_VISIBLE_PREFIX, + Math.floor(value.length / 2), + ); + const prefix = value.slice(0, visible); + const maskedLength = Math.max(0, value.length - prefix.length); + return `${prefix}${'•'.repeat(maskedLength)}`; +} + +/** + * Normalizes a caller-supplied secret into a usable `[real, redacted]` pair, + * or `null` when there's nothing to mask. A bare string is auto-redacted via + * `defaultRedact`; nullable entries (e.g. a CHC deployment has no key) and + * empty reals are dropped rather than throwing. + */ +function normalizeSecret(entry: SecretInput | null | undefined): Secret | null { + if (typeof entry === 'string') { + return entry ? [entry, defaultRedact(entry)] : null; + } + return Array.isArray(entry) && entry[0] ? entry : null; +} + +/** + * Replaces every occurrence of each secret's real value with its stand-in. + * Longer real values are substituted first so that when one secret's real + * value is a substring of another, the more specific (longer) match wins and + * the replacement is order-independent. + */ +function redact(text: string, secrets: readonly Secret[]): string { + return [...secrets] + .sort((a, b) => b[0].length - a[0].length) + .reduce( + (acc, [real, redacted]) => acc.split(real).join(redacted), + text, + ); +} + +interface RevealSnippetProps { + /** Snippet text with real secret values already inlined. */ + value: string; + /** + * Secrets to mask in `value`. Each entry is either an explicit + * `[real, redacted]` pair, or a bare string whose whole value is masked + * via `defaultRedact` (e.g. `secrets={[apiKey]}` for an API-key field). + * Nullable entries are skipped, so `secrets={[maybeKey]}` is safe. Omitted + * or empty → nothing is masked and the snippet is a plain copyable block. + */ + secrets?: readonly (SecretInput | null)[]; + /** When false, the snippet stays redacted and reveal is unavailable. */ + canReveal?: boolean; + children: ReactNode; +} + +/** + * Reveal-aware snippet. Holds the real value plus its secrets and shares + * reveal state with its subcomponents (`Reveal`, `Code`, `Input`, `Copy`) + * via context, so the surrounding layout is up to the caller. + */ +export function RevealSnippet({ + value, + secrets, + canReveal = true, + children, +}: RevealSnippetProps) { + const [revealed, setRevealed] = useState(false); + + // Re-mask when the underlying value changes (e.g. an API key is rotated + // while revealed). Adjusting state during render — React's recommended + // pattern over an effect — re-hides the new value before it ever paints, + // so a freshly issued credential is never shown without a fresh reveal. + const [prevValue, setPrevValue] = useState(value); + if (value !== prevValue) { + setPrevValue(value); + setRevealed(false); + } + + // Normalize entries to pairs (bare strings auto-redacted), dropping the + // ones with nothing to mask. Omitted/empty → nothing is masked (safe, + // least-surprising default). + const resolvedSecrets = useMemo(() => { + if (!secrets) return NO_SECRETS; + return secrets.map(normalizeSecret).filter((s): s is Secret => s !== null); + }, [secrets]); + + const redactedText = useMemo( + () => redact(value, resolvedSecrets), + [value, resolvedSecrets], + ); + + const hasSecrets = resolvedSecrets.length > 0; + const canToggle = canReveal && hasSecrets; + + const ctx = useMemo( + () => ({ + realText: value, + redactedText, + // Gate on canToggle so a stale revealed=true can't leak after + // canReveal flips to false. + revealed: canToggle && revealed, + toggle: canToggle ? () => setRevealed(v => !v) : null, + hasSecrets, + }), + [value, redactedText, revealed, canToggle, hasSecrets], + ); + + return {children}; +} + +/** State handed to a custom `Reveal` renderer. */ +interface RevealRenderProps { + revealed: boolean; + toggle: () => void; + label: string; +} + +interface RevealProps { + /** Label when hidden. Default "Reveal key". */ + showLabel?: string; + /** Label when shown. Default "Hide key". */ + hideLabel?: string; + /** Render-prop override; keeps the state + gating, replaces the button. */ + children?: (props: RevealRenderProps) => ReactNode; +} + +/** + * Toggle that flips the snippet between redacted and real. Renders + * nothing when there is nothing to reveal or `canReveal` is false, so a + * custom renderer never has to re-implement that gating. + */ +function Reveal({ + showLabel = 'Reveal key', + hideLabel = 'Hide key', + children, +}: RevealProps) { + const { revealed, toggle } = useRevealSnippet('RevealSnippet.Reveal'); + if (!toggle) return null; + const label = revealed ? hideLabel : showLabel; + + if (children) { + return <>{children({ revealed, toggle, label })}; + } + + return ( + + ); +} + +interface CodeProps { + /** Wrap long lines instead of scrolling horizontally. Default true. */ + wrap?: boolean; +} + +/** The snippet body, as a code block. Redacted until revealed. */ +function Code({ wrap = true }: CodeProps) { + const { realText, redactedText, revealed } = + useRevealSnippet('RevealSnippet.Code'); + return ( +
+      {revealed ? realText : redactedText}
+    
+ ); +} + +interface InputProps { + 'aria-label'?: string; + /** Reveal-toggle aria label. Default "Reveal value". */ + revealLabel?: string; + /** Copy aria label / tooltip. Default "Copy". */ + copyLabel?: string; + 'data-testid'?: string; +} + +/** + * Read-only field for a secret value (e.g. an API key), with the reveal + * eye and copy icon inline in the right section. Self-contained: renders + * its own reveal + copy, so it needs no sibling `Reveal` / `Copy`. + */ +function Input({ + 'aria-label': ariaLabel, + revealLabel = 'Reveal value', + copyLabel = 'Copy', + 'data-testid': dataTestId, +}: InputProps) { + const { realText, redactedText, revealed, toggle } = useRevealSnippet( + 'RevealSnippet.Input', + ); + + return ( + + {toggle ? ( + + + {revealed ? : } + + + ) : null} + + {({ copied, copy }) => ( + + + {copied ? : } + + + )} + + + } + /> + ); +} + +interface CopyProps { + /** Tooltip / aria label. Default "Copy". */ + label?: string; + /** Render a labelled button instead of an icon-only action. */ + variant?: 'icon' | 'button'; +} + +/** Copies the real value regardless of reveal state. */ +function Copy({ label = 'Copy', variant = 'icon' }: CopyProps) { + const { realText } = useRevealSnippet('RevealSnippet.Copy'); + return ( + + {({ copied, copy }) => + variant === 'button' ? ( + + ) : ( + + + {copied ? : } + + + ) + } + + ); +} + +RevealSnippet.Reveal = Reveal; +RevealSnippet.Code = Code; +RevealSnippet.Input = Input; +RevealSnippet.Copy = Copy; diff --git a/packages/app/src/components/RevealSnippet/__tests__/RevealSnippet.test.tsx b/packages/app/src/components/RevealSnippet/__tests__/RevealSnippet.test.tsx new file mode 100644 index 0000000000..215f921caa --- /dev/null +++ b/packages/app/src/components/RevealSnippet/__tests__/RevealSnippet.test.tsx @@ -0,0 +1,255 @@ +import { MantineProvider } from '@mantine/core'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { RevealSnippet } from '@/components/RevealSnippet/RevealSnippet'; + +const REAL_KEY = 'abcdef0123456789deadbeefcafef00d'; +const SNIPPET = `Authorization: Bearer ${REAL_KEY}`; + +function renderSnippet(ui: React.ReactNode) { + return render({ui}); +} + +describe('RevealSnippet', () => { + it('masks the secret by default and reveals it on toggle', async () => { + const user = userEvent.setup(); + renderSnippet( + + + + , + ); + + expect(screen.queryByText(new RegExp(REAL_KEY))).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /reveal key/i })); + + expect(screen.getByText(new RegExp(REAL_KEY))).toBeInTheDocument(); + }); + + it('shows the value as-is when secrets is omitted (no auto-masking)', () => { + // Regression: omitting `secrets` must NOT mask the value. The safe + // default is a plain, fully-visible snippet. + renderSnippet( + + + + , + ); + + expect(screen.getByText(new RegExp(REAL_KEY))).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /reveal key/i }), + ).not.toBeInTheDocument(); + }); + + it('shows the value as-is when secrets is empty', () => { + renderSnippet( + + + + , + ); + + expect(screen.getByText(new RegExp(REAL_KEY))).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /reveal key/i }), + ).not.toBeInTheDocument(); + }); + + it('masks a bare-string secret via defaultRedact and reveals on toggle', async () => { + const user = userEvent.setup(); + renderSnippet( + + + + , + ); + + // Bare string → whole value masked, but the visible prefix remains. + expect(screen.queryByText(REAL_KEY)).not.toBeInTheDocument(); + expect(screen.getByText(/^abcd•+$/)).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /reveal key/i })); + + expect(screen.getByText(REAL_KEY)).toBeInTheDocument(); + }); + + it('masks a bare-string secret as a substring of a larger snippet', () => { + renderSnippet( + + + , + ); + + // Only the key is masked; the surrounding text stays readable. + expect(screen.queryByText(new RegExp(REAL_KEY))).not.toBeInTheDocument(); + expect( + screen.getByText(/^Authorization: Bearer abcd•+$/), + ).toBeInTheDocument(); + }); + + it('renders the snippet unchanged when a null secret is passed (no throw)', () => { + // Regression: callers pass `secrets={[maybeSecret]}` where the + // entry can be `null` (e.g. a CHC deployment has no key). This must + // not throw while destructuring; the snippet renders as-is. + renderSnippet( + + + + , + ); + + expect(screen.getByText(new RegExp(REAL_KEY))).toBeInTheDocument(); + }); + + it('omits the reveal toggle when there is nothing to reveal', () => { + renderSnippet( + + + + , + ); + + expect( + screen.queryByRole('button', { name: /reveal key/i }), + ).not.toBeInTheDocument(); + }); + + it('omits the reveal toggle when canReveal is false but still copies', () => { + renderSnippet( + + + + + , + ); + + expect(screen.queryByText(new RegExp(REAL_KEY))).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /reveal key/i }), + ).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /copy/i })).toBeInTheDocument(); + }); + + it('renders a custom Reveal via render prop and still toggles', async () => { + const user = userEvent.setup(); + renderSnippet( + + + {({ revealed, toggle, label }) => ( + + )} + + + , + ); + + // Default button is overridden — only the custom control is present. + const custom = screen.getByTestId('custom-reveal'); + expect(custom).toHaveTextContent('hidden: Reveal key'); + expect(screen.queryByText(new RegExp(REAL_KEY))).not.toBeInTheDocument(); + + await user.click(custom); + + expect(custom).toHaveTextContent('shown: Hide key'); + expect(screen.getByText(new RegExp(REAL_KEY))).toBeInTheDocument(); + }); + + it('renders nothing for a custom Reveal when there is nothing to reveal', () => { + // The "nothing to reveal" gating stays in the component, so a custom + // renderer never has to re-implement it. + renderSnippet( + + + {({ toggle }) => ( + + )} + + + , + ); + + expect(screen.queryByTestId('custom-reveal')).not.toBeInTheDocument(); + }); + + it('re-masks when the value changes after being revealed (key rotation)', async () => { + // Regression: a revealed key that is then rotated must NOT leak the new + // value. The component re-hides on value change so a fresh reveal is + // required for the rotated key. + const user = userEvent.setup(); + const ROTATED_KEY = 'ffffffff1111111122222222deadbeef'; + + const ui = (key: string) => ( + + + + + ); + + const { rerender } = render( + {ui(REAL_KEY)}, + ); + + // Reveal the original key. + await user.click(screen.getByRole('button', { name: /reveal key/i })); + expect(screen.getByText(REAL_KEY)).toBeInTheDocument(); + + // Rotate: same instance, new value. It must come back masked. + rerender({ui(ROTATED_KEY)}); + + expect(screen.queryByText(ROTATED_KEY)).not.toBeInTheDocument(); + expect(screen.getByText(/^ffff•+$/)).toBeInTheDocument(); + // And the reveal toggle is back to its hidden label. + expect( + screen.getByRole('button', { name: /reveal key/i }), + ).toBeInTheDocument(); + }); + + describe('copy without revealing', () => { + // userEvent.setup() attaches its own clipboard stub; assert against it via + // readText() rather than mocking navigator.clipboard ourselves. + it('RevealSnippet.Copy puts the real value on the clipboard while masked', async () => { + const user = userEvent.setup(); + renderSnippet( + + + + , + ); + + // Still masked on screen... + expect(screen.queryByText(new RegExp(REAL_KEY))).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /copy/i })); + + // ...but the clipboard got the real, unmasked snippet. + await expect(navigator.clipboard.readText()).resolves.toBe(SNIPPET); + }); + + it('RevealSnippet.Input puts the real value on the clipboard while masked', async () => { + const user = userEvent.setup(); + renderSnippet( + + + , + ); + + // Field shows the masked value, not the real key. + expect(screen.getByLabelText('API key')).not.toHaveValue(REAL_KEY); + + await user.click(screen.getByRole('button', { name: /copy key/i })); + + await expect(navigator.clipboard.readText()).resolves.toBe(REAL_KEY); + }); + }); +}); diff --git a/packages/app/src/components/TeamSettings/ApiKeysSection.tsx b/packages/app/src/components/TeamSettings/ApiKeysSection.tsx index edbc77f7aa..03f4d9b29e 100644 --- a/packages/app/src/components/TeamSettings/ApiKeysSection.tsx +++ b/packages/app/src/components/TeamSettings/ApiKeysSection.tsx @@ -1,39 +1,32 @@ -import { useState } from 'react'; -import { CopyToClipboard } from 'react-copy-to-clipboard'; import { Box, Button, Card, Divider, Group, Text } from '@mantine/core'; import { notifications } from '@mantine/notifications'; -import { IconCheck, IconClipboard } from '@tabler/icons-react'; import api from '@/api'; +import { RevealSnippet } from '@/components/RevealSnippet/RevealSnippet'; import { useConfirm } from '@/useConfirm'; +// The reveal Input fills its container, so cap its width here at the parent. +const KEY_FIELD_MAX_WIDTH = 420; + +/** Masked, read-only API-key field with an inline reveal eye and copy icon. */ function APIKeyCopyButton({ value, dataTestId, + ariaLabel, }: { value: string; dataTestId?: string; + ariaLabel?: string; }) { - const [copied, setCopied] = useState(false); - return ( - - - + + + ); } @@ -119,12 +112,15 @@ export default function ApiKeysSection() { Ingestion API key - + {team?.apiKey && ( - + + + )} {hasAdminAccess && (