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:
222
dashboard/src/pages/AgentDetail.tsx
Normal file
222
dashboard/src/pages/AgentDetail.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import * as React from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import type { Agent } 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';
|
||||
|
||||
type BadgeVariant = 'success' | 'warning' | 'danger';
|
||||
|
||||
/** Maps AgentStatus to a Badge variant. */
|
||||
function statusVariant(status: Agent['status']): BadgeVariant {
|
||||
switch (status) {
|
||||
case 'active': return 'success';
|
||||
case 'suspended': return 'warning';
|
||||
case 'decommissioned': return 'danger';
|
||||
}
|
||||
}
|
||||
|
||||
/** Formats an ISO timestamp to a readable local date-time string. */
|
||||
function formatDateTime(iso: string): string {
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
interface DetailRowProps {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/** Single label/value row in the detail card. */
|
||||
function DetailRow({ label, value }: DetailRowProps): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:gap-4">
|
||||
<dt className="w-36 shrink-0 text-sm font-medium text-slate-500">{label}</dt>
|
||||
<dd className="text-sm text-slate-900 break-all">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type DialogAction = 'suspend' | 'reactivate';
|
||||
|
||||
/**
|
||||
* Agent Detail page — shows all agent fields and provides suspend/reactivate actions.
|
||||
* Route: /dashboard/agents/:agentId
|
||||
*/
|
||||
export default function AgentDetail(): React.JSX.Element {
|
||||
const { agentId } = useParams<{ agentId: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [agent, setAgent] = React.useState<Agent | null>(null);
|
||||
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);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!agentId) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const fetchAgent = async (): Promise<void> => {
|
||||
try {
|
||||
const result = await getClient().agents.getAgent(agentId);
|
||||
if (!cancelled) setAgent(result);
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to load agent.');
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void fetchAgent();
|
||||
return () => { cancelled = true; };
|
||||
}, [agentId]);
|
||||
|
||||
const handleAction = React.useCallback(
|
||||
async (action: DialogAction): Promise<void> => {
|
||||
if (!agentId) return;
|
||||
setActionLoading(true);
|
||||
setDialog(null);
|
||||
try {
|
||||
const newStatus = action === 'suspend' ? 'suspended' : 'active';
|
||||
const updated = await getClient().agents.updateAgent(agentId, { status: newStatus });
|
||||
setAgent(updated);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Action failed.');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
},
|
||||
[agentId],
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="h-5 w-full animate-pulse rounded bg-slate-200" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !agent) {
|
||||
return (
|
||||
<div className="rounded-md bg-red-50 px-4 py-3 text-sm text-red-700" role="alert">
|
||||
{error ?? 'Agent not found.'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const dialogConfig = dialog === 'suspend'
|
||||
? {
|
||||
title: `Suspend agent ${agent.email}?`,
|
||||
description: `Suspending ${agent.email} means it will no longer be able to authenticate.`,
|
||||
confirmLabel: 'Suspend',
|
||||
variant: 'destructive' as const,
|
||||
}
|
||||
: {
|
||||
title: `Reactivate agent ${agent.email}?`,
|
||||
description: `Reactivating ${agent.email} will allow it to authenticate again.`,
|
||||
confirmLabel: 'Reactivate',
|
||||
variant: 'default' as const,
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Back navigation */}
|
||||
<button
|
||||
onClick={() => { navigate('/dashboard/agents'); }}
|
||||
className="mb-6 flex items-center gap-1 text-sm text-brand-600 hover:text-brand-800"
|
||||
>
|
||||
← Back to Agents
|
||||
</button>
|
||||
|
||||
<div className="mb-6 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">{agent.email}</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">Agent ID: {agent.agentId}</p>
|
||||
</div>
|
||||
<Badge variant={statusVariant(agent.status)} className="mt-1">{agent.status}</Badge>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-md bg-red-50 px-4 py-3 text-sm text-red-700" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detail card */}
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<dl className="space-y-4">
|
||||
<DetailRow label="Email" value={agent.email} />
|
||||
<DetailRow label="Agent ID" value={agent.agentId} />
|
||||
<DetailRow label="Type" value={agent.agentType} />
|
||||
<DetailRow label="Version" value={agent.version} />
|
||||
<DetailRow label="Owner" value={agent.owner} />
|
||||
<DetailRow label="Environment" value={agent.deploymentEnv} />
|
||||
<DetailRow label="Capabilities" value={agent.capabilities.join(', ') || '—'} />
|
||||
<DetailRow label="Status" value={agent.status} />
|
||||
<DetailRow label="Created" value={formatDateTime(agent.createdAt)} />
|
||||
<DetailRow label="Updated" value={formatDateTime(agent.updatedAt)} />
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{agent.status !== 'decommissioned' && (
|
||||
<div className="mt-6 flex gap-3">
|
||||
{agent.status === 'active' && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
loading={actionLoading}
|
||||
onClick={() => { setDialog('suspend'); }}
|
||||
>
|
||||
Suspend Agent
|
||||
</Button>
|
||||
)}
|
||||
{agent.status === 'suspended' && (
|
||||
<Button
|
||||
variant="default"
|
||||
loading={actionLoading}
|
||||
onClick={() => { setDialog('reactivate'); }}
|
||||
>
|
||||
Reactivate Agent
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Credentials section */}
|
||||
<div className="mt-8 rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<h2 className="mb-4 text-lg font-semibold text-slate-900">Credentials</h2>
|
||||
<p className="mb-4 text-sm text-slate-600">
|
||||
Manage client secrets for this agent. Rotate or revoke credentials as needed.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => { navigate(`/dashboard/agents/${agent.agentId}/credentials`); }}
|
||||
>
|
||||
View Credentials
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Confirm dialog */}
|
||||
{dialog !== null && (
|
||||
<ConfirmDialog
|
||||
open
|
||||
title={dialogConfig.title}
|
||||
description={dialogConfig.description}
|
||||
confirmLabel={dialogConfig.confirmLabel}
|
||||
variant={dialogConfig.variant}
|
||||
onConfirm={() => { void handleAction(dialog); }}
|
||||
onCancel={() => { setDialog(null); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
204
dashboard/src/pages/Agents.tsx
Normal file
204
dashboard/src/pages/Agents.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import * as React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { Agent, AgentStatus } from '@sentryagent/idp-sdk';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { getClient } from '@/lib/client';
|
||||
|
||||
const PAGE_LIMIT = 20;
|
||||
|
||||
/** Maps AgentStatus to a Badge variant. */
|
||||
function statusVariant(status: AgentStatus): 'success' | 'warning' | 'danger' | 'muted' {
|
||||
switch (status) {
|
||||
case 'active': return 'success';
|
||||
case 'suspended': return 'warning';
|
||||
case 'decommissioned': return 'danger';
|
||||
}
|
||||
}
|
||||
|
||||
/** 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' });
|
||||
}
|
||||
|
||||
/** Skeleton row shown while loading. */
|
||||
function SkeletonRow(): React.JSX.Element {
|
||||
return (
|
||||
<tr>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<td key={i} className="px-4 py-3">
|
||||
<div className="h-4 w-full animate-pulse rounded bg-slate-200" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Agents list page — displays all registered agents with search, status filter, and pagination.
|
||||
* Clicking a row navigates to the Agent Detail page.
|
||||
*/
|
||||
export default function Agents(): React.JSX.Element {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [agents, setAgents] = React.useState<Agent[]>([]);
|
||||
const [total, setTotal] = React.useState<number>(0);
|
||||
const [page, setPage] = React.useState<number>(1);
|
||||
const [loading, setLoading] = React.useState<boolean>(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
// Filters (client-side email search, server-side status)
|
||||
const [searchInput, setSearchInput] = React.useState<string>('');
|
||||
const [debouncedSearch, setDebouncedSearch] = React.useState<string>('');
|
||||
const [statusFilter, setStatusFilter] = React.useState<AgentStatus | ''>('');
|
||||
|
||||
// Debounce search input 300ms
|
||||
React.useEffect(() => {
|
||||
const timer = setTimeout(() => { setDebouncedSearch(searchInput); }, 300);
|
||||
return () => { clearTimeout(timer); };
|
||||
}, [searchInput]);
|
||||
|
||||
// Reset to page 1 on filter change
|
||||
React.useEffect(() => {
|
||||
setPage(1);
|
||||
}, [debouncedSearch, statusFilter]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const fetchAgents = async (): Promise<void> => {
|
||||
try {
|
||||
const client = getClient();
|
||||
const result = await client.agents.listAgents({
|
||||
page,
|
||||
limit: PAGE_LIMIT,
|
||||
status: statusFilter !== '' ? statusFilter : undefined,
|
||||
});
|
||||
if (!cancelled) {
|
||||
setAgents(result.data);
|
||||
setTotal(result.total);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load agents.');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void fetchAgents();
|
||||
return () => { cancelled = true; };
|
||||
}, [page, statusFilter]);
|
||||
|
||||
// Client-side email filter applied after API results arrive
|
||||
const filteredAgents = React.useMemo(() => {
|
||||
if (!debouncedSearch.trim()) return agents;
|
||||
const lower = debouncedSearch.toLowerCase();
|
||||
return agents.filter((a) => a.email.toLowerCase().includes(lower));
|
||||
}, [agents, debouncedSearch]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_LIMIT));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h1 className="text-2xl font-bold text-slate-900">Agents</h1>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="search"
|
||||
value={searchInput}
|
||||
onChange={(e) => { setSearchInput(e.target.value); }}
|
||||
placeholder="Search by email…"
|
||||
className="w-60 rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => { setStatusFilter(e.target.value as AgentStatus | ''); }}
|
||||
className="rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="suspended">Suspended</option>
|
||||
<option value="decommissioned">Decommissioned</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-md bg-red-50 px-4 py-3 text-sm text-red-700" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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>
|
||||
{['Name (Email)', 'Type', 'Status', 'Environment', 'Owner', 'Created'].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: 5 }).map((_, i) => <SkeletonRow key={i} />)
|
||||
: filteredAgents.length === 0
|
||||
? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-12 text-center text-slate-400">
|
||||
No agents found.
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
: filteredAgents.map((agent) => (
|
||||
<tr
|
||||
key={agent.agentId}
|
||||
onClick={() => { navigate(`/dashboard/agents/${agent.agentId}`); }}
|
||||
className="cursor-pointer hover:bg-slate-50"
|
||||
>
|
||||
<td className="px-4 py-3 font-medium text-brand-700">{agent.email}</td>
|
||||
<td className="px-4 py-3 text-slate-600">{agent.agentType}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant={statusVariant(agent.status)}>{agent.status}</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-600">{agent.deploymentEnv}</td>
|
||||
<td className="px-4 py-3 text-slate-600">{agent.owner}</td>
|
||||
<td className="px-4 py-3 text-slate-500">{formatDate(agent.createdAt)}</td>
|
||||
</tr>
|
||||
))
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{!loading && total > 0 && (
|
||||
<div className="mt-4 flex items-center justify-between text-sm text-slate-600">
|
||||
<span>
|
||||
Page {page} of {totalPages} ({total} total)
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => { setPage((p) => Math.max(1, p - 1)); }}
|
||||
disabled={page <= 1}
|
||||
className="rounded-md border border-slate-300 px-3 py-1.5 hover:bg-slate-50 disabled:opacity-40"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setPage((p) => Math.min(totalPages, p + 1)); }}
|
||||
disabled={page >= totalPages}
|
||||
className="rounded-md border border-slate-300 px-3 py-1.5 hover:bg-slate-50 disabled:opacity-40"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
223
dashboard/src/pages/AuditLog.tsx
Normal file
223
dashboard/src/pages/AuditLog.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import * as React from 'react';
|
||||
import type { AuditEvent, AuditAction, AuditOutcome } from '@sentryagent/idp-sdk';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { getClient } from '@/lib/client';
|
||||
|
||||
const PAGE_LIMIT = 20;
|
||||
|
||||
/** All AuditAction values for the filter dropdown. */
|
||||
const AUDIT_ACTIONS: AuditAction[] = [
|
||||
'agent.created',
|
||||
'agent.updated',
|
||||
'agent.decommissioned',
|
||||
'agent.suspended',
|
||||
'agent.reactivated',
|
||||
'token.issued',
|
||||
'token.revoked',
|
||||
'token.introspected',
|
||||
'credential.generated',
|
||||
'credential.rotated',
|
||||
'credential.revoked',
|
||||
'auth.failed',
|
||||
];
|
||||
|
||||
/** Formats an ISO timestamp to a readable local date-time string. */
|
||||
function formatDateTime(iso: string): string {
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
/** Truncates a string to a maximum length with ellipsis. */
|
||||
function truncate(value: string, maxLen = 24): string {
|
||||
return value.length > maxLen ? `${value.slice(0, maxLen)}…` : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Audit Log page — displays audit events with filters for agent, action, outcome, and date range.
|
||||
* Route: /dashboard/audit
|
||||
*/
|
||||
export default function AuditLog(): React.JSX.Element {
|
||||
const [events, setEvents] = React.useState<AuditEvent[]>([]);
|
||||
const [total, setTotal] = React.useState<number>(0);
|
||||
const [page, setPage] = React.useState<number>(1);
|
||||
const [loading, setLoading] = React.useState<boolean>(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
// Filters
|
||||
const [agentIdFilter, setAgentIdFilter] = React.useState<string>('');
|
||||
const [actionFilter, setActionFilter] = React.useState<AuditAction | ''>('');
|
||||
const [outcomeFilter, setOutcomeFilter] = React.useState<AuditOutcome | ''>('');
|
||||
const [fromDate, setFromDate] = React.useState<string>('');
|
||||
const [toDate, setToDate] = React.useState<string>('');
|
||||
|
||||
// Reset to page 1 on filter change
|
||||
React.useEffect(() => {
|
||||
setPage(1);
|
||||
}, [agentIdFilter, actionFilter, outcomeFilter, fromDate, toDate]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const fetchEvents = async (): Promise<void> => {
|
||||
try {
|
||||
const result = await getClient().audit.queryAuditLog({
|
||||
page,
|
||||
limit: PAGE_LIMIT,
|
||||
agentId: agentIdFilter.trim() || undefined,
|
||||
action: actionFilter !== '' ? actionFilter : undefined,
|
||||
outcome: outcomeFilter !== '' ? outcomeFilter : undefined,
|
||||
fromDate: fromDate || undefined,
|
||||
toDate: toDate || undefined,
|
||||
});
|
||||
if (!cancelled) {
|
||||
setEvents(result.data);
|
||||
setTotal(result.total);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load audit log.');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void fetchEvents();
|
||||
return () => { cancelled = true; };
|
||||
}, [page, agentIdFilter, actionFilter, outcomeFilter, fromDate, toDate]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_LIMIT));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="mb-6 text-2xl font-bold text-slate-900">Audit Log</h1>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="mb-6 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<input
|
||||
type="text"
|
||||
value={agentIdFilter}
|
||||
onChange={(e) => { setAgentIdFilter(e.target.value); }}
|
||||
placeholder="Agent ID…"
|
||||
className="rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
<select
|
||||
value={actionFilter}
|
||||
onChange={(e) => { setActionFilter(e.target.value as AuditAction | ''); }}
|
||||
className="rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
>
|
||||
<option value="">All Actions</option>
|
||||
{AUDIT_ACTIONS.map((action) => (
|
||||
<option key={action} value={action}>{action}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={outcomeFilter}
|
||||
onChange={(e) => { setOutcomeFilter(e.target.value as AuditOutcome | ''); }}
|
||||
className="rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
>
|
||||
<option value="">All Outcomes</option>
|
||||
<option value="success">Success</option>
|
||||
<option value="failure">Failure</option>
|
||||
</select>
|
||||
<input
|
||||
type="date"
|
||||
value={fromDate}
|
||||
onChange={(e) => { setFromDate(e.target.value); }}
|
||||
className="rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
title="From date"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={toDate}
|
||||
onChange={(e) => { setToDate(e.target.value); }}
|
||||
className="rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
title="To date"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-md bg-red-50 px-4 py-3 text-sm text-red-700" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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>
|
||||
{['Timestamp', 'Agent ID', 'Action', 'Outcome', 'IP Address'].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: 5 }).map((_, i) => (
|
||||
<tr key={i}>
|
||||
{Array.from({ length: 5 }).map((__, j) => (
|
||||
<td key={j} className="px-4 py-3">
|
||||
<div className="h-4 w-full animate-pulse rounded bg-slate-200" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
: events.length === 0
|
||||
? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-12 text-center text-slate-400">
|
||||
No audit events found.
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
: events.map((event) => (
|
||||
<tr key={event.eventId} className="hover:bg-slate-50">
|
||||
<td className="px-4 py-3 text-slate-500 whitespace-nowrap">{formatDateTime(event.timestamp)}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-slate-700">{truncate(event.agentId)}</td>
|
||||
<td className="px-4 py-3 text-slate-700">{event.action}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant={event.outcome === 'success' ? 'success' : 'danger'}>
|
||||
{event.outcome}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-500">{event.ipAddress}</td>
|
||||
</tr>
|
||||
))
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{!loading && total > 0 && (
|
||||
<div className="mt-4 flex items-center justify-between text-sm text-slate-600">
|
||||
<span>
|
||||
Page {page} of {totalPages} ({total} total)
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => { setPage((p) => Math.max(1, p - 1)); }}
|
||||
disabled={page <= 1}
|
||||
className="rounded-md border border-slate-300 px-3 py-1.5 hover:bg-slate-50 disabled:opacity-40"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setPage((p) => Math.min(totalPages, p + 1)); }}
|
||||
disabled={page >= totalPages}
|
||||
className="rounded-md border border-slate-300 px-3 py-1.5 hover:bg-slate-50 disabled:opacity-40"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
173
dashboard/src/pages/Health.tsx
Normal file
173
dashboard/src/pages/Health.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import * as React from 'react';
|
||||
|
||||
/** Shape of the /health API response. */
|
||||
interface HealthResponse {
|
||||
status: 'ok' | 'degraded';
|
||||
version?: string;
|
||||
uptime?: number;
|
||||
services: {
|
||||
postgres: 'connected' | 'disconnected';
|
||||
redis: 'connected' | 'disconnected';
|
||||
};
|
||||
}
|
||||
|
||||
type ServiceStatus = 'connected' | 'disconnected' | 'unknown';
|
||||
|
||||
interface HealthState {
|
||||
postgres: ServiceStatus;
|
||||
redis: ServiceStatus;
|
||||
version: string | null;
|
||||
uptime: number | null;
|
||||
lastChecked: Date | null;
|
||||
reachable: boolean;
|
||||
}
|
||||
|
||||
const initialState: HealthState = {
|
||||
postgres: 'unknown',
|
||||
redis: 'unknown',
|
||||
version: null,
|
||||
uptime: null,
|
||||
lastChecked: null,
|
||||
reachable: true,
|
||||
};
|
||||
|
||||
/** Formats seconds into a human-readable uptime string. */
|
||||
function formatUptime(seconds: number): string {
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const parts: string[] = [];
|
||||
if (days > 0) parts.push(`${days}d`);
|
||||
if (hours > 0) parts.push(`${hours}h`);
|
||||
parts.push(`${minutes}m`);
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
interface StatusCardProps {
|
||||
label: string;
|
||||
status: ServiceStatus;
|
||||
}
|
||||
|
||||
/** Card displaying the connectivity status of a single service. */
|
||||
function StatusCard({ label, status }: StatusCardProps): React.JSX.Element {
|
||||
const isConnected = status === 'connected';
|
||||
const isUnknown = status === 'unknown';
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl border p-6 shadow-sm ${
|
||||
isUnknown
|
||||
? 'border-slate-200 bg-slate-50'
|
||||
: isConnected
|
||||
? 'border-green-200 bg-green-50'
|
||||
: 'border-red-200 bg-red-50'
|
||||
}`}>
|
||||
<p className="text-sm font-medium text-slate-600">{label}</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span className={`inline-block h-3 w-3 rounded-full ${
|
||||
isUnknown ? 'bg-slate-400' : isConnected ? 'bg-green-500' : 'bg-red-500'
|
||||
}`} />
|
||||
<span className={`text-lg font-semibold ${
|
||||
isUnknown ? 'text-slate-600' : isConnected ? 'text-green-700' : 'text-red-700'
|
||||
}`}>
|
||||
{isUnknown ? 'Checking…' : isConnected ? 'Connected' : 'Disconnected'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Health page — shows PostgreSQL and Redis connectivity status.
|
||||
* Polls GET /health every 30 seconds. No authentication required.
|
||||
* Route: /dashboard/health
|
||||
*/
|
||||
export default function Health(): React.JSX.Element {
|
||||
const [health, setHealth] = React.useState<HealthState>(initialState);
|
||||
const [loading, setLoading] = React.useState<boolean>(true);
|
||||
|
||||
const checkHealth = React.useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const response = await fetch('/health');
|
||||
const data = (await response.json()) as HealthResponse;
|
||||
|
||||
setHealth({
|
||||
postgres: data.services?.postgres ?? 'unknown',
|
||||
redis: data.services?.redis ?? 'unknown',
|
||||
version: data.version ?? null,
|
||||
uptime: data.uptime ?? null,
|
||||
lastChecked: new Date(),
|
||||
reachable: true,
|
||||
});
|
||||
} catch {
|
||||
setHealth((prev) => ({
|
||||
...prev,
|
||||
postgres: 'disconnected',
|
||||
redis: 'disconnected',
|
||||
lastChecked: new Date(),
|
||||
reachable: false,
|
||||
}));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
void checkHealth();
|
||||
const interval = setInterval(() => { void checkHealth(); }, 30_000);
|
||||
return () => { clearInterval(interval); };
|
||||
}, [checkHealth]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-slate-900">System Health</h1>
|
||||
<button
|
||||
onClick={() => { void checkHealth(); }}
|
||||
disabled={loading}
|
||||
className="rounded-md border border-slate-300 px-3 py-1.5 text-sm hover:bg-slate-50 disabled:opacity-40"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!health.reachable && (
|
||||
<div className="mb-6 rounded-md bg-red-50 px-4 py-3 text-sm text-red-700" role="alert">
|
||||
API is unreachable. Check that the server is running.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<StatusCard label="PostgreSQL" status={loading ? 'unknown' : health.postgres} />
|
||||
<StatusCard label="Redis" status={loading ? 'unknown' : health.redis} />
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
{(health.version !== null || health.uptime !== null) && (
|
||||
<div className="mt-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<h2 className="mb-4 text-base font-semibold text-slate-900">API Details</h2>
|
||||
<dl className="space-y-2">
|
||||
{health.version !== null && (
|
||||
<div className="flex gap-4">
|
||||
<dt className="w-24 text-sm font-medium text-slate-500">Version</dt>
|
||||
<dd className="text-sm text-slate-900">{health.version}</dd>
|
||||
</div>
|
||||
)}
|
||||
{health.uptime !== null && (
|
||||
<div className="flex gap-4">
|
||||
<dt className="w-24 text-sm font-medium text-slate-500">Uptime</dt>
|
||||
<dd className="text-sm text-slate-900">{formatUptime(health.uptime)}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Last checked */}
|
||||
{health.lastChecked !== null && (
|
||||
<p className="mt-4 text-xs text-slate-400">
|
||||
Last checked: {health.lastChecked.toLocaleTimeString()} — auto-refreshes every 30 seconds
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
109
dashboard/src/pages/Login.tsx
Normal file
109
dashboard/src/pages/Login.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import * as React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
|
||||
/**
|
||||
* Login page — accepts API Base URL, Client ID, and Client Secret.
|
||||
* Validates credentials against the AgentIdP token endpoint before persisting.
|
||||
*/
|
||||
export default function Login(): React.JSX.Element {
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [baseUrl, setBaseUrl] = React.useState<string>(window.location.origin);
|
||||
const [clientId, setClientId] = React.useState<string>('');
|
||||
const [clientSecret, setClientSecret] = React.useState<string>('');
|
||||
const [loading, setLoading] = React.useState<boolean>(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const success = await login({ baseUrl: baseUrl.trim(), clientId: clientId.trim(), clientSecret });
|
||||
if (success) {
|
||||
navigate('/dashboard/agents', { replace: true });
|
||||
} else {
|
||||
setError('Invalid credentials. Please check your Client ID and secret.');
|
||||
setClientSecret('');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[login, navigate, baseUrl, clientId, clientSecret],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-slate-50 px-4">
|
||||
<div className="w-full max-w-md rounded-xl bg-white p-8 shadow-lg">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-2xl font-bold text-brand-700">SentryAgent.ai</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">AgentIdP Dashboard — Sign In</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={(e) => { void handleSubmit(e); }} className="space-y-5">
|
||||
<div>
|
||||
<label htmlFor="baseUrl" className="block text-sm font-medium text-slate-700">
|
||||
API Base URL
|
||||
</label>
|
||||
<input
|
||||
id="baseUrl"
|
||||
type="url"
|
||||
required
|
||||
value={baseUrl}
|
||||
onChange={(e) => { setBaseUrl(e.target.value); }}
|
||||
className="mt-1 block w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
placeholder="https://api.example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="clientId" className="block text-sm font-medium text-slate-700">
|
||||
Client ID
|
||||
</label>
|
||||
<input
|
||||
id="clientId"
|
||||
type="text"
|
||||
required
|
||||
value={clientId}
|
||||
onChange={(e) => { setClientId(e.target.value); }}
|
||||
className="mt-1 block w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
placeholder="agent-uuid"
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="clientSecret" className="block text-sm font-medium text-slate-700">
|
||||
Client Secret
|
||||
</label>
|
||||
<input
|
||||
id="clientSecret"
|
||||
type="password"
|
||||
required
|
||||
value={clientSecret}
|
||||
onChange={(e) => { setClientSecret(e.target.value); }}
|
||||
className="mt-1 block w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="rounded-md bg-red-50 px-3 py-2 text-sm text-red-700" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button type="submit" loading={loading} className="w-full" size="lg">
|
||||
{loading ? 'Validating…' : 'Sign In'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user