feat: add per-app sensitive keys section to AppConfigDetailPage
Adds sensitiveKeys/globalSensitiveKeys/mergedSensitiveKeys fields to ApplicationConfig, unwraps the new AppConfigResponse envelope in useApplicationConfig, and renders an editable Sensitive Keys section with read-only global pills and add/remove app-specific key tags. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,9 @@ export interface ApplicationConfig {
|
|||||||
tapVersion: number
|
tapVersion: number
|
||||||
routeRecording: Record<string, boolean>
|
routeRecording: Record<string, boolean>
|
||||||
compressSuccess: boolean
|
compressSuccess: boolean
|
||||||
|
sensitiveKeys?: string[]
|
||||||
|
globalSensitiveKeys?: string[]
|
||||||
|
mergedSensitiveKeys?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Authenticated fetch using the JWT from auth store. Paths are relative to apiBaseUrl. */
|
/** Authenticated fetch using the JWT from auth store. Paths are relative to apiBaseUrl. */
|
||||||
@@ -58,8 +61,13 @@ export function useApplicationConfig(application: string | undefined) {
|
|||||||
queryKey: ['applicationConfig', application],
|
queryKey: ['applicationConfig', application],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await authFetch(`/config/${application}`)
|
const res = await authFetch(`/config/${application}`)
|
||||||
if (!res.ok) throw new Error('Failed to fetch config')
|
if (!res.ok) throw new Error(`Failed to fetch config: ${res.status}`)
|
||||||
return res.json() as Promise<ApplicationConfig>
|
const data = await res.json()
|
||||||
|
// Server returns AppConfigResponse: { config, globalSensitiveKeys, mergedSensitiveKeys }
|
||||||
|
const cfg = data.config ?? data
|
||||||
|
cfg.globalSensitiveKeys = data.globalSensitiveKeys ?? null
|
||||||
|
cfg.mergedSensitiveKeys = data.mergedSensitiveKeys ?? null
|
||||||
|
return cfg as ApplicationConfig
|
||||||
},
|
},
|
||||||
enabled: !!application,
|
enabled: !!application,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -100,3 +100,23 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sensitiveKeysRow {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pillList {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
min-height: 28px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sensitiveKeyInput {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useState, useMemo } from 'react';
|
|||||||
import { useParams, useNavigate } from 'react-router';
|
import { useParams, useNavigate } from 'react-router';
|
||||||
import { ArrowLeft, Pencil, X } from 'lucide-react';
|
import { ArrowLeft, Pencil, X } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Button, SectionHeader, MonoText, Badge, DataTable, Spinner, Toggle, Select, Label, useToast,
|
Button, SectionHeader, MonoText, Badge, DataTable, Spinner, Toggle, Select, Label, Tag, useToast,
|
||||||
} from '@cameleer/design-system';
|
} from '@cameleer/design-system';
|
||||||
import type { Column } from '@cameleer/design-system';
|
import type { Column } from '@cameleer/design-system';
|
||||||
import { useApplicationConfig, useUpdateApplicationConfig } from '../../api/queries/commands';
|
import { useApplicationConfig, useUpdateApplicationConfig } from '../../api/queries/commands';
|
||||||
@@ -85,6 +85,8 @@ export default function AppConfigDetailPage() {
|
|||||||
const [form, setForm] = useState<Partial<ApplicationConfig> | null>(null);
|
const [form, setForm] = useState<Partial<ApplicationConfig> | null>(null);
|
||||||
const [tracedDraft, setTracedDraft] = useState<Record<string, string>>({});
|
const [tracedDraft, setTracedDraft] = useState<Record<string, string>>({});
|
||||||
const [routeRecordingDraft, setRouteRecordingDraft] = useState<Record<string, boolean>>({});
|
const [routeRecordingDraft, setRouteRecordingDraft] = useState<Record<string, boolean>>({});
|
||||||
|
const [sensitiveKeysDraft, setSensitiveKeysDraft] = useState<string[]>([]);
|
||||||
|
const [sensitiveKeyInput, setSensitiveKeyInput] = useState('');
|
||||||
|
|
||||||
// Find routes for this application from the catalog
|
// Find routes for this application from the catalog
|
||||||
const appRoutes: CatalogRoute[] = useMemo(() => {
|
const appRoutes: CatalogRoute[] = useMemo(() => {
|
||||||
@@ -106,6 +108,7 @@ export default function AppConfigDetailPage() {
|
|||||||
});
|
});
|
||||||
setTracedDraft({ ...config.tracedProcessors });
|
setTracedDraft({ ...config.tracedProcessors });
|
||||||
setRouteRecordingDraft({ ...config.routeRecording });
|
setRouteRecordingDraft({ ...config.routeRecording });
|
||||||
|
setSensitiveKeysDraft([...(config.sensitiveKeys ?? [])]);
|
||||||
}
|
}
|
||||||
}, [config]);
|
}, [config]);
|
||||||
|
|
||||||
@@ -122,6 +125,7 @@ export default function AppConfigDetailPage() {
|
|||||||
});
|
});
|
||||||
setTracedDraft({ ...config.tracedProcessors });
|
setTracedDraft({ ...config.tracedProcessors });
|
||||||
setRouteRecordingDraft({ ...config.routeRecording });
|
setRouteRecordingDraft({ ...config.routeRecording });
|
||||||
|
setSensitiveKeysDraft([...(config.sensitiveKeys ?? [])]);
|
||||||
setEditing(true);
|
setEditing(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,6 +152,7 @@ export default function AppConfigDetailPage() {
|
|||||||
...form,
|
...form,
|
||||||
tracedProcessors: tracedDraft,
|
tracedProcessors: tracedDraft,
|
||||||
routeRecording: routeRecordingDraft,
|
routeRecording: routeRecordingDraft,
|
||||||
|
sensitiveKeys: sensitiveKeysDraft.length > 0 ? sensitiveKeysDraft : undefined,
|
||||||
} as ApplicationConfig;
|
} as ApplicationConfig;
|
||||||
updateConfig.mutate({ config: updated, environment: selectedEnv }, {
|
updateConfig.mutate({ config: updated, environment: selectedEnv }, {
|
||||||
onSuccess: (saved: ConfigUpdateResponse) => {
|
onSuccess: (saved: ConfigUpdateResponse) => {
|
||||||
@@ -468,6 +473,76 @@ export default function AppConfigDetailPage() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Sensitive Keys ──────────────────────────────────────────────────── */}
|
||||||
|
<div className={sectionStyles.section}>
|
||||||
|
<SectionHeader>Sensitive Keys</SectionHeader>
|
||||||
|
{config.globalSensitiveKeys && config.globalSensitiveKeys.length > 0 ? (
|
||||||
|
<span className={styles.sectionSummary}>
|
||||||
|
{config.globalSensitiveKeys.length} global (enforced) · {(editing ? sensitiveKeysDraft : config.sensitiveKeys ?? []).length} app-specific
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className={styles.sectionSummary}>
|
||||||
|
No global sensitive keys configured. Agents use their built-in defaults.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Global keys — read-only pills */}
|
||||||
|
{config.globalSensitiveKeys && config.globalSensitiveKeys.length > 0 && (
|
||||||
|
<div className={styles.sensitiveKeysRow}>
|
||||||
|
<Label>Global (enforced)</Label>
|
||||||
|
<div className={styles.pillList}>
|
||||||
|
{config.globalSensitiveKeys.map((key) => (
|
||||||
|
<Badge key={key} label={key} color="auto" variant="filled" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Per-app keys — editable */}
|
||||||
|
<div className={styles.sensitiveKeysRow}>
|
||||||
|
<Label>Application-specific</Label>
|
||||||
|
<div className={styles.pillList}>
|
||||||
|
{(editing ? sensitiveKeysDraft : config.sensitiveKeys ?? []).map((key, i) => (
|
||||||
|
editing ? (
|
||||||
|
<Tag key={`${key}-${i}`} label={key} onRemove={() => setSensitiveKeysDraft((prev) => prev.filter((_, idx) => idx !== i))} />
|
||||||
|
) : (
|
||||||
|
<Badge key={key} label={key} color="primary" variant="filled" />
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
{(editing ? sensitiveKeysDraft : config.sensitiveKeys ?? []).length === 0 && (
|
||||||
|
<span className={styles.hint}>None</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{editing && (
|
||||||
|
<div className={styles.sensitiveKeyInput}>
|
||||||
|
<input
|
||||||
|
className={styles.numberInput}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
value={sensitiveKeyInput}
|
||||||
|
onChange={(e) => setSensitiveKeyInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
const trimmed = sensitiveKeyInput.trim();
|
||||||
|
if (trimmed && !sensitiveKeysDraft.some((k) => k.toLowerCase() === trimmed.toLowerCase())) {
|
||||||
|
setSensitiveKeysDraft((prev) => [...prev, trimmed]);
|
||||||
|
setSensitiveKeyInput('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Add key or glob pattern"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.globalSensitiveKeys && config.globalSensitiveKeys.length > 0 && (
|
||||||
|
<span className={styles.hint}>
|
||||||
|
Global keys are enforced by your administrator and cannot be removed per-app.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user