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:
11
dashboard/src/components/RequireAuth.tsx
Normal file
11
dashboard/src/components/RequireAuth.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import * as React from 'react';
|
||||
import { Navigate, Outlet } from 'react-router-dom';
|
||||
import { isAuthenticated } from '@/lib/auth';
|
||||
|
||||
/** Redirects to /dashboard/login if not authenticated. */
|
||||
export function RequireAuth(): React.JSX.Element {
|
||||
if (!isAuthenticated()) {
|
||||
return <Navigate to="/dashboard/login" replace />;
|
||||
}
|
||||
return <Outlet />;
|
||||
}
|
||||
62
dashboard/src/components/layout/AppShell.tsx
Normal file
62
dashboard/src/components/layout/AppShell.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import * as React from 'react';
|
||||
import { NavLink, Outlet } from 'react-router-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
|
||||
interface NavItem {
|
||||
to: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ to: '/dashboard/agents', label: 'Agents' },
|
||||
{ to: '/dashboard/audit', label: 'Audit Log' },
|
||||
{ to: '/dashboard/health', label: 'Health' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Outer application shell: top navigation bar and main content area.
|
||||
* Renders the active page via <Outlet />.
|
||||
*/
|
||||
export function AppShell(): React.JSX.Element {
|
||||
const { logout } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
<header className="border-b border-slate-200 bg-white shadow-sm">
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-3">
|
||||
<div className="flex items-center gap-8">
|
||||
<span className="text-lg font-bold text-brand-700">SentryAgent.ai</span>
|
||||
<nav className="flex gap-1">
|
||||
{NAV_ITEMS.map(({ to, label }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-brand-50 text-brand-700'
|
||||
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900',
|
||||
)
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="text-sm text-slate-500 hover:text-slate-900"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto max-w-7xl px-4 py-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
dashboard/src/components/ui/badge.tsx
Normal file
27
dashboard/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type BadgeVariant = 'default' | 'success' | 'warning' | 'danger' | 'muted';
|
||||
|
||||
interface BadgeProps {
|
||||
variant?: BadgeVariant;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const variantClasses: Record<BadgeVariant, string> = {
|
||||
default: 'bg-brand-100 text-brand-700',
|
||||
success: 'bg-green-100 text-green-700',
|
||||
warning: 'bg-yellow-100 text-yellow-700',
|
||||
danger: 'bg-red-100 text-red-700',
|
||||
muted: 'bg-slate-100 text-slate-600',
|
||||
};
|
||||
|
||||
/** Small status badge. */
|
||||
export function Badge({ variant = 'default', children, className }: BadgeProps): React.JSX.Element {
|
||||
return (
|
||||
<span className={cn('inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium', variantClasses[variant], className)}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
65
dashboard/src/components/ui/button.tsx
Normal file
65
dashboard/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Variant = 'default' | 'destructive' | 'outline' | 'ghost';
|
||||
type Size = 'sm' | 'md' | 'lg';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const variantClasses: Record<Variant, string> = {
|
||||
default: 'bg-brand-600 text-white hover:bg-brand-700 focus:ring-brand-500',
|
||||
destructive: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
|
||||
outline: 'border border-slate-300 bg-white text-slate-700 hover:bg-slate-50 focus:ring-brand-500',
|
||||
ghost: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900 focus:ring-brand-500',
|
||||
};
|
||||
|
||||
const sizeClasses: Record<Size, string> = {
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-6 py-3 text-base',
|
||||
};
|
||||
|
||||
/**
|
||||
* Reusable button component with variant and size support.
|
||||
*
|
||||
* @param variant - Visual style: default | destructive | outline | ghost
|
||||
* @param size - Size: sm | md | lg
|
||||
* @param loading - When true, shows a spinner and disables the button
|
||||
*/
|
||||
export function Button({
|
||||
variant = 'default',
|
||||
size = 'md',
|
||||
loading = false,
|
||||
className,
|
||||
children,
|
||||
disabled,
|
||||
...props
|
||||
}: ButtonProps): React.JSX.Element {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center gap-2 rounded-md font-medium',
|
||||
'focus:outline-none focus:ring-2 focus:ring-offset-2',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
'transition-colors duration-150',
|
||||
variantClasses[variant],
|
||||
sizeClasses[size],
|
||||
className,
|
||||
)}
|
||||
disabled={disabled ?? loading}
|
||||
{...props}
|
||||
>
|
||||
{loading && (
|
||||
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||
</svg>
|
||||
)}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
45
dashboard/src/components/ui/dialog.tsx
Normal file
45
dashboard/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import * as React from 'react';
|
||||
import { Button } from './button';
|
||||
|
||||
interface DialogProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
variant?: 'default' | 'destructive';
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal confirmation dialog for destructive actions (suspend, revoke, rotate).
|
||||
*/
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
confirmLabel = 'Confirm',
|
||||
cancelLabel = 'Cancel',
|
||||
variant = 'default',
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: DialogProps): React.JSX.Element | null {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={onCancel} />
|
||||
<div className="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
||||
<h2 className="text-lg font-semibold text-slate-900">{title}</h2>
|
||||
<p className="mt-2 text-sm text-slate-600">{description}</p>
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<Button variant="outline" onClick={onCancel}>{cancelLabel}</Button>
|
||||
<Button variant={variant === 'destructive' ? 'destructive' : 'default'} onClick={onConfirm}>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user