From ffb7ef08394c924cae3cefa6c1112e97172d52b5 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 26 Apr 2026 17:41:40 +0200 Subject: [PATCH] feat(ui): add license minting form, verify tool, and update all pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ui/src/api/vendor-hooks.ts | 30 ++- ui/src/components/Layout.tsx | 11 +- ui/src/pages/tenant/TenantDashboardPage.tsx | 4 +- ui/src/pages/tenant/TenantLicensePage.tsx | 66 ++--- ui/src/pages/vendor/CreateTenantPage.tsx | 4 +- ui/src/pages/vendor/LicenseVerifyPage.tsx | 187 ++++++++++++++ ui/src/pages/vendor/TenantDetailPage.tsx | 259 ++++++++++++++++++-- ui/src/router.tsx | 6 + ui/src/types/api.ts | 43 +++- ui/src/utils/tier.ts | 3 +- ui/tsconfig.tsbuildinfo | 2 +- 11 files changed, 552 insertions(+), 63 deletions(-) create mode 100644 ui/src/pages/vendor/LicenseVerifyPage.tsx diff --git a/ui/src/api/vendor-hooks.ts b/ui/src/api/vendor-hooks.ts index 6eefdad..09c5042 100644 --- a/ui/src/api/vendor-hooks.ts +++ b/ui/src/api/vendor-hooks.ts @@ -1,6 +1,6 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { api } from './client'; -import type { VendorTenantSummary, VendorTenantDetail, CreateTenantRequest, TenantResponse, LicenseResponse, AuditLogPage, AuditLogFilters, TenantMetricsEntry } from '../types/api'; +import type { VendorTenantSummary, VendorTenantDetail, CreateTenantRequest, TenantResponse, LicenseBundleResponse, MintLicenseRequest, LicensePreset, VerifyLicenseResponse, AuditLogPage, AuditLogFilters, TenantMetricsEntry } from '../types/api'; export function useVendorTenants() { return useQuery({ @@ -70,11 +70,31 @@ export function useUpgradeServer() { }); } -export function useRenewLicense() { +export function useMintLicense(tenantId: string) { const qc = useQueryClient(); - return useMutation({ - mutationFn: (tenantId) => api.post(`/vendor/tenants/${tenantId}/license`), - onSuccess: (_, tenantId) => qc.invalidateQueries({ queryKey: ['vendor', 'tenants', tenantId] }), + return useMutation({ + mutationFn: (req) => api.post(`/vendor/tenants/${tenantId}/license`, req), + onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'tenants', tenantId] }), + }); +} + +export function useLicensePresets() { + return useQuery({ + queryKey: ['vendor', 'license-presets'], + queryFn: () => api.get('/vendor/license-presets'), + }); +} + +export function useVerifyLicense() { + return useMutation({ + mutationFn: (token) => api.post('/vendor/license/verify', { token }), + }); +} + +export function usePublicKey() { + return useQuery<{ publicKey: string }>({ + queryKey: ['vendor', 'signing-key'], + queryFn: () => api.get('/vendor/signing-key/public'), }); } diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 428e92f..44156f9 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -4,7 +4,7 @@ import { Sidebar, TopBar, } from '@cameleer/design-system'; -import { LayoutDashboard, ShieldCheck, Users, Settings, Shield, Building, ScrollText, Mail, BarChart3, Server, ExternalLink } from 'lucide-react'; +import { LayoutDashboard, ShieldCheck, Users, Settings, Shield, Building, ScrollText, Mail, BarChart3, Server, ExternalLink, Key } from 'lucide-react'; import { useQuery } from '@tanstack/react-query'; import { useAuth } from '../auth/useAuth'; import { useScopes } from '../auth/useScopes'; @@ -139,6 +139,15 @@ export function Layout() { Email Connector +
navigate('/vendor/license-tools')} + > + + License Tools +
window.open(`${window.location.protocol}//${window.location.hostname}:3002`, '_blank', 'noopener')} diff --git a/ui/src/pages/tenant/TenantDashboardPage.tsx b/ui/src/pages/tenant/TenantDashboardPage.tsx index 6fde51f..594609d 100644 --- a/ui/src/pages/tenant/TenantDashboardPage.tsx +++ b/ui/src/pages/tenant/TenantDashboardPage.tsx @@ -53,8 +53,8 @@ export function TenantDashboardPage() { const serverDown = !data.serverHealthy; const daysRemaining = data.licenseDaysRemaining ?? 0; - const agentLimit = data.limits?.['agents'] ?? -1; - const envLimit = data.limits?.['environments'] ?? -1; + const agentLimit = data.limits?.['max_agents'] ?? -1; + const envLimit = data.limits?.['max_environments'] ?? -1; const agentUsed = data.agentCount ?? 0; const envUsed = data.environmentCount ?? 0; diff --git a/ui/src/pages/tenant/TenantLicensePage.tsx b/ui/src/pages/tenant/TenantLicensePage.tsx index 030cf55..03dac71 100644 --- a/ui/src/pages/tenant/TenantLicensePage.tsx +++ b/ui/src/pages/tenant/TenantLicensePage.tsx @@ -9,10 +9,25 @@ import { } from '@cameleer/design-system'; import { Copy, Eye, EyeOff } from 'lucide-react'; import { useTenantLicense } from '../../api/tenant-hooks'; -import { UsageIndicator } from '../../components/UsageIndicator'; import { tierColor } from '../../utils/tier'; import styles from '../../styles/platform.module.css'; +const LIMIT_LABELS: Record = { + 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 daysColor(days: number): 'success' | 'warning' | 'error' | 'auto' { if (days <= 0) return 'error'; if (days <= 30) return 'warning'; @@ -51,10 +66,6 @@ export function TenantLicensePage() { } } - const agentLimit = data.limits?.['agents'] ?? -1; - const envLimit = data.limits?.['environments'] ?? -1; - const retentionDays = data.limits?.['retentionDays'] ?? -1; - return (
{/* Header */} @@ -82,37 +93,34 @@ export function TenantLicensePage() { color={daysColor(data.daysRemaining)} />
-
- - - {/* Features card */} - -
- {Object.entries(data.features).map(([key, enabled]) => ( -
- {key} - + {data.gracePeriodDays > 0 && ( +
+ Grace period + {data.gracePeriodDays}d +
+ )} + {data.label && ( +
+ Label + {data.label}
- ))} - {Object.keys(data.features).length === 0 && ( -

No feature flags configured.

)}
- {/* Limits & Usage card */} - + {/* Limits card */} +
- - - {retentionDays >= 0 && ( -
- Data retention - {retentionDays}d + {Object.entries(data.limits).map(([key, value]) => ( +
+ {LIMIT_LABELS[key] ?? key} + + {value.toLocaleString()} +
+ ))} + {Object.keys(data.limits).length === 0 && ( +

No limits configured.

)}
diff --git a/ui/src/pages/vendor/CreateTenantPage.tsx b/ui/src/pages/vendor/CreateTenantPage.tsx index 2c8bcac..350c027 100644 --- a/ui/src/pages/vendor/CreateTenantPage.tsx +++ b/ui/src/pages/vendor/CreateTenantPage.tsx @@ -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(''); diff --git a/ui/src/pages/vendor/LicenseVerifyPage.tsx b/ui/src/pages/vendor/LicenseVerifyPage.tsx new file mode 100644 index 0000000..4443824 --- /dev/null +++ b/ui/src/pages/vendor/LicenseVerifyPage.tsx @@ -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 = { + 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(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 ( +
+
+

License Tools

+
+ +
+ {/* Verify Token */} + +
+

+ Paste a signed license token to decode and validate its signature. +

+