feat(ui): add license minting form, verify tool, and update all pages
Some checks failed
CI / build (push) Successful in 2m42s
CI / docker (push) Failing after 51s

Vendor UI:
- TenantDetailPage: full minting form with tier presets, 13 configurable
  limits, expiry/grace period, label. Mint & Push or Mint & Copy actions.
  License bundle display with all three env vars for standalone deployment.
- LicenseVerifyPage: paste token to decode + validate signature, shows
  envelope details and state badge. Public key viewer with copy button.
- Layout: added "License Tools" nav item under Vendor section.
- vendor-hooks: useMintLicense, useLicensePresets, useVerifyLicense, usePublicKey

Tenant UI:
- TenantLicensePage: replaced features card with full 13-key limits display,
  added grace period and label fields
- TenantDashboardPage: fixed limit keys (agents→max_agents, environments→max_environments)

Common:
- Updated types (dropped features, added label/gracePeriodDays/bundle types)
- Updated tier colors for STARTER/TEAM/BUSINESS/ENTERPRISE
- Updated CreateTenantPage tier dropdown

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-26 17:41:40 +02:00
parent 4dea1c6764
commit ffb7ef0839
11 changed files with 552 additions and 63 deletions

View File

@@ -4,7 +4,7 @@ import { Button, Card, FormField, Input, useToast } from '@cameleer/design-syste
import { useCreateTenant } from '../../api/vendor-hooks';
import { toSlug } from '../../utils/slug';
const TIERS = ['LOW', 'MID', 'HIGH', 'BUSINESS'];
const TIERS = ['STARTER', 'TEAM', 'BUSINESS', 'ENTERPRISE'];
export function CreateTenantPage() {
const navigate = useNavigate();
@@ -14,7 +14,7 @@ export function CreateTenantPage() {
const [name, setName] = useState('');
const [slug, setSlug] = useState('');
const [slugTouched, setSlugTouched] = useState(false);
const [tier, setTier] = useState('LOW');
const [tier, setTier] = useState('STARTER');
const [adminUsername, setAdminUsername] = useState('');
const [adminPassword, setAdminPassword] = useState('');

View File

@@ -0,0 +1,187 @@
import { useState } from 'react';
import {
Badge,
Button,
Card,
useToast,
} from '@cameleer/design-system';
import { Clipboard, Search, ShieldCheck } from 'lucide-react';
import { useVerifyLicense, usePublicKey } from '../../api/vendor-hooks';
import styles from '../../styles/platform.module.css';
import type { VerifyLicenseResponse } from '../../types/api';
const LIMIT_LABELS: Record<string, string> = {
max_environments: 'Environments',
max_apps: 'Apps',
max_agents: 'Agents',
max_users: 'Users',
max_outbound_connections: 'Outbound Connections',
max_alert_rules: 'Alert Rules',
max_total_cpu_millis: 'CPU (millicores)',
max_total_memory_mb: 'Memory (MB)',
max_total_replicas: 'Replicas',
max_execution_retention_days: 'Execution Retention (days)',
max_log_retention_days: 'Log Retention (days)',
max_metric_retention_days: 'Metric Retention (days)',
max_jar_retention_count: 'JAR Retention Count',
};
function stateColor(state: string): 'success' | 'warning' | 'error' | 'auto' {
switch (state) {
case 'ACTIVE': return 'success';
case 'GRACE': return 'warning';
case 'EXPIRED': return 'error';
case 'INVALID': return 'error';
default: return 'auto';
}
}
export function LicenseVerifyPage() {
const { toast } = useToast();
const verifyLicense = useVerifyLicense();
const { data: keyData } = usePublicKey();
const [token, setToken] = useState('');
const [result, setResult] = useState<VerifyLicenseResponse | null>(null);
async function handleVerify() {
if (!token.trim()) return;
try {
const res = await verifyLicense.mutateAsync(token.trim());
setResult(res);
} catch (err) {
toast({ title: 'Verification failed', description: String(err), variant: 'error' });
}
}
async function handleCopyPublicKey() {
if (!keyData?.publicKey) return;
try {
await navigator.clipboard.writeText(keyData.publicKey);
toast({ title: 'Public key copied', variant: 'success' });
} catch {
toast({ title: 'Copy failed', variant: 'error' });
}
}
return (
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<h1 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 600 }}>License Tools</h1>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(400px, 1fr))', gap: 16 }}>
{/* Verify Token */}
<Card title="Verify License Token">
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<p className={styles.description}>
Paste a signed license token to decode and validate its signature.
</p>
<textarea
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="base64payload.base64signature"
rows={4}
style={{
width: '100%', padding: '8px 12px', border: '1px solid var(--border)',
borderRadius: 6, background: 'var(--bg-surface)', color: 'var(--text-primary)',
fontSize: '0.8rem', fontFamily: 'monospace', resize: 'vertical',
}}
/>
<Button variant="primary" onClick={handleVerify} loading={verifyLicense.isPending} disabled={!token.trim()}>
<Search size={14} style={{ marginRight: 6, verticalAlign: -2 }} />
Verify
</Button>
</div>
</Card>
{/* Public Key */}
<Card title="Signing Public Key">
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<p className={styles.description}>
This is the Ed25519 public key used to verify license signatures.
Distribute to standalone tenants via <code>CAMELEER_SERVER_LICENSE_PUBLICKEY</code>.
</p>
{keyData?.publicKey ? (
<>
<div className={styles.tokenBlock}>
<code className={styles.tokenCode} style={{ fontSize: 11, wordBreak: 'break-all' }}>
{keyData.publicKey}
</code>
</div>
<Button variant="secondary" onClick={handleCopyPublicKey}>
<Clipboard size={14} style={{ marginRight: 6, verticalAlign: -2 }} />
Copy Public Key
</Button>
</>
) : (
<p className={styles.description}>Loading...</p>
)}
</div>
</Card>
</div>
{/* Verification Result */}
{result && (
<Card title="Verification Result">
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<ShieldCheck size={20} style={{ color: result.valid ? 'var(--success)' : 'var(--error)' }} />
<Badge
label={result.valid ? 'Valid Signature' : 'Invalid'}
color={result.valid ? 'success' : 'error'}
/>
{result.valid && (
<Badge label={result.state} color={stateColor(result.state)} />
)}
</div>
{result.error && (
<p style={{ color: 'var(--error)', fontSize: 13, margin: 0 }}>{result.error}</p>
)}
{result.valid && (
<div className={styles.dividerList}>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Tenant ID</span>
<span className={styles.kvValueMono}>{result.tenantId}</span>
</div>
{result.label && (
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Label</span>
<span className={styles.kvValue}>{result.label}</span>
</div>
)}
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Issued</span>
<span className={styles.kvValue}>{result.issuedAt ? new Date(result.issuedAt).toLocaleDateString() : '—'}</span>
</div>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Expires</span>
<span className={styles.kvValue}>{result.expiresAt ? new Date(result.expiresAt).toLocaleDateString() : '—'}</span>
</div>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Grace Period</span>
<span className={styles.kvValue}>{result.gracePeriodDays}d</span>
</div>
</div>
)}
{result.valid && result.limits && (
<div>
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--text-muted)', display: 'block', marginBottom: 8 }}>Limits</label>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: 4 }}>
{Object.entries(result.limits).map(([key, value]) => (
<div key={key} className={styles.kvRow}>
<span className={styles.kvLabel} style={{ fontSize: 12 }}>{LIMIT_LABELS[key] ?? key}</span>
<span className={styles.kvValue} style={{ fontSize: 12, fontVariantNumeric: 'tabular-nums' }}>{value.toLocaleString()}</span>
</div>
))}
</div>
</div>
)}
</div>
</Card>
)}
</div>
);
}

View File

@@ -9,19 +9,39 @@ import {
Spinner,
useToast,
} from '@cameleer/design-system';
import { ArrowLeft, ArrowUpCircle, RefreshCw, Trash2 } from 'lucide-react';
import { ArrowLeft, ArrowUpCircle, Check, Clipboard, RefreshCw, Send, Trash2 } from 'lucide-react';
import {
useVendorTenant,
useSuspendTenant,
useActivateTenant,
useDeleteTenant,
useRenewLicense,
useMintLicense,
useLicensePresets,
useRestartServer,
useUpgradeServer,
} from '../../api/vendor-hooks';
import { ServerStatusBadge } from '../../components/ServerStatusBadge';
import { tierColor } from '../../utils/tier';
import styles from '../../styles/platform.module.css';
import type { LicenseBundleResponse, MintLicenseRequest } from '../../types/api';
const LIMIT_LABELS: Record<string, string> = {
max_environments: 'Environments',
max_apps: 'Apps',
max_agents: 'Agents',
max_users: 'Users',
max_outbound_connections: 'Outbound Connections',
max_alert_rules: 'Alert Rules',
max_total_cpu_millis: 'CPU (millicores)',
max_total_memory_mb: 'Memory (MB)',
max_total_replicas: 'Replicas',
max_execution_retention_days: 'Execution Retention (days)',
max_log_retention_days: 'Log Retention (days)',
max_metric_retention_days: 'Metric Retention (days)',
max_jar_retention_count: 'JAR Retention Count',
};
const LIMIT_KEYS = Object.keys(LIMIT_LABELS);
function licenseDaysRemaining(expiresAt: string | null | undefined): number {
if (!expiresAt) return 0;
@@ -38,6 +58,17 @@ function statusColor(status: string): 'success' | 'error' | 'warning' | 'auto' {
}
}
function formatBundle(bundle: LicenseBundleResponse): string {
return [
`# Cameleer License — ${bundle.label || bundle.tenantSlug}`,
`# Issued: ${new Date(bundle.issuedAt).toISOString().slice(0, 10)} | Expires: ${new Date(bundle.expiresAt).toISOString().slice(0, 10)}`,
'',
`CAMELEER_SERVER_TENANT_ID=${bundle.tenantSlug}`,
`CAMELEER_SERVER_LICENSE_PUBLICKEY=${bundle.publicKeyB64}`,
`CAMELEER_SERVER_LICENSE_TOKEN=${bundle.token}`,
].join('\n');
}
export function TenantDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
@@ -68,11 +99,75 @@ export function TenantDetailPage() {
const suspendTenant = useSuspendTenant();
const activateTenant = useActivateTenant();
const deleteTenant = useDeleteTenant();
const renewLicense = useRenewLicense();
const mintLicense = useMintLicense(id ?? '');
const restartServer = useRestartServer();
const upgradeServer = useUpgradeServer();
const { data: presets } = useLicensePresets();
const [deleteOpen, setDeleteOpen] = useState(false);
const [mintOpen, setMintOpen] = useState(false);
const [selectedTier, setSelectedTier] = useState('');
const [limits, setLimits] = useState<Record<string, number>>({});
const [expiryDays, setExpiryDays] = useState(365);
const [graceDays, setGraceDays] = useState(14);
const [label, setLabel] = useState('');
const [lastBundle, setLastBundle] = useState<LicenseBundleResponse | null>(null);
// Initialize form when presets load or tenant data changes
useEffect(() => {
if (data?.tenant && presets && !selectedTier) {
const tier = data.tenant.tier;
setSelectedTier(tier);
const preset = presets.find(p => p.tier === tier);
if (preset) setLimits({ ...preset.limits });
setLabel(`${data.tenant.name} (${tier})`);
}
}, [data, presets, selectedTier]);
function handleTierChange(tier: string) {
setSelectedTier(tier);
const preset = presets?.find(p => p.tier === tier);
if (preset) setLimits({ ...preset.limits });
if (data?.tenant) setLabel(`${data.tenant.name} (${tier})`);
}
function isCustom(): boolean {
if (!presets) return false;
const preset = presets.find(p => p.tier === selectedTier);
if (!preset) return true;
return LIMIT_KEYS.some(k => (limits[k] ?? 0) !== (preset.limits[k] ?? 0));
}
async function handleMint(pushToServer: boolean) {
const req: MintLicenseRequest = {
tier: selectedTier,
limits,
expiresAt: new Date(Date.now() + expiryDays * 86_400_000).toISOString(),
gracePeriodDays: graceDays,
label,
pushToServer,
};
try {
const result = await mintLicense.mutateAsync(req);
setLastBundle(result);
toast({
title: pushToServer && result.pushedToServer ? 'License minted and pushed to server' : 'License minted',
variant: 'success',
});
} catch (err) {
toast({ title: 'Minting failed', description: String(err), variant: 'error' });
}
}
async function handleCopyBundle() {
if (!lastBundle) return;
try {
await navigator.clipboard.writeText(formatBundle(lastBundle));
toast({ title: 'License bundle copied to clipboard', variant: 'success' });
} catch {
toast({ title: 'Copy failed', variant: 'error' });
}
}
if (isLoading) {
return (
@@ -140,16 +235,6 @@ export function TenantDetailPage() {
}
}
async function handleRenewLicense() {
if (!id) return;
try {
await renewLicense.mutateAsync(id);
toast({ title: 'License renewed', variant: 'success' });
} catch (err) {
toast({ title: 'Renewal failed', description: String(err), variant: 'error' });
}
}
return (
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 20 }}>
{/* Header */}
@@ -209,6 +294,12 @@ export function TenantDetailPage() {
<span className={styles.kvLabel}>Tier</span>
<Badge label={license.tier} color={tierColor(license.tier)} />
</div>
{license.label && (
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Label</span>
<span className={styles.kvValue}>{license.label}</span>
</div>
)}
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Expires</span>
<span className={styles.kvValue}>
@@ -221,14 +312,17 @@ export function TenantDetailPage() {
{daysRemaining}d
</span>
</div>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Grace period</span>
<span className={styles.kvValue}>{license.gracePeriodDays}d</span>
</div>
<div style={{ paddingTop: 8 }}>
<Button
variant="secondary"
onClick={handleRenewLicense}
loading={renewLicense.isPending}
onClick={() => { setMintOpen(true); setLastBundle(null); }}
>
<RefreshCw size={14} style={{ marginRight: 6, verticalAlign: -2 }} />
Renew License
Mint New License
</Button>
</div>
</div>
@@ -237,10 +331,9 @@ export function TenantDetailPage() {
<p className={styles.description}>No license issued.</p>
<Button
variant="secondary"
onClick={handleRenewLicense}
loading={renewLicense.isPending}
onClick={() => { setMintOpen(true); setLastBundle(null); }}
>
Issue License
Mint License
</Button>
</div>
)}
@@ -305,6 +398,134 @@ export function TenantDetailPage() {
</Card>
</div>
{/* License Minting Form — full width */}
{mintOpen && (
<Card title={lastBundle ? 'License Minted' : 'Mint License'}>
{lastBundle ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Check size={16} style={{ color: 'var(--success)' }} />
<span style={{ fontWeight: 500 }}>
License minted successfully
{lastBundle.pushedToServer && ' and pushed to server'}
</span>
</div>
<div className={styles.tokenBlock}>
<code className={styles.tokenCode} style={{ whiteSpace: 'pre', fontSize: 12 }}>
{formatBundle(lastBundle)}
</code>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<Button variant="secondary" onClick={handleCopyBundle}>
<Clipboard size={14} style={{ marginRight: 6, verticalAlign: -2 }} />
Copy Bundle
</Button>
{!lastBundle.pushedToServer && (
<Button variant="secondary" onClick={() => handleMint(true)} loading={mintLicense.isPending}>
<Send size={14} style={{ marginRight: 6, verticalAlign: -2 }} />
Push to Server
</Button>
)}
<Button variant="ghost" onClick={() => setMintOpen(false)}>Close</Button>
</div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Tier preset + label */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
<div>
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--text-muted)', display: 'block', marginBottom: 4 }}>
Tier Preset {isCustom() && <Badge label="Custom" color="warning" />}
</label>
<select
value={selectedTier}
onChange={(e) => handleTierChange(e.target.value)}
style={{
width: '100%', padding: '8px 12px', border: '1px solid var(--border)',
borderRadius: 6, background: 'var(--bg-surface)', color: 'var(--text-primary)', fontSize: '0.875rem',
}}
>
{presets?.map(p => <option key={p.tier} value={p.tier}>{p.tier}</option>)}
</select>
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--text-muted)', display: 'block', marginBottom: 4 }}>
Validity (days)
</label>
<input
type="number" min={1} max={3650} value={expiryDays}
onChange={(e) => setExpiryDays(Number(e.target.value))}
style={{
width: '100%', padding: '8px 12px', border: '1px solid var(--border)',
borderRadius: 6, background: 'var(--bg-surface)', color: 'var(--text-primary)', fontSize: '0.875rem',
}}
/>
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--text-muted)', display: 'block', marginBottom: 4 }}>
Grace Period (days)
</label>
<input
type="number" min={0} max={90} value={graceDays}
onChange={(e) => setGraceDays(Number(e.target.value))}
style={{
width: '100%', padding: '8px 12px', border: '1px solid var(--border)',
borderRadius: 6, background: 'var(--bg-surface)', color: 'var(--text-primary)', fontSize: '0.875rem',
}}
/>
</div>
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--text-muted)', display: 'block', marginBottom: 4 }}>Label</label>
<input
type="text" value={label} onChange={(e) => setLabel(e.target.value)}
style={{
width: '100%', padding: '8px 12px', border: '1px solid var(--border)',
borderRadius: 6, background: 'var(--bg-surface)', color: 'var(--text-primary)', fontSize: '0.875rem',
}}
/>
</div>
{/* Limits grid */}
<div>
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--text-muted)', display: 'block', marginBottom: 8 }}>Limits</label>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: 8 }}>
{LIMIT_KEYS.map(key => (
<div key={key} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 12, color: 'var(--text-muted)', minWidth: 160 }}>{LIMIT_LABELS[key]}</span>
<input
type="number" min={0}
value={limits[key] ?? 0}
onChange={(e) => setLimits(prev => ({ ...prev, [key]: Number(e.target.value) }))}
style={{
width: 80, padding: '4px 8px', border: '1px solid var(--border)',
borderRadius: 4, background: 'var(--bg-surface)', color: 'var(--text-primary)', fontSize: '0.8rem',
textAlign: 'right',
}}
/>
</div>
))}
</div>
</div>
{/* Actions */}
<div style={{ display: 'flex', gap: 8, paddingTop: 8 }}>
<Button variant="primary" onClick={() => handleMint(true)} loading={mintLicense.isPending}>
<Send size={14} style={{ marginRight: 6, verticalAlign: -2 }} />
Mint & Push to Server
</Button>
<Button variant="secondary" onClick={() => handleMint(false)} loading={mintLicense.isPending}>
<Clipboard size={14} style={{ marginRight: 6, verticalAlign: -2 }} />
Mint & Copy
</Button>
<Button variant="ghost" onClick={() => setMintOpen(false)}>Cancel</Button>
</div>
</div>
)}
</Card>
)}
{/* Delete confirmation dialog */}
<AlertDialog
open={deleteOpen}