Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 .changeset/reveal-snippet-component.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
* `<CopyButton>`.
*/
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 (
<RevealSnippet value={snippet} secrets={[accessKey]}>
<Stack gap="xs">
<Group justify="space-between" align="center" wrap="nowrap">
{label ? (
<Text size="sm" fw={500}>
{label}
</Text>
) : (
<span />
)}
<Group gap="xs" wrap="nowrap">
<RevealSnippet.Reveal />
<RevealSnippet.Copy variant="button" />
</Group>
</Group>
<RevealSnippet.Code />
</Stack>
</RevealSnippet>
);
}

return (
<Stack gap="xs">
{label ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <host>" 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 <host>" 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);
Expand All @@ -48,7 +47,11 @@ export function DeeplinkInstall({
</Group>
{note}
<Collapse expanded={manualOpen} transitionDuration={150}>
<CopySnippet label={fallbackLabel} snippet={fallbackSnippet} />
<CopySnippet
label={fallbackLabel}
snippet={fallbackSnippet}
accessKey={fallbackAccessKey}
/>
</Collapse>
</Stack>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<AgentHost>('claude-code');
Expand All @@ -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);
}
Expand All @@ -108,7 +85,11 @@ export default function McpInstallPanel({ deployment }: McpInstallPanelProps) {
aria-label="MCP host"
/>

<HostInstall host={host} snippets={snippets} />
<HostInstall
host={host}
snippets={snippets}
accessKey={deployment.accessKey}
/>
</Stack>
);
}
Expand All @@ -128,9 +109,7 @@ function HostIcon({ id }: { id: AgentHost }) {
case 'other':
return <IconRobot size={16} />;
}
// 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;
Expand All @@ -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 (
<CopySnippet
label="Paste in your terminal:"
snippet={snippets.claudeCode}
accessKey={accessKey}
/>
);

Expand All @@ -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}
/>
);

Expand All @@ -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={
<Text size="xs" c="dimmed">
Requires VS Code 1.99+ with the Copilot Chat MCP feature enabled.
Expand All @@ -181,6 +165,7 @@ function HostInstall({ host, snippets }: HostInstallProps) {
<CopySnippet
label="Paste in your terminal:"
snippet={snippets.codexCli}
accessKey={accessKey}
/>
);

Expand All @@ -189,6 +174,7 @@ function HostInstall({ host, snippets }: HostInstallProps) {
<CopySnippet
label="Paste this into `opencode.json` (project) or `~/.config/opencode/config.json` (global):"
snippet={snippets.openCode}
accessKey={accessKey}
/>
);

Expand All @@ -197,12 +183,11 @@ function HostInstall({ host, snippets }: HostInstallProps) {
<CopySnippet
label="Paste this into your host's MCP config:"
snippet={snippets.jsonBlock}
accessKey={accessKey}
/>
);
}
// 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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
<MantineProvider>
<McpInstallPanel deployment={DEPLOYMENT} />
</MantineProvider>,
);
}

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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Comment thread
brandon-pereira marked this conversation as resolved.
Resolved
color: var(--color-text-muted);
transition: color 100ms ease;

&:hover {
color: var(--color-text);
}
}
Loading
Loading