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:
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