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:
351
portal/app/analytics/page.tsx
Normal file
351
portal/app/analytics/page.tsx
Normal 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
137
portal/app/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ const links: NavLink[] = [
|
||||
{ href: '/get-started', label: 'Get Started' },
|
||||
{ href: '/sdks', label: 'SDKs' },
|
||||
{ href: '/pricing', label: 'Pricing' },
|
||||
{ href: '/analytics', label: 'Analytics' },
|
||||
];
|
||||
|
||||
export function Nav(): React.ReactElement {
|
||||
|
||||
133
portal/components/charts/AgentHeatmap.tsx
Normal file
133
portal/components/charts/AgentHeatmap.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* AgentHeatmap — Recharts BarChart showing agent activity grouped by day-of-week.
|
||||
*
|
||||
* The API returns daily aggregates (no hour granularity), so this component
|
||||
* aggregates event counts per day-of-week across all agents and renders a
|
||||
* grouped bar chart (one bar per day, Mon–Sun).
|
||||
*
|
||||
* This component is designed to be lazy-loaded via `next/dynamic`. Do NOT
|
||||
* import it directly from a page; use dynamic(() => import('./AgentHeatmap'))
|
||||
* so that recharts stays out of the main bundle.
|
||||
*
|
||||
* @module components/charts/AgentHeatmap
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
|
||||
/** A single activity bucket from the API response. */
|
||||
export interface AgentActivityBucket {
|
||||
/** The agent's unique identifier. */
|
||||
agent_id: string;
|
||||
/** Day-of-week as an integer (0 = Sunday … 6 = Saturday). */
|
||||
dow: number;
|
||||
/** Total event count for this agent on this day-of-week. */
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** Props for the AgentHeatmap component. */
|
||||
export interface AgentHeatmapProps {
|
||||
/**
|
||||
* Array of per-agent, per-day-of-week activity buckets from
|
||||
* `GET /api/analytics/agents/activity`.
|
||||
*/
|
||||
data: AgentActivityBucket[];
|
||||
}
|
||||
|
||||
/** Display labels for days of the week, ordered Mon–Sun (dow 1–0). */
|
||||
const DOW_LABELS: Record<number, string> = {
|
||||
0: 'Sun',
|
||||
1: 'Mon',
|
||||
2: 'Tue',
|
||||
3: 'Wed',
|
||||
4: 'Thu',
|
||||
5: 'Fri',
|
||||
6: 'Sat',
|
||||
};
|
||||
|
||||
/** Ordered day-of-week values for Mon → Sun display. */
|
||||
const DOW_ORDER = [1, 2, 3, 4, 5, 6, 0];
|
||||
|
||||
/** Aggregated count per day-of-week for chart rendering. */
|
||||
interface DowAggregate {
|
||||
day: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates raw per-agent activity buckets into per-day-of-week totals
|
||||
* suitable for the bar chart.
|
||||
*
|
||||
* @param data - Raw activity buckets from the API
|
||||
* @returns Array of { day, count } sorted Mon → Sun
|
||||
*/
|
||||
function aggregateByDow(data: AgentActivityBucket[]): DowAggregate[] {
|
||||
const totals: Record<number, number> = {};
|
||||
for (const dow of DOW_ORDER) {
|
||||
totals[dow] = 0;
|
||||
}
|
||||
for (const bucket of data) {
|
||||
if (bucket.dow in totals) {
|
||||
totals[bucket.dow] += bucket.count;
|
||||
}
|
||||
}
|
||||
return DOW_ORDER.map((dow) => ({
|
||||
day: DOW_LABELS[dow],
|
||||
count: totals[dow],
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a responsive bar chart of agent activity grouped by day-of-week
|
||||
* using recharts. Data is aggregated across all agents.
|
||||
*
|
||||
* @param props - AgentHeatmapProps
|
||||
* @returns JSX element
|
||||
*/
|
||||
export default function AgentHeatmap({
|
||||
data,
|
||||
}: AgentHeatmapProps): React.ReactElement {
|
||||
const chartData = useMemo(() => aggregateByDow(data), [data]);
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart
|
||||
data={chartData}
|
||||
margin={{ top: 8, right: 16, left: 0, bottom: 8 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
tick={{ fontSize: 12, fill: '#64748b' }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: '#cbd5e1' }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12, fill: '#64748b' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number) => [value.toLocaleString(), 'Events']}
|
||||
contentStyle={{
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e2e8f0',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="count" fill="#6366f1" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
99
portal/components/charts/TokenTrendChart.tsx
Normal file
99
portal/components/charts/TokenTrendChart.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* TokenTrendChart — Recharts LineChart showing daily token issuance counts.
|
||||
*
|
||||
* This component is designed to be lazy-loaded via `next/dynamic`. Do NOT
|
||||
* import it directly from a page; use dynamic(() => import('./TokenTrendChart'))
|
||||
* so that recharts stays out of the main bundle.
|
||||
*
|
||||
* @module components/charts/TokenTrendChart
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
|
||||
/** A single data point for the token trend line chart. */
|
||||
export interface TokenTrendDataPoint {
|
||||
/** ISO 8601 date string (e.g. "2026-03-01"). */
|
||||
date: string;
|
||||
/** Number of tokens issued on this date. */
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** Props for the TokenTrendChart component. */
|
||||
export interface TokenTrendChartProps {
|
||||
/** Array of daily token issuance data points, sorted ascending by date. */
|
||||
data: TokenTrendDataPoint[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats an ISO date string as a short label (e.g. "Mar 1").
|
||||
*
|
||||
* @param dateStr - ISO 8601 date string
|
||||
* @returns Formatted short date label
|
||||
*/
|
||||
function formatDateLabel(dateStr: string): string {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a responsive line chart of daily token issuance counts using recharts.
|
||||
*
|
||||
* @param props - TokenTrendChartProps
|
||||
* @returns JSX element
|
||||
*/
|
||||
export default function TokenTrendChart({
|
||||
data,
|
||||
}: TokenTrendChartProps): React.ReactElement {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart
|
||||
data={data}
|
||||
margin={{ top: 8, right: 16, left: 0, bottom: 8 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={formatDateLabel}
|
||||
tick={{ fontSize: 12, fill: '#64748b' }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: '#cbd5e1' }}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12, fill: '#64748b' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number) => [value.toLocaleString(), 'Tokens issued']}
|
||||
labelFormatter={(label: string) => formatDateLabel(label)}
|
||||
contentStyle={{
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e2e8f0',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="count"
|
||||
stroke="#6366f1"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 4, fill: '#6366f1' }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
70
portal/hooks/useAuth.ts
Normal file
70
portal/hooks/useAuth.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* useAuth — Client-side authentication hook for the SentryAgent portal.
|
||||
*
|
||||
* Reads the tenant JWT stored in localStorage under the key
|
||||
* `sentryagent_token`. If no token is present the hook signals that the user
|
||||
* is unauthenticated so the calling page can redirect to `/login`.
|
||||
*
|
||||
* This is intentionally lightweight: the portal calls the AgentIdP API
|
||||
* directly; the JWT is issued by the AgentIdP `/api/tenants/login` endpoint
|
||||
* and stored on successful sign-in.
|
||||
*
|
||||
* @module hooks/useAuth
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
/** The localStorage key under which the tenant JWT is persisted. */
|
||||
export const AUTH_TOKEN_KEY = 'sentryagent_token';
|
||||
|
||||
/** Shape returned by the useAuth hook. */
|
||||
export interface AuthState {
|
||||
/** The stored JWT, or null if unauthenticated. */
|
||||
token: string | null;
|
||||
/** True while the hook is reading from localStorage on mount. */
|
||||
loading: boolean;
|
||||
/**
|
||||
* Sign the user out by removing the stored token and redirecting to /login.
|
||||
*/
|
||||
signOut: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current authentication state and provides a sign-out helper.
|
||||
* Redirects to `/login` when no token is found (after the initial mount check).
|
||||
*
|
||||
* @param redirectOnUnauth - When true (default), redirects to /login if
|
||||
* no token is present. Pass false on public pages.
|
||||
* @returns AuthState
|
||||
*/
|
||||
export function useAuth(redirectOnUnauth = true): AuthState {
|
||||
const router = useRouter();
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const stored =
|
||||
typeof window !== 'undefined'
|
||||
? localStorage.getItem(AUTH_TOKEN_KEY)
|
||||
: null;
|
||||
setToken(stored);
|
||||
setLoading(false);
|
||||
|
||||
if (!stored && redirectOnUnauth) {
|
||||
router.replace('/login');
|
||||
}
|
||||
}, [redirectOnUnauth, router]);
|
||||
|
||||
const signOut = (): void => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem(AUTH_TOKEN_KEY);
|
||||
}
|
||||
setToken(null);
|
||||
router.replace('/login');
|
||||
};
|
||||
|
||||
return { token, loading, signOut };
|
||||
}
|
||||
344
portal/package-lock.json
generated
344
portal/package-lock.json
generated
@@ -9,9 +9,11 @@
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@stoplight/elements": "^9.0.16",
|
||||
"date-fns": "^3.3.0",
|
||||
"next": "14.2.5",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^18.3.1",
|
||||
"recharts": "^2.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.14.0",
|
||||
@@ -1418,6 +1420,69 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/har-format": {
|
||||
"version": "1.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz",
|
||||
@@ -2020,6 +2085,137 @@
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
|
||||
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -2037,6 +2233,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deepmerge": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||
@@ -2174,6 +2376,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/extend": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||
@@ -2186,6 +2394,15 @@
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-equals": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
|
||||
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||
@@ -2934,6 +3151,15 @@
|
||||
"css-in-js-utils": "^3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-arguments": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
|
||||
@@ -4937,6 +5163,47 @@
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-smooth": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
|
||||
"integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-equals": "^5.0.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"react-transition-group": "^4.4.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-transition-group": {
|
||||
"version": "4.4.5",
|
||||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
||||
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.5.5",
|
||||
"dom-helpers": "^5.0.1",
|
||||
"loose-envify": "^1.4.0",
|
||||
"prop-types": "^15.6.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.6.0",
|
||||
"react-dom": ">=16.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-transition-group/node_modules/dom-helpers": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.8.7",
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/react-universal-interface": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/react-universal-interface/-/react-universal-interface-0.6.2.tgz",
|
||||
@@ -4969,6 +5236,53 @@
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "2.15.4",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
|
||||
"integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"clsx": "^2.0.0",
|
||||
"eventemitter3": "^4.0.1",
|
||||
"lodash": "^4.17.21",
|
||||
"react-is": "^18.3.1",
|
||||
"react-smooth": "^4.0.4",
|
||||
"recharts-scale": "^0.4.4",
|
||||
"tiny-invariant": "^1.3.1",
|
||||
"victory-vendor": "^36.6.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts-scale": {
|
||||
"version": "0.4.5",
|
||||
"resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
|
||||
"integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"decimal.js-light": "^2.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts/node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts/node_modules/react-is": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/remark-frontmatter": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/remark-frontmatter/-/remark-frontmatter-3.0.0.tgz",
|
||||
@@ -5452,6 +5766,12 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
@@ -6014,6 +6334,28 @@
|
||||
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "36.9.2",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
|
||||
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
|
||||
"license": "MIT AND ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/web-namespaces": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
|
||||
|
||||
@@ -10,9 +10,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@stoplight/elements": "^9.0.16",
|
||||
"date-fns": "^3.3.0",
|
||||
"next": "14.2.5",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^18.3.1",
|
||||
"recharts": "^2.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.14.0",
|
||||
|
||||
Reference in New Issue
Block a user