feat(phase-6): WS3+WS4+WS6 — Analytics, API Tiers, AGNTCY Compliance

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>
This commit is contained in:
SentryAgent.ai Developer
2026-04-04 02:20:09 +00:00
parent 0fad328329
commit eea885db04
34 changed files with 4262 additions and 25 deletions

View File

@@ -0,0 +1,351 @@
'use client';
/**
* AnalyticsPage — Tenant analytics dashboard for the SentryAgent portal.
*
* Displays:
* - Token issuance trend (last 30 days) via TokenTrendChart (lazy-loaded)
* - Agent activity by day-of-week via AgentHeatmap (lazy-loaded)
* - Per-agent usage table for the current billing period
*
* All chart components are code-split via `next/dynamic` so that recharts
* is excluded from the main bundle.
*
* Protected route: redirects to `/login` when no JWT is present (via useAuth).
*
* @module app/analytics/page
*/
import React, { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { useAuth } from '@/hooks/useAuth';
import type { TokenTrendDataPoint } from '@/components/charts/TokenTrendChart';
import type { AgentActivityBucket } from '@/components/charts/AgentHeatmap';
/* -------------------------------------------------------------------------
* Lazy-loaded chart components (recharts stays out of the main bundle)
* ---------------------------------------------------------------------- */
const TokenTrendChart = dynamic(
() => import('@/components/charts/TokenTrendChart'),
{
ssr: false,
loading: () => (
<ChartSkeleton label="Loading token trend chart…" height={300} />
),
},
);
const AgentHeatmap = dynamic(
() => import('@/components/charts/AgentHeatmap'),
{
ssr: false,
loading: () => (
<ChartSkeleton label="Loading activity chart…" height={300} />
),
},
);
/* -------------------------------------------------------------------------
* API response types
* ---------------------------------------------------------------------- */
/** A single entry from `GET /api/analytics/agents`. */
interface AgentUsageSummary {
agent_id: string;
name: string;
token_count: number;
}
/** Root shape of `GET /api/analytics/agents/activity`. */
type AgentActivityResponse = AgentActivityBucket[];
/** Root shape of `GET /api/analytics/tokens`. */
type TokenTrendResponse = TokenTrendDataPoint[];
/** Root shape of `GET /api/analytics/agents`. */
type AgentUsageResponse = AgentUsageSummary[];
/* -------------------------------------------------------------------------
* Page state
* ---------------------------------------------------------------------- */
/** Loading / error / data state for a single async fetch. */
interface FetchState<T> {
data: T | null;
loading: boolean;
error: string | null;
}
function initFetchState<T>(): FetchState<T> {
return { data: null, loading: true, error: null };
}
/* -------------------------------------------------------------------------
* Helpers
* ---------------------------------------------------------------------- */
/**
* Fetches a JSON endpoint with an Authorization bearer token.
*
* @param url - Absolute URL to fetch
* @param token - JWT bearer token
* @returns Parsed JSON of type T
* @throws Error with a descriptive message on non-2xx or network failure
*/
async function fetchWithAuth<T>(url: string, token: string): Promise<T> {
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { message?: string };
throw new Error(body.message ?? `Request failed: ${res.status}`);
}
return res.json() as Promise<T>;
}
/* -------------------------------------------------------------------------
* Sub-components
* ---------------------------------------------------------------------- */
/** Props for ChartSkeleton. */
interface ChartSkeletonProps {
label: string;
height: number;
}
/**
* Placeholder shown while a dynamic chart chunk is loading.
*
* @param props - ChartSkeletonProps
* @returns JSX element
*/
function ChartSkeleton({ label, height }: ChartSkeletonProps): React.ReactElement {
return (
<div
className="flex animate-pulse items-center justify-center rounded-xl bg-slate-100 text-sm text-slate-400"
style={{ height }}
aria-label={label}
>
{label}
</div>
);
}
/** Props for SectionCard. */
interface SectionCardProps {
title: string;
children: React.ReactNode;
}
/**
* Simple card wrapper for dashboard sections.
*
* @param props - SectionCardProps
* @returns JSX element
*/
function SectionCard({ title, children }: SectionCardProps): React.ReactElement {
return (
<div className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<h2 className="mb-4 text-lg font-semibold text-slate-900">{title}</h2>
{children}
</div>
);
}
/** Props for ErrorBanner. */
interface ErrorBannerProps {
message: string;
}
/**
* Inline error banner for failed data fetches.
*
* @param props - ErrorBannerProps
* @returns JSX element
*/
function ErrorBanner({ message }: ErrorBannerProps): React.ReactElement {
return (
<p className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">
{message}
</p>
);
}
/* -------------------------------------------------------------------------
* Page component
* ---------------------------------------------------------------------- */
/**
* Renders the analytics dashboard page.
*
* Checks authentication via `useAuth` (redirects to /login if no token).
* Fetches analytics data from the AgentIdP API using the stored JWT.
*
* @returns JSX element
*/
export default function AnalyticsPage(): React.ReactElement {
const { token, loading: authLoading } = useAuth(true);
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000';
const [tokenTrend, setTokenTrend] =
useState<FetchState<TokenTrendResponse>>(initFetchState);
const [agentActivity, setAgentActivity] =
useState<FetchState<AgentActivityResponse>>(initFetchState);
const [agentUsage, setAgentUsage] =
useState<FetchState<AgentUsageResponse>>(initFetchState);
useEffect(() => {
// Wait until auth state is resolved and token is available
if (authLoading || token === null) return;
void fetchWithAuth<TokenTrendResponse>(
`${apiUrl}/api/analytics/tokens?days=30`,
token,
)
.then((data) => setTokenTrend({ data, loading: false, error: null }))
.catch((err: unknown) =>
setTokenTrend({
data: null,
loading: false,
error: err instanceof Error ? err.message : 'Failed to load token trend',
}),
);
void fetchWithAuth<AgentActivityResponse>(
`${apiUrl}/api/analytics/agents/activity`,
token,
)
.then((data) => setAgentActivity({ data, loading: false, error: null }))
.catch((err: unknown) =>
setAgentActivity({
data: null,
loading: false,
error: err instanceof Error ? err.message : 'Failed to load agent activity',
}),
);
void fetchWithAuth<AgentUsageResponse>(
`${apiUrl}/api/analytics/agents`,
token,
)
.then((data) => setAgentUsage({ data, loading: false, error: null }))
.catch((err: unknown) =>
setAgentUsage({
data: null,
loading: false,
error: err instanceof Error ? err.message : 'Failed to load agent usage',
}),
);
}, [authLoading, token, apiUrl]);
// While auth state is being resolved, show a full-page loading state
if (authLoading) {
return (
<div className="flex min-h-[60vh] items-center justify-center">
<p className="text-slate-500">Loading</p>
</div>
);
}
// token === null means useAuth is redirecting to /login; render nothing
if (token === null) {
return <></>;
}
return (
<div className="px-6 py-16">
<div className="mx-auto max-w-6xl">
{/* Page header */}
<div className="mb-10">
<h1 className="text-4xl font-extrabold text-slate-900">Analytics</h1>
<p className="mt-2 text-slate-600">
Token issuance and agent activity for the last 30 days
</p>
</div>
<div className="grid gap-8">
{/* Token Trend */}
<SectionCard title="Token Issuance Trend (Last 30 Days)">
{tokenTrend.error !== null && (
<ErrorBanner message={tokenTrend.error} />
)}
{tokenTrend.loading && tokenTrend.error === null && (
<ChartSkeleton label="Loading token trend chart…" height={300} />
)}
{tokenTrend.data !== null && (
<TokenTrendChart data={tokenTrend.data} />
)}
</SectionCard>
{/* Agent Activity Heatmap */}
<SectionCard title="Agent Activity by Day of Week (Last 30 Days)">
{agentActivity.error !== null && (
<ErrorBanner message={agentActivity.error} />
)}
{agentActivity.loading && agentActivity.error === null && (
<ChartSkeleton label="Loading activity chart…" height={300} />
)}
{agentActivity.data !== null && (
<AgentHeatmap data={agentActivity.data} />
)}
</SectionCard>
{/* Per-Agent Usage Table */}
<SectionCard title="Per-Agent Usage (Current Month)">
{agentUsage.error !== null && (
<ErrorBanner message={agentUsage.error} />
)}
{agentUsage.loading && agentUsage.error === null && (
<div className="animate-pulse space-y-2">
{[1, 2, 3].map((n) => (
<div key={n} className="h-10 rounded bg-slate-100" />
))}
</div>
)}
{agentUsage.data !== null && agentUsage.data.length === 0 && (
<p className="text-sm text-slate-500">
No agents have issued tokens this month.
</p>
)}
{agentUsage.data !== null && agentUsage.data.length > 0 && (
<div className="overflow-hidden rounded-xl border border-slate-200">
<table className="w-full text-sm">
<thead>
<tr className="bg-slate-50">
<th className="px-4 py-3 text-left font-semibold text-slate-700">
Agent
</th>
<th className="px-4 py-3 text-right font-semibold text-slate-700">
Tokens Issued
</th>
</tr>
</thead>
<tbody>
{agentUsage.data.map(({ agent_id, name, token_count }, i) => (
<tr
key={agent_id}
className={i % 2 === 0 ? 'bg-white' : 'bg-slate-50'}
>
<td className="px-4 py-3 text-slate-700">
<span className="font-medium">{name}</span>
<span className="ml-2 font-mono text-xs text-slate-400">
{agent_id}
</span>
</td>
<td className="px-4 py-3 text-right font-medium tabular-nums text-slate-900">
{token_count.toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</SectionCard>
</div>
</div>
</div>
);
}

137
portal/app/login/page.tsx Normal file
View File

@@ -0,0 +1,137 @@
'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>
);
}

View File

@@ -0,0 +1,432 @@
'use client';
/**
* TierPage — Tenant tier & billing dashboard for the SentryAgent portal.
*
* Displays:
* - Current tier name (styled badge)
* - Daily limits table: agents, API calls, token issuances
* - Current usage vs. limit for each metric with a visual progress bar
* - Time until daily reset
* - For free/pro tiers: an "Upgrade" button that calls POST /api/v1/tiers/upgrade
* and redirects to the returned Stripe checkoutUrl
* - For enterprise tier: an "Enterprise — Unlimited" label with no upgrade button
*
* Protected route: redirects to /login when no JWT is present (via useAuth).
*
* @module app/settings/tier/page
*/
import React, { useEffect, useState } from 'react';
import { useAuth } from '@/hooks/useAuth';
/* -------------------------------------------------------------------------
* Constants
* ---------------------------------------------------------------------- */
/** The ordered sequence of upgradeable tiers. */
const UPGRADE_PATH: Record<string, string> = {
free: 'pro',
pro: 'enterprise',
};
/** Human-readable tier labels. */
const TIER_LABELS: Record<string, string> = {
free: 'Free',
pro: 'Pro',
enterprise: 'Enterprise',
};
/** Tailwind badge colour classes per tier. */
const TIER_BADGE_CLASSES: Record<string, string> = {
free: 'bg-slate-100 text-slate-700',
pro: 'bg-brand-100 text-brand-700',
enterprise: 'bg-emerald-100 text-emerald-700',
};
/* -------------------------------------------------------------------------
* API response types
* ---------------------------------------------------------------------- */
/** Per-metric limit and usage returned by GET /api/v1/tiers/status. */
interface TierMetric {
/** Hard daily ceiling, or null when unlimited (enterprise). */
limit: number | null;
/** Current usage count for today. */
used: number;
}
/** Shape returned by GET /api/v1/tiers/status. */
interface TierStatus {
/** Canonical tier identifier: "free" | "pro" | "enterprise". */
tier: string;
/** Daily metrics. */
limits: {
agents: TierMetric;
api_calls: TierMetric;
token_issuances: TierMetric;
};
/** ISO-8601 timestamp for when the daily counters reset (UTC midnight). */
reset_at: string;
}
/** Shape returned by POST /api/v1/tiers/upgrade. */
interface UpgradeResponse {
/** Stripe Checkout URL to redirect the user to. */
checkoutUrl: string;
}
/* -------------------------------------------------------------------------
* Helpers
* ---------------------------------------------------------------------- */
/**
* Fetches a JSON endpoint with an Authorization bearer token.
*
* @param url - Absolute URL to fetch
* @param token - JWT bearer token
* @param options - Optional RequestInit overrides
* @returns Parsed JSON of type T
* @throws Error with a descriptive message on non-2xx or network failure
*/
async function fetchWithAuth<T>(
url: string,
token: string,
options: RequestInit = {},
): Promise<T> {
const res = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
...(options.headers ?? {}),
},
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { message?: string };
throw new Error(body.message ?? `Request failed: ${res.status}`);
}
return res.json() as Promise<T>;
}
/**
* Returns the number of whole minutes (rounded up) until the given ISO-8601
* reset timestamp.
*
* @param resetAt - ISO-8601 date string
* @returns Human-readable time-until string, e.g. "3 h 42 min"
*/
function formatTimeUntilReset(resetAt: string): string {
const msUntil = new Date(resetAt).getTime() - Date.now();
if (msUntil <= 0) return 'resetting now';
const totalMinutes = Math.ceil(msUntil / 60_000);
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (hours === 0) return `${minutes} min`;
return `${hours} h ${minutes} min`;
}
/* -------------------------------------------------------------------------
* Sub-components
* ---------------------------------------------------------------------- */
/** Props for UsageRow. */
interface UsageRowProps {
/** Display label, e.g. "API Calls". */
label: string;
/** Current usage count. */
used: number;
/** Hard ceiling, or null for unlimited. */
limit: number | null;
}
/**
* Renders a single row in the usage table with a visual progress bar.
*
* @param props - UsageRowProps
* @returns JSX element
*/
function UsageRow({ label, used, limit }: UsageRowProps): React.ReactElement {
const isUnlimited = limit === null;
const pct = isUnlimited ? 0 : Math.min(100, Math.round((used / limit) * 100));
const barColour =
pct >= 90 ? 'bg-red-500' : pct >= 70 ? 'bg-amber-400' : 'bg-brand-500';
return (
<tr>
<td className="px-4 py-3 text-sm font-medium text-slate-700">{label}</td>
<td className="px-4 py-3 text-right text-sm tabular-nums text-slate-900">
{used.toLocaleString()}
</td>
<td className="px-4 py-3 text-right text-sm tabular-nums text-slate-500">
{isUnlimited ? '∞' : limit.toLocaleString()}
</td>
<td className="px-4 py-3">
{isUnlimited ? (
<span className="text-xs font-medium text-emerald-600">Unlimited</span>
) : (
<div className="flex items-center gap-2">
<div className="h-2 w-24 overflow-hidden rounded-full bg-slate-100">
<div
className={`h-full rounded-full transition-all ${barColour}`}
style={{ width: `${pct}%` }}
/>
</div>
<span className="text-xs text-slate-400">{pct}%</span>
</div>
)}
</td>
</tr>
);
}
/** Props for TierBadge. */
interface TierBadgeProps {
tier: string;
}
/**
* Renders a styled pill badge for the given tier name.
*
* @param props - TierBadgeProps
* @returns JSX element
*/
function TierBadge({ tier }: TierBadgeProps): React.ReactElement {
const colourClass =
TIER_BADGE_CLASSES[tier] ?? 'bg-slate-100 text-slate-700';
const label = TIER_LABELS[tier] ?? tier;
return (
<span
className={`inline-block rounded-full px-3 py-1 text-sm font-semibold ${colourClass}`}
>
{label}
</span>
);
}
/** Props for ErrorBanner. */
interface ErrorBannerProps {
message: string;
}
/**
* Inline error banner for failed data fetches or actions.
*
* @param props - ErrorBannerProps
* @returns JSX element
*/
function ErrorBanner({ message }: ErrorBannerProps): React.ReactElement {
return (
<p className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">
{message}
</p>
);
}
/* -------------------------------------------------------------------------
* Page component
* ---------------------------------------------------------------------- */
/**
* Renders the Tier & Billing settings page.
*
* Checks authentication via `useAuth` (redirects to /login if no token).
* Fetches tier status from the AgentIdP API using the stored JWT.
*
* @returns JSX element
*/
export default function TierPage(): React.ReactElement {
const { token, loading: authLoading } = useAuth(true);
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000';
const [status, setStatus] = useState<TierStatus | null>(null);
const [fetchLoading, setFetchLoading] = useState(true);
const [fetchError, setFetchError] = useState<string | null>(null);
const [upgrading, setUpgrading] = useState(false);
const [upgradeError, setUpgradeError] = useState<string | null>(null);
useEffect(() => {
if (authLoading || token === null) return;
void fetchWithAuth<TierStatus>(`${apiUrl}/api/v1/tiers/status`, token)
.then((data) => {
setStatus(data);
setFetchLoading(false);
})
.catch((err: unknown) => {
setFetchError(
err instanceof Error ? err.message : 'Failed to load tier status',
);
setFetchLoading(false);
});
}, [authLoading, token, apiUrl]);
/**
* Initiates a tier upgrade by calling POST /api/v1/tiers/upgrade and
* redirecting the browser to the returned Stripe Checkout URL.
*/
async function handleUpgrade(): Promise<void> {
if (token === null || status === null) return;
const targetTier = UPGRADE_PATH[status.tier];
if (targetTier === undefined) return;
setUpgrading(true);
setUpgradeError(null);
try {
const { checkoutUrl } = await fetchWithAuth<UpgradeResponse>(
`${apiUrl}/api/v1/tiers/upgrade`,
token,
{
method: 'POST',
body: JSON.stringify({ target_tier: targetTier }),
},
);
window.location.href = checkoutUrl;
} catch (err: unknown) {
setUpgradeError(
err instanceof Error ? err.message : 'Upgrade failed — please try again',
);
setUpgrading(false);
}
}
/* ------------------------------------------------------------------
* Render: auth loading
* ---------------------------------------------------------------- */
if (authLoading) {
return (
<div className="flex min-h-[60vh] items-center justify-center">
<p className="text-slate-500">Loading</p>
</div>
);
}
// token === null means useAuth is redirecting to /login; render nothing
if (token === null) {
return <></>;
}
/* ------------------------------------------------------------------
* Render: data loading / error
* ---------------------------------------------------------------- */
return (
<div className="px-6 py-16">
<div className="mx-auto max-w-3xl">
{/* Page header */}
<div className="mb-10">
<h1 className="text-4xl font-extrabold text-slate-900">
Tier &amp; Billing
</h1>
<p className="mt-2 text-slate-600">
Your current plan, daily limits, and usage at a glance
</p>
</div>
{fetchError !== null && <ErrorBanner message={fetchError} />}
{fetchLoading && fetchError === null && (
<div className="animate-pulse space-y-4">
<div className="h-8 w-32 rounded-full bg-slate-100" />
<div className="h-48 rounded-2xl bg-slate-100" />
</div>
)}
{status !== null && (
<div className="space-y-6">
{/* Current tier card */}
<div className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<div className="flex items-center justify-between">
<div>
<p className="mb-1 text-sm font-medium text-slate-500">
Current Plan
</p>
<TierBadge tier={status.tier} />
</div>
{/* Enterprise: show label; free/pro: show upgrade button */}
{status.tier === 'enterprise' ? (
<span className="text-sm font-semibold text-emerald-600">
Enterprise Unlimited
</span>
) : (
<div className="flex flex-col items-end gap-2">
{upgradeError !== null && (
<ErrorBanner message={upgradeError} />
)}
<button
type="button"
onClick={() => { void handleUpgrade(); }}
disabled={upgrading}
className="rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{upgrading
? 'Redirecting…'
: `Upgrade to ${TIER_LABELS[UPGRADE_PATH[status.tier] ?? ''] ?? 'Next Tier'}`}
</button>
</div>
)}
</div>
</div>
{/* Usage table card */}
<div className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<h2 className="mb-4 text-lg font-semibold text-slate-900">
Daily Usage
</h2>
<div className="overflow-hidden rounded-xl border border-slate-200">
<table className="w-full text-sm">
<thead>
<tr className="bg-slate-50">
<th className="px-4 py-3 text-left font-semibold text-slate-700">
Metric
</th>
<th className="px-4 py-3 text-right font-semibold text-slate-700">
Used
</th>
<th className="px-4 py-3 text-right font-semibold text-slate-700">
Limit
</th>
<th className="px-4 py-3 text-left font-semibold text-slate-700">
Usage
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
<UsageRow
label="Agents"
used={status.limits.agents.used}
limit={status.limits.agents.limit}
/>
<UsageRow
label="API Calls"
used={status.limits.api_calls.used}
limit={status.limits.api_calls.limit}
/>
<UsageRow
label="Token Issuances"
used={status.limits.token_issuances.used}
limit={status.limits.token_issuances.limit}
/>
</tbody>
</table>
</div>
<p className="mt-3 text-xs text-slate-400">
Counters reset in{' '}
<span className="font-medium text-slate-600">
{formatTimeUntilReset(status.reset_at)}
</span>{' '}
(UTC midnight)
</p>
</div>
</div>
)}
</div>
</div>
);
}