feat(ui): add license minting form, verify tool, and update all pages
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:
187
ui/src/pages/vendor/LicenseVerifyPage.tsx
vendored
Normal file
187
ui/src/pages/vendor/LicenseVerifyPage.tsx
vendored
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user