Files
sentryagent-idp/dashboard/src/pages/Credentials.tsx
SentryAgent.ai Developer 7d6e248a14 feat(phase-2): workstream 6 — Web Dashboard UI
- dashboard/: Vite 5 + React 18 + TypeScript strict SPA
  - Auth: sessionStorage credentials, TokenManager validation, AuthProvider context
  - Pages: Login, Agents (search + filter), AgentDetail (suspend/reactivate),
    Credentials (generate/rotate/revoke, new secret shown once),
    AuditLog (filters + pagination), Health (PG + Redis status, 30s refresh)
  - Components: Button, Badge, ConfirmDialog, AppShell, RequireAuth
  - All destructive actions gated by ConfirmDialog
  - Zero dangerouslySetInnerHTML; sessionStorage only (OWASP compliant)
- src/routes/health.ts: unauthenticated GET /health — PG + Redis connectivity
- src/app.ts: health route + dashboard/dist/ served at /dashboard with SPA fallback
- 6 new health route tests; 308/308 unit tests passing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 23:19:18 +00:00

265 lines
9.3 KiB
TypeScript

import * as React from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import type { Credential, CredentialWithSecret } from '@sentryagent/idp-sdk';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ConfirmDialog } from '@/components/ui/dialog';
import { getClient } from '@/lib/client';
/** Truncates a string to a maximum length with ellipsis. */
function truncate(value: string, maxLen = 16): string {
return value.length > maxLen ? `${value.slice(0, maxLen)}` : value;
}
/** Formats an ISO timestamp to a short local date string. */
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
}
interface NewSecretBoxProps {
secret: string;
onDismiss: () => void;
}
/**
* Displays a newly issued client secret exactly once.
* Provides a copy button and a dismiss button.
*/
function NewSecretBox({ secret, onDismiss }: NewSecretBoxProps): React.JSX.Element {
const [copied, setCopied] = React.useState<boolean>(false);
const handleCopy = React.useCallback(async (): Promise<void> => {
await navigator.clipboard.writeText(secret);
setCopied(true);
setTimeout(() => { setCopied(false); }, 2000);
}, [secret]);
return (
<div className="mb-6 rounded-lg border-2 border-green-400 bg-green-50 p-4">
<p className="mb-2 text-sm font-semibold text-green-800">
New client secret copy it now. It will not be shown again.
</p>
<div className="flex items-center gap-3">
<code className="flex-1 break-all rounded bg-white px-3 py-2 text-sm font-mono text-green-900 border border-green-200">
{secret}
</code>
<Button variant="outline" size="sm" onClick={() => { void handleCopy(); }}>
{copied ? 'Copied!' : 'Copy'}
</Button>
</div>
<button
onClick={onDismiss}
className="mt-3 text-xs text-green-700 underline hover:text-green-900"
>
I have saved this secret dismiss
</button>
</div>
);
}
type DialogAction = { type: 'rotate'; credentialId: string } | { type: 'revoke'; credentialId: string };
/**
* Credentials page — lists all credentials for an agent with rotate/revoke actions.
* Route: /dashboard/agents/:agentId/credentials
*/
export default function Credentials(): React.JSX.Element {
const { agentId } = useParams<{ agentId: string }>();
const navigate = useNavigate();
const [credentials, setCredentials] = React.useState<Credential[]>([]);
const [loading, setLoading] = React.useState<boolean>(true);
const [error, setError] = React.useState<string | null>(null);
const [actionLoading, setActionLoading] = React.useState<boolean>(false);
const [dialog, setDialog] = React.useState<DialogAction | null>(null);
const [newSecret, setNewSecret] = React.useState<CredentialWithSecret | null>(null);
const fetchCredentials = React.useCallback(async (): Promise<void> => {
if (!agentId) return;
setLoading(true);
setError(null);
try {
const result = await getClient().credentials.listCredentials(agentId);
setCredentials(result.data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load credentials.');
} finally {
setLoading(false);
}
}, [agentId]);
React.useEffect(() => {
void fetchCredentials();
}, [fetchCredentials]);
const handleGenerate = React.useCallback(async (): Promise<void> => {
if (!agentId) return;
setActionLoading(true);
setError(null);
try {
const result = await getClient().credentials.generateCredential(agentId, {});
setNewSecret(result);
await fetchCredentials();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to generate credential.');
} finally {
setActionLoading(false);
}
}, [agentId, fetchCredentials]);
const handleConfirm = React.useCallback(async (): Promise<void> => {
if (!dialog || !agentId) return;
setActionLoading(true);
setDialog(null);
setError(null);
try {
if (dialog.type === 'rotate') {
const result = await getClient().credentials.rotateCredential(agentId, dialog.credentialId);
setNewSecret(result);
} else {
await getClient().credentials.revokeCredential(agentId, dialog.credentialId);
}
await fetchCredentials();
} catch (err) {
setError(err instanceof Error ? err.message : `Failed to ${dialog.type} credential.`);
} finally {
setActionLoading(false);
}
}, [dialog, agentId, fetchCredentials]);
const dialogConfig = React.useMemo(() => {
if (!dialog) return null;
if (dialog.type === 'rotate') {
return {
title: 'Rotate credential?',
description: 'The existing secret will be invalidated immediately. You will receive a new secret — store it securely.',
confirmLabel: 'Rotate',
variant: 'destructive' as const,
};
}
return {
title: 'Revoke credential?',
description: 'This will permanently revoke the credential. This cannot be undone.',
confirmLabel: 'Revoke',
variant: 'destructive' as const,
};
}, [dialog]);
return (
<div>
{/* Back navigation */}
<button
onClick={() => { navigate(`/dashboard/agents/${agentId ?? ''}`); }}
className="mb-6 flex items-center gap-1 text-sm text-brand-600 hover:text-brand-800"
>
Back to Agent
</button>
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-bold text-slate-900">Credentials</h1>
<Button
loading={actionLoading}
onClick={() => { void handleGenerate(); }}
>
Generate Credential
</Button>
</div>
{error && (
<div className="mb-4 rounded-md bg-red-50 px-4 py-3 text-sm text-red-700" role="alert">
{error}
</div>
)}
{/* New secret display — shown once */}
{newSecret !== null && (
<NewSecretBox
secret={newSecret.clientSecret}
onDismiss={() => { setNewSecret(null); }}
/>
)}
{/* Credentials table */}
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<table className="min-w-full divide-y divide-slate-200 text-sm">
<thead className="bg-slate-50">
<tr>
{['Credential ID', 'Status', 'Created', 'Actions'].map((col) => (
<th key={col} className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
{col}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{loading ? (
Array.from({ length: 3 }).map((_, i) => (
<tr key={i}>
{Array.from({ length: 4 }).map((__, j) => (
<td key={j} className="px-4 py-3">
<div className="h-4 w-full animate-pulse rounded bg-slate-200" />
</td>
))}
</tr>
))
) : credentials.length === 0 ? (
<tr>
<td colSpan={4} className="px-4 py-12 text-center text-slate-400">
No credentials found. Generate one above.
</td>
</tr>
) : credentials.map((cred) => (
<tr key={cred.credentialId} className="hover:bg-slate-50">
<td className="px-4 py-3 font-mono text-xs text-slate-700">
{truncate(cred.credentialId, 24)}
</td>
<td className="px-4 py-3">
<Badge variant={cred.status === 'active' ? 'success' : 'muted'}>
{cred.status}
</Badge>
</td>
<td className="px-4 py-3 text-slate-500">{formatDate(cred.createdAt)}</td>
<td className="px-4 py-3">
{cred.status === 'active' && (
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={actionLoading}
onClick={() => { setDialog({ type: 'rotate', credentialId: cred.credentialId }); }}
>
Rotate
</Button>
<Button
variant="destructive"
size="sm"
disabled={actionLoading}
onClick={() => { setDialog({ type: 'revoke', credentialId: cred.credentialId }); }}
>
Revoke
</Button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Confirm dialog */}
{dialog !== null && dialogConfig !== null && (
<ConfirmDialog
open
title={dialogConfig.title}
description={dialogConfig.description}
confirmLabel={dialogConfig.confirmLabel}
variant={dialogConfig.variant}
onConfirm={() => { void handleConfirm(); }}
onCancel={() => { setDialog(null); }}
/>
)}
</div>
);
}