Files
cameleer-saas/ui/src/pages/LicensePage.tsx
hsiegeln c96faa4f3f
All checks were successful
CI / build (push) Successful in 1m0s
CI / docker (push) Successful in 55s
fix: display username in UI, fix license limits key mismatch
- Read user profile from Logto ID token in OrgResolver, store in
  Zustand org store, display in sidebar footer and TopBar avatar
- Fix license limits showing "—" by aligning frontend LIMIT_LABELS
  keys with backend snake_case convention (max_agents, retention_days,
  max_environments)
- Bump @cameleer/design-system to v0.1.38 (font-size floor)
- Add dev volume mount for local UI hot-reload without image rebuild

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 12:20:40 +02:00

185 lines
5.5 KiB
TypeScript

import React, { useState } from 'react';
import {
Badge,
Card,
EmptyState,
Spinner,
} from '@cameleer/design-system';
import { useAuth } from '../auth/useAuth';
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> = {
max_agents: 'Max Agents',
retention_days: 'Retention Days',
max_environments: '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 } = useAuth();
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>
);
}