geocrop-platform./apps/nextgen/superadmin/client/src/pages/LicensingEngine.tsx

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;