'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 = { free: 'pro', pro: 'enterprise', }; /** Human-readable tier labels. */ const TIER_LABELS: Record = { free: 'Free', pro: 'Pro', enterprise: 'Enterprise', }; /** Tailwind badge colour classes per tier. */ const TIER_BADGE_CLASSES: Record = { 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( url: string, token: string, options: RequestInit = {}, ): Promise { 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; } /** * 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 ( {label} {used.toLocaleString()} {isUnlimited ? '∞' : limit.toLocaleString()} {isUnlimited ? ( Unlimited ) : (
{pct}%
)} ); } /** 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 ( {label} ); } /** 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 (

{message}

); } /* ------------------------------------------------------------------------- * 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(null); const [fetchLoading, setFetchLoading] = useState(true); const [fetchError, setFetchError] = useState(null); const [upgrading, setUpgrading] = useState(false); const [upgradeError, setUpgradeError] = useState(null); useEffect(() => { if (authLoading || token === null) return; void fetchWithAuth(`${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 { 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( `${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 (

Loading…

); } // token === null means useAuth is redirecting to /login; render nothing if (token === null) { return <>; } /* ------------------------------------------------------------------ * Render: data loading / error * ---------------------------------------------------------------- */ return (
{/* Page header */}

Tier & Billing

Your current plan, daily limits, and usage at a glance

{fetchError !== null && } {fetchLoading && fetchError === null && (
)} {status !== null && (
{/* Current tier card */}

Current Plan

{/* Enterprise: show label; free/pro: show upgrade button */} {status.tier === 'enterprise' ? ( Enterprise — Unlimited ) : (
{upgradeError !== null && ( )}
)}
{/* Usage table card */}

Daily Usage

Metric Used Limit Usage

Counters reset in{' '} {formatTimeUntilReset(status.reset_at)} {' '} (UTC midnight)

)}
); }