398 lines
17 KiB
TypeScript
398 lines
17 KiB
TypeScript
import React, { useEffect, useMemo, useState } from 'react';
|
|
import {
|
|
Key, RefreshCw, ShieldAlert, CheckCircle2, AlertTriangle, Clock,
|
|
Calendar, Building2, BadgeCheck, Sparkles, FileSignature, Copy, Check
|
|
} from 'lucide-react';
|
|
|
|
interface LicensingEngineProps {
|
|
tenants: any[];
|
|
metrics: { active_licenses?: number; expired_licenses?: number } | null;
|
|
onRefresh?: () => void;
|
|
}
|
|
|
|
interface JwksKey {
|
|
kty?: string;
|
|
crv?: string;
|
|
alg?: string;
|
|
use?: string;
|
|
kid?: string;
|
|
pubKeyPem?: string;
|
|
}
|
|
|
|
function parseDate(d: any): Date | null {
|
|
if (!d) return null;
|
|
const dt = new Date(d);
|
|
return isNaN(dt.getTime()) ? null : dt;
|
|
}
|
|
|
|
function fmtDate(d: any): string {
|
|
const dt = parseDate(d);
|
|
return dt ? dt.toLocaleDateString() : '—';
|
|
}
|
|
|
|
function daysBetween(a: Date, b: Date): number {
|
|
const ms = b.getTime() - a.getTime();
|
|
return Math.ceil(ms / (1000 * 60 * 60 * 24));
|
|
}
|
|
|
|
function statusBadge(status: string | undefined, isExpired: boolean) {
|
|
if (status === 'overridden') {
|
|
return { label: 'OVERRIDDEN', cls: 'bg-amber-500/10 border-amber-500/20 text-amber-500', icon: ShieldAlert };
|
|
}
|
|
if (isExpired) {
|
|
return { label: 'EXPIRED', cls: 'bg-rose-500/10 border-rose-500/20 text-rose-500', icon: AlertTriangle };
|
|
}
|
|
return { label: 'ACTIVE', cls: 'bg-emerald-500/10 border-emerald-500/20 text-emerald-500', icon: CheckCircle2 };
|
|
}
|
|
|
|
export function LicensingEngine({ tenants, metrics, onRefresh }: LicensingEngineProps) {
|
|
const now = useMemo(() => new Date(), []);
|
|
const horizon30 = useMemo(() => new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), [now]);
|
|
|
|
const [trustAnchor, setTrustAnchor] = useState<{ kid?: string; pubKeyPem?: string; error?: string } | null>(null);
|
|
const [copied, setCopied] = useState(false);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
(async () => {
|
|
try {
|
|
const res = await fetch('/api/v1/auth/jwks');
|
|
if (!res.ok) throw new Error(`JWKS fetch failed: HTTP ${res.status}`);
|
|
const data = await res.json();
|
|
const key: JwksKey | undefined = Array.isArray(data?.keys) ? data.keys[0] : undefined;
|
|
if (cancelled) return;
|
|
setTrustAnchor({ kid: key?.kid, pubKeyPem: key?.pubKeyPem });
|
|
} catch (err: any) {
|
|
if (cancelled) return;
|
|
setTrustAnchor({ error: err?.message || 'Failed to load JWKS' });
|
|
}
|
|
})();
|
|
return () => { cancelled = true; };
|
|
}, []);
|
|
|
|
const copyPublicKey = async () => {
|
|
if (!trustAnchor?.pubKeyPem) return;
|
|
try {
|
|
await navigator.clipboard.writeText(trustAnchor.pubKeyPem);
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
} catch {
|
|
setCopied(false);
|
|
}
|
|
};
|
|
|
|
const counts = useMemo(() => {
|
|
let active = 0, expired = 0, overridden = 0;
|
|
for (const t of tenants) {
|
|
if (t.license?.status === 'overridden') overridden += 1;
|
|
else if (t.is_expired) expired += 1;
|
|
else active += 1;
|
|
}
|
|
return {
|
|
active: metrics?.active_licenses ?? active,
|
|
expired: metrics?.expired_licenses ?? expired,
|
|
overridden,
|
|
};
|
|
}, [tenants, metrics]);
|
|
|
|
const expiringSoon = useMemo(() => {
|
|
return tenants
|
|
.filter(t => {
|
|
const end = parseDate(t.license?.end_date);
|
|
const isOverridden = t.license?.status === 'overridden';
|
|
if (!end || isOverridden) return false;
|
|
return end.getTime() >= now.getTime() && end.getTime() <= horizon30.getTime();
|
|
})
|
|
.sort((a, b) => {
|
|
const ea = parseDate(a.license?.end_date)?.getTime() ?? 0;
|
|
const eb = parseDate(b.license?.end_date)?.getTime() ?? 0;
|
|
return ea - eb;
|
|
});
|
|
}, [tenants, now, horizon30]);
|
|
|
|
const recentlyIssued = useMemo(() => {
|
|
return [...tenants]
|
|
.filter(t => parseDate(t.license?.end_date))
|
|
.sort((a, b) => {
|
|
const ea = parseDate(a.license?.end_date)?.getTime() ?? 0;
|
|
const eb = parseDate(b.license?.end_date)?.getTime() ?? 0;
|
|
return eb - ea;
|
|
})
|
|
.slice(0, 10);
|
|
}, [tenants]);
|
|
|
|
const activeOverrides = useMemo(() => {
|
|
return tenants.filter(t => t.license?.status === 'overridden');
|
|
}, [tenants]);
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
{/* Header */}
|
|
<header className="flex flex-wrap items-center justify-between gap-4 pb-6 border-b border-border">
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<h1 className="text-2xl font-black tracking-tight text-foreground font-heading">
|
|
Cryptographic <span className="text-primary">Licensing Engine</span>
|
|
</h1>
|
|
<span className="px-2.5 py-0.5 rounded-full bg-indigo-500/10 border border-indigo-500/20 text-indigo-500 text-xs font-mono font-bold flex items-center gap-1">
|
|
<BadgeCheck size={12} /> SIGNED · Ed25519 (EdDSA)
|
|
</span>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground mt-1 font-medium">
|
|
Asymmetric Ed25519 (EdDSA) term licenses issued by LicenseTokenGenerator; verifiable offline via tenant PUBLIC_KEY.
|
|
</p>
|
|
</div>
|
|
{onRefresh && (
|
|
<button
|
|
onClick={onRefresh}
|
|
className="flex items-center gap-2 px-4 py-2.5 bg-card hover:bg-muted text-foreground rounded-xl text-xs font-bold transition-all border border-border shadow-sm"
|
|
>
|
|
<RefreshCw size={14} className="text-primary" />
|
|
<span>Recompute License State</span>
|
|
</button>
|
|
)}
|
|
</header>
|
|
|
|
{/* License Health row */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-5">
|
|
<div className="bg-card border border-border rounded-2xl p-6 relative overflow-hidden shadow-sm">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<span className="text-xs font-bold uppercase tracking-widest text-muted-foreground">Total Active</span>
|
|
<div className="stat-icon green"><CheckCircle2 size={20} /></div>
|
|
</div>
|
|
<h3 className="text-3xl font-black text-emerald-500">{counts.active}</h3>
|
|
<p className="text-[0.7rem] text-muted-foreground font-mono mt-2">Valid signed tokens in field</p>
|
|
</div>
|
|
|
|
<div className="bg-card border border-border rounded-2xl p-6 relative overflow-hidden shadow-sm">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<span className="text-xs font-bold uppercase tracking-widest text-muted-foreground">Total Expired</span>
|
|
<div className="stat-icon orange"><Clock size={20} /></div>
|
|
</div>
|
|
<h3 className="text-3xl font-black text-rose-500">{counts.expired}</h3>
|
|
<p className="text-[0.7rem] text-muted-foreground font-mono mt-2">In grace period or past end_date</p>
|
|
</div>
|
|
|
|
<div className="bg-card border border-border rounded-2xl p-6 relative overflow-hidden shadow-sm">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<span className="text-xs font-bold uppercase tracking-widest text-muted-foreground">Total Overridden</span>
|
|
<div className="stat-icon purple"><ShieldAlert size={20} /></div>
|
|
</div>
|
|
<h3 className="text-3xl font-black text-amber-500">{counts.overridden}</h3>
|
|
<p className="text-[0.7rem] text-muted-foreground font-mono mt-2">Emergency overrides granted</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Expiring Soon */}
|
|
<section className="bg-card border border-border rounded-2xl p-6 shadow-sm">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div>
|
|
<h2 className="text-sm font-black text-foreground font-heading uppercase tracking-wider">Expiring Within 30 Days</h2>
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
Term licenses reaching end_date before {fmtDate(horizon30)}.
|
|
</p>
|
|
</div>
|
|
<Calendar size={18} className="text-primary" />
|
|
</div>
|
|
{expiringSoon.length === 0 ? (
|
|
<div className="py-6 text-center text-muted-foreground font-mono text-xs">
|
|
No licenses expiring in the next 30 days.
|
|
</div>
|
|
) : (
|
|
<div className="ng-table-container">
|
|
<table className="ng-table">
|
|
<thead className="ng-table-header">
|
|
<tr>
|
|
<th>School</th>
|
|
<th>Term</th>
|
|
<th>End Date</th>
|
|
<th>Days Remaining</th>
|
|
<th>Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{expiringSoon.map(t => {
|
|
const end = parseDate(t.license?.end_date);
|
|
const daysLeft = end ? daysBetween(now, end) : 0;
|
|
const badge = statusBadge(t.license?.status, !!t.is_expired);
|
|
const BadgeIcon = badge.icon;
|
|
return (
|
|
<tr key={t.id} className="ng-table-row">
|
|
<td className="ng-table-cell">
|
|
<div className="flex items-center gap-2">
|
|
<Building2 size={14} className="text-muted-foreground" />
|
|
<span className="font-bold text-foreground text-sm">{t.name}</span>
|
|
<span className="text-[0.65rem] text-muted-foreground font-mono">{t.code}</span>
|
|
</div>
|
|
</td>
|
|
<td className="ng-table-cell">{t.license?.term_name || 'Term 1'}</td>
|
|
<td className="ng-table-cell font-mono">{fmtDate(t.license?.end_date)}</td>
|
|
<td className="ng-table-cell font-black text-rose-500">{daysLeft > 0 ? `${daysLeft}d` : 'today'}</td>
|
|
<td className="ng-table-cell">
|
|
<span className={`ng-table-badge ${badge.cls}`}>
|
|
<BadgeIcon size={11} /> {badge.label}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{/* Recently Issued */}
|
|
<section className="bg-card border border-border rounded-2xl p-6 shadow-sm">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div>
|
|
<h2 className="text-sm font-black text-foreground font-heading uppercase tracking-wider">Recently Issued Licenses</h2>
|
|
<p className="text-xs text-muted-foreground mt-0.5">Top 10 most recently issued term licenses.</p>
|
|
</div>
|
|
<FileSignature size={18} className="text-primary" />
|
|
</div>
|
|
{recentlyIssued.length === 0 ? (
|
|
<div className="py-6 text-center text-muted-foreground font-mono text-xs">
|
|
No licenses have been issued yet.
|
|
</div>
|
|
) : (
|
|
<div className="ng-table-container">
|
|
<table className="ng-table">
|
|
<thead className="ng-table-header">
|
|
<tr>
|
|
<th>School</th>
|
|
<th>Issued</th>
|
|
<th>Expires</th>
|
|
<th>Term</th>
|
|
<th>Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{recentlyIssued.map(t => {
|
|
const badge = statusBadge(t.license?.status, !!t.is_expired);
|
|
const BadgeIcon = badge.icon;
|
|
return (
|
|
<tr key={t.id} className="ng-table-row">
|
|
<td className="ng-table-cell">
|
|
<div className="flex items-center gap-2">
|
|
<Building2 size={14} className="text-muted-foreground" />
|
|
<span className="font-bold text-foreground text-sm">{t.name}</span>
|
|
<span className="text-[0.65rem] text-muted-foreground font-mono">{t.code}</span>
|
|
</div>
|
|
</td>
|
|
<td className="ng-table-cell font-mono">{fmtDate(t.license?.start_date)}</td>
|
|
<td className="ng-table-cell font-mono">{fmtDate(t.license?.end_date)}</td>
|
|
<td className="ng-table-cell">{t.license?.term_name || 'Term 1'}</td>
|
|
<td className="ng-table-cell">
|
|
<span className={`ng-table-badge ${badge.cls}`}>
|
|
<BadgeIcon size={11} /> {badge.label}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{/* Active Overrides */}
|
|
<section className="bg-card border border-border rounded-2xl p-6 shadow-sm">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div>
|
|
<h2 className="text-sm font-black text-foreground font-heading uppercase tracking-wider">Active Overrides</h2>
|
|
<p className="text-xs text-muted-foreground mt-0.5">Emergency bypasses currently in effect.</p>
|
|
</div>
|
|
<Sparkles size={18} className="text-amber-500" />
|
|
</div>
|
|
{activeOverrides.length === 0 ? (
|
|
<div className="py-6 text-center text-muted-foreground font-mono text-xs">
|
|
No emergency overrides active.
|
|
</div>
|
|
) : (
|
|
<div className="ng-table-container">
|
|
<table className="ng-table">
|
|
<thead className="ng-table-header">
|
|
<tr>
|
|
<th>School</th>
|
|
<th>Override Reason</th>
|
|
<th>Override End Date</th>
|
|
<th>Granted</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{activeOverrides.map(t => (
|
|
<tr key={t.id} className="ng-table-row">
|
|
<td className="ng-table-cell">
|
|
<div className="flex items-center gap-2">
|
|
<Building2 size={14} className="text-muted-foreground" />
|
|
<span className="font-bold text-foreground text-sm">{t.name}</span>
|
|
<span className="text-[0.65rem] text-muted-foreground font-mono">{t.code}</span>
|
|
</div>
|
|
</td>
|
|
<td className="ng-table-cell">{t.license?.override_reason || t.license?.reason || '—'}</td>
|
|
<td className="ng-table-cell font-mono">{fmtDate(t.license?.override_end_date || t.license?.end_date)}</td>
|
|
<td className="ng-table-cell">
|
|
<span className="ng-table-badge bg-amber-500/10 border-amber-500/20 text-amber-500">
|
|
<ShieldAlert size={11} /> Emergency Override Granted
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{/* Trust Anchor (Public Key / JWKS) */}
|
|
<section className="bg-card border border-border rounded-2xl p-6 shadow-sm">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div>
|
|
<h2 className="text-sm font-black text-foreground font-heading uppercase tracking-wider">Trust Anchor · Public Key</h2>
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
Set <code className="font-mono">SUPERADMIN_PUBLIC_KEY</code> in <code className="font-mono">server/.env</code> to this value so the tenant middleware can verify Ed25519 signatures offline.
|
|
</p>
|
|
</div>
|
|
<Key size={18} className="text-indigo-500" />
|
|
</div>
|
|
{trustAnchor?.error ? (
|
|
<div className="py-4 text-rose-500 font-mono text-xs">JWKS error: {trustAnchor.error}</div>
|
|
) : !trustAnchor?.pubKeyPem ? (
|
|
<div className="py-4 text-muted-foreground font-mono text-xs">Loading JWKS…</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-2 text-[0.7rem] font-mono text-muted-foreground">
|
|
<span>alg: <strong className="text-foreground">{trustAnchor.kid ? 'EdDSA' : '—'}</strong></span>
|
|
<span>·</span>
|
|
<span>crv: <strong className="text-foreground">Ed25519</strong></span>
|
|
{trustAnchor.kid && (
|
|
<>
|
|
<span>·</span>
|
|
<span>kid: <strong className="text-foreground">{trustAnchor.kid}</strong></span>
|
|
</>
|
|
)}
|
|
<button
|
|
onClick={copyPublicKey}
|
|
className="ml-auto flex items-center gap-1 text-primary hover:underline"
|
|
>
|
|
{copied ? <Check size={12} className="text-emerald-500" /> : <Copy size={12} />}
|
|
<span>{copied ? 'Copied' : 'Copy PEM'}</span>
|
|
</button>
|
|
</div>
|
|
<div className="p-3.5 rounded-xl bg-slate-950 border border-slate-800 font-mono text-[0.68rem] text-amber-300/90 break-all select-all max-h-32 overflow-y-auto custom-scrollbar">
|
|
{trustAnchor.pubKeyPem}
|
|
</div>
|
|
<p className="text-[0.7rem] font-mono text-muted-foreground">
|
|
JWKS endpoint: <code>GET /api/v1/auth/jwks</code> (public, no auth)
|
|
</p>
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default LicensingEngine;
|