feat: add license page with tier features and limits
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,184 @@
|
|||||||
export function LicensePage() {
|
import React, { useState } from 'react';
|
||||||
return <div>License</div>;
|
import {
|
||||||
|
Badge,
|
||||||
|
Card,
|
||||||
|
EmptyState,
|
||||||
|
Spinner,
|
||||||
|
} from '@cameleer/design-system';
|
||||||
|
import { useAuthStore } from '../auth/auth-store';
|
||||||
|
import { useLicense } from '../api/hooks';
|
||||||
|
|
||||||
|
const FEATURE_LABELS: Record<string, string> = {
|
||||||
|
topology: 'Topology',
|
||||||
|
lineage: 'Lineage',
|
||||||
|
correlation: 'Correlation',
|
||||||
|
debugger: 'Debugger',
|
||||||
|
replay: 'Replay',
|
||||||
|
};
|
||||||
|
|
||||||
|
const LIMIT_LABELS: Record<string, string> = {
|
||||||
|
maxAgents: 'Max Agents',
|
||||||
|
retentionDays: 'Retention Days',
|
||||||
|
maxEnvironments: 'Max Environments',
|
||||||
|
};
|
||||||
|
|
||||||
|
function tierColor(tier: string): 'primary' | 'success' | 'warning' | 'error' {
|
||||||
|
switch (tier?.toUpperCase()) {
|
||||||
|
case 'BUSINESS': return 'success';
|
||||||
|
case 'HIGH': return 'primary';
|
||||||
|
case 'MID': return 'warning';
|
||||||
|
case 'LOW': return 'error';
|
||||||
|
default: return 'primary';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function daysRemaining(expiresAt: string): number {
|
||||||
|
const now = Date.now();
|
||||||
|
const exp = new Date(expiresAt).getTime();
|
||||||
|
return Math.max(0, Math.ceil((exp - now) / (1000 * 60 * 60 * 24)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LicensePage() {
|
||||||
|
const tenantId = useAuthStore((s) => s.tenantId);
|
||||||
|
const { data: license, isLoading, isError } = useLicense(tenantId ?? '');
|
||||||
|
const [tokenExpanded, setTokenExpanded] = useState(false);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tenantId) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
title="No tenant associated"
|
||||||
|
description="Your account is not linked to a tenant. Please contact your administrator."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !license) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
title="License unavailable"
|
||||||
|
description="Unable to load license information. Please try again later."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const expDate = new Date(license.expiresAt).toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
const days = daysRemaining(license.expiresAt);
|
||||||
|
const isExpiringSoon = days <= 30;
|
||||||
|
const isExpired = days === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 p-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-semibold text-white">License</h1>
|
||||||
|
<Badge
|
||||||
|
label={license.tier.toUpperCase()}
|
||||||
|
color={tierColor(license.tier)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expiry info */}
|
||||||
|
<Card title="Validity">
|
||||||
|
<div className="flex flex-col gap-2 text-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-white/60">Issued</span>
|
||||||
|
<span className="text-white">
|
||||||
|
{new Date(license.issuedAt).toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-white/60">Expires</span>
|
||||||
|
<span className="text-white">{expDate}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-white/60">Days remaining</span>
|
||||||
|
<Badge
|
||||||
|
label={isExpired ? 'Expired' : `${days} days`}
|
||||||
|
color={isExpired ? 'error' : isExpiringSoon ? 'warning' : 'success'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Feature matrix */}
|
||||||
|
<Card title="Features">
|
||||||
|
<div className="divide-y divide-white/10">
|
||||||
|
{Object.entries(FEATURE_LABELS).map(([key, label]) => {
|
||||||
|
const enabled = license.features[key] ?? false;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="flex items-center justify-between py-3 first:pt-0 last:pb-0"
|
||||||
|
>
|
||||||
|
<span className="text-sm text-white">{label}</span>
|
||||||
|
<Badge
|
||||||
|
label={enabled ? 'Enabled' : 'Disabled'}
|
||||||
|
color={enabled ? 'success' : 'error'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Limits */}
|
||||||
|
<Card title="Limits">
|
||||||
|
<div className="divide-y divide-white/10">
|
||||||
|
{Object.entries(LIMIT_LABELS).map(([key, label]) => {
|
||||||
|
const value = license.limits[key];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="flex items-center justify-between py-3 first:pt-0 last:pb-0"
|
||||||
|
>
|
||||||
|
<span className="text-sm text-white/60">{label}</span>
|
||||||
|
<span className="text-sm font-mono text-white">
|
||||||
|
{value !== undefined ? value : '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* License token */}
|
||||||
|
<Card title="License Token">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-white/60">
|
||||||
|
Use this token when registering Cameleer agents with your tenant.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-sm text-primary-400 hover:text-primary-300 underline underline-offset-2 focus:outline-none"
|
||||||
|
onClick={() => setTokenExpanded((v) => !v)}
|
||||||
|
>
|
||||||
|
{tokenExpanded ? 'Hide token' : 'Show token'}
|
||||||
|
</button>
|
||||||
|
{tokenExpanded && (
|
||||||
|
<div className="mt-2 rounded bg-white/5 border border-white/10 p-3 overflow-x-auto">
|
||||||
|
<code className="text-xs font-mono text-white/80 break-all">
|
||||||
|
{license.token}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user