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:
432
portal/app/settings/tier/page.tsx
Normal file
432
portal/app/settings/tier/page.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user