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>
This commit is contained in:
264
dashboard/src/pages/Credentials.tsx
Normal file
264
dashboard/src/pages/Credentials.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user