WS3 — Advanced Analytics Dashboard: - DB migration: analytics_events table (tenant_id, date, metric_type, count) - AnalyticsService: recordEvent (fire-and-forget), getTokenTrend, getAgentActivity, getAgentUsageSummary - Analytics hooks in OAuth2Service (token_issued) and AgentService (agent_registered/deactivated) - AnalyticsController + routes/analytics.ts (gated by ANALYTICS_ENABLED flag) - Portal: TokenTrendChart (recharts LineChart), AgentHeatmap (recharts heatmap), /analytics page WS4 — API Gateway Tiers: - DB migration: tenant_tiers table; src/config/tiers.ts (free/pro/enterprise limits) - TierService: getStatus, initiateUpgrade (Stripe), applyUpgrade; TierLimitError in errors.ts - tierEnforcement middleware (Redis-backed daily call/token counters; TIER_ENFORCEMENT flag) - Agent count enforcement in AgentService.create() - Stripe webhook updated to call TierService.applyUpgrade() on checkout.session.completed - TierController + routes/tiers.ts; Portal: /settings/tier page with upgrade flow WS6 — AGNTCY Compliance Certification: - ComplianceService: generateReport() (Redis-cached 5 min), exportAgentCards() - Compliance sections: agent-identity (DID + credential expiry checks), audit-trail (Merkle chain) - ComplianceController updated with getComplianceReport, exportAgentCards handlers - routes/compliance.ts: new AGNTCY routes (gated by COMPLIANCE_ENABLED flag); SOC2 routes unaffected QA: - 28 new unit tests: AnalyticsService (8), TierService (9), ComplianceService (11) — all pass - 673 total unit tests passing; 0 TypeScript errors across API and portal - AGNTCY conformance test suite at tests/agntcy-conformance/ (4 protocol tests) - Portal builds cleanly: 9 routes including /analytics and /settings/tier - Feature flags verified: ANALYTICS_ENABLED, TIER_ENFORCEMENT, COMPLIANCE_ENABLED Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
138 lines
4.4 KiB
TypeScript
138 lines
4.4 KiB
TypeScript
'use client';
|
|
|
|
/**
|
|
* LoginPage — Tenant admin sign-in page for the SentryAgent developer portal.
|
|
*
|
|
* Posts credentials to `POST /api/tenants/login` on the AgentIdP backend and
|
|
* stores the returned JWT in localStorage so that protected pages (e.g.
|
|
* /analytics) can read it via the `useAuth` hook.
|
|
*
|
|
* @module app/login/page
|
|
*/
|
|
|
|
import React, { useState, type FormEvent } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import { AUTH_TOKEN_KEY } from '@/hooks/useAuth';
|
|
|
|
/** Shape of the successful login response from the AgentIdP API. */
|
|
interface LoginResponse {
|
|
access_token: string;
|
|
}
|
|
|
|
/**
|
|
* Renders the portal login form and handles credential submission.
|
|
*
|
|
* @returns JSX element
|
|
*/
|
|
export default function LoginPage(): React.ReactElement {
|
|
const router = useRouter();
|
|
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000';
|
|
|
|
const [email, setEmail] = useState('');
|
|
const [password, setPassword] = useState('');
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
/**
|
|
* Submits the login form and stores the JWT on success.
|
|
*
|
|
* @param e - The form submission event
|
|
*/
|
|
async function handleSubmit(e: FormEvent<HTMLFormElement>): Promise<void> {
|
|
e.preventDefault();
|
|
setError(null);
|
|
setSubmitting(true);
|
|
|
|
try {
|
|
const res = await fetch(`${apiUrl}/api/tenants/login`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email, password }),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const body = (await res.json().catch(() => ({}))) as { message?: string };
|
|
setError(body.message ?? 'Invalid credentials. Please try again.');
|
|
return;
|
|
}
|
|
|
|
const data = (await res.json()) as LoginResponse;
|
|
localStorage.setItem(AUTH_TOKEN_KEY, data.access_token);
|
|
router.replace('/analytics');
|
|
} catch {
|
|
setError('Network error. Please check your connection and try again.');
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="flex min-h-[70vh] items-center justify-center px-6 py-16">
|
|
<div className="w-full max-w-md">
|
|
<div className="mb-8 text-center">
|
|
<h1 className="text-3xl font-extrabold text-slate-900">Sign in</h1>
|
|
<p className="mt-2 text-slate-600">
|
|
Access your SentryAgent tenant dashboard
|
|
</p>
|
|
</div>
|
|
|
|
<form
|
|
onSubmit={(e) => void handleSubmit(e)}
|
|
className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm"
|
|
>
|
|
<div className="mb-4">
|
|
<label
|
|
htmlFor="email"
|
|
className="mb-1.5 block text-sm font-medium text-slate-700"
|
|
>
|
|
Email address
|
|
</label>
|
|
<input
|
|
id="email"
|
|
type="email"
|
|
required
|
|
autoComplete="email"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
|
placeholder="admin@example.com"
|
|
/>
|
|
</div>
|
|
|
|
<div className="mb-6">
|
|
<label
|
|
htmlFor="password"
|
|
className="mb-1.5 block text-sm font-medium text-slate-700"
|
|
>
|
|
Password
|
|
</label>
|
|
<input
|
|
id="password"
|
|
type="password"
|
|
required
|
|
autoComplete="current-password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
|
/>
|
|
</div>
|
|
|
|
{error !== null && (
|
|
<p className="mb-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">
|
|
{error}
|
|
</p>
|
|
)}
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={submitting}
|
|
className="w-full rounded-lg bg-brand-600 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-brand-700 disabled:opacity-60"
|
|
>
|
|
{submitting ? 'Signing in…' : 'Sign in'}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|