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:
@@ -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<VendorTenantSummary[]>({
|
||||
@@ -70,11 +70,31 @@ export function useUpgradeServer() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useRenewLicense() {
|
||||
export function useMintLicense(tenantId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<LicenseResponse, Error, string>({
|
||||
mutationFn: (tenantId) => api.post(`/vendor/tenants/${tenantId}/license`),
|
||||
onSuccess: (_, tenantId) => qc.invalidateQueries({ queryKey: ['vendor', 'tenants', tenantId] }),
|
||||
return useMutation<LicenseBundleResponse, Error, MintLicenseRequest>({
|
||||
mutationFn: (req) => api.post(`/vendor/tenants/${tenantId}/license`, req),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'tenants', tenantId] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useLicensePresets() {
|
||||
return useQuery<LicensePreset[]>({
|
||||
queryKey: ['vendor', 'license-presets'],
|
||||
queryFn: () => api.get('/vendor/license-presets'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useVerifyLicense() {
|
||||
return useMutation<VerifyLicenseResponse, Error, string>({
|
||||
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'),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
<Mail size={12} style={{ marginRight: 6, verticalAlign: -1 }} />
|
||||
Email Connector
|
||||
</div>
|
||||
<div
|
||||
style={{ padding: '6px 12px 6px 36px', fontSize: 13, cursor: 'pointer',
|
||||
fontWeight: isActive(location, '/vendor/license-tools') ? 600 : 400,
|
||||
color: isActive(location, '/vendor/license-tools') ? 'var(--amber)' : 'var(--text-muted)' }}
|
||||
onClick={() => navigate('/vendor/license-tools')}
|
||||
>
|
||||
<Key size={12} style={{ marginRight: 6, verticalAlign: -1 }} />
|
||||
License Tools
|
||||
</div>
|
||||
<div
|
||||
style={{ padding: '6px 12px 6px 36px', fontSize: 13, cursor: 'pointer', color: 'var(--text-muted)' }}
|
||||
onClick={() => window.open(`${window.location.protocol}//${window.location.hostname}:3002`, '_blank', 'noopener')}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<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 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 (
|
||||
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
{/* Header */}
|
||||
@@ -82,37 +93,34 @@ export function TenantLicensePage() {
|
||||
color={daysColor(data.daysRemaining)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Features card */}
|
||||
<Card title="Features">
|
||||
<div className={styles.dividerList}>
|
||||
{Object.entries(data.features).map(([key, enabled]) => (
|
||||
<div key={key} className={styles.kvRow}>
|
||||
<span className={styles.kvLabel}>{key}</span>
|
||||
<Badge
|
||||
label={enabled ? 'Enabled' : 'Disabled'}
|
||||
color={enabled ? 'success' : 'auto'}
|
||||
/>
|
||||
{data.gracePeriodDays > 0 && (
|
||||
<div className={styles.kvRow}>
|
||||
<span className={styles.kvLabel}>Grace period</span>
|
||||
<span className={styles.kvValue}>{data.gracePeriodDays}d</span>
|
||||
</div>
|
||||
)}
|
||||
{data.label && (
|
||||
<div className={styles.kvRow}>
|
||||
<span className={styles.kvLabel}>Label</span>
|
||||
<span className={styles.kvValue}>{data.label}</span>
|
||||
</div>
|
||||
))}
|
||||
{Object.keys(data.features).length === 0 && (
|
||||
<p className={styles.description}>No feature flags configured.</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Limits & Usage card */}
|
||||
<Card title="Limits & Usage">
|
||||
{/* Limits card */}
|
||||
<Card title="Limits">
|
||||
<div className={styles.dividerList}>
|
||||
<UsageIndicator used={0} limit={agentLimit} label="Agents" />
|
||||
<UsageIndicator used={0} limit={envLimit} label="Environments" />
|
||||
{retentionDays >= 0 && (
|
||||
<div className={styles.kvRow}>
|
||||
<span className={styles.kvLabel}>Data retention</span>
|
||||
<span className={styles.kvValue}>{retentionDays}d</span>
|
||||
{Object.entries(data.limits).map(([key, value]) => (
|
||||
<div key={key} className={styles.kvRow}>
|
||||
<span className={styles.kvLabel}>{LIMIT_LABELS[key] ?? key}</span>
|
||||
<span className={styles.kvValue} style={{ fontVariantNumeric: 'tabular-nums' }}>
|
||||
{value.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{Object.keys(data.limits).length === 0 && (
|
||||
<p className={styles.description}>No limits configured.</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
4
ui/src/pages/vendor/CreateTenantPage.tsx
vendored
4
ui/src/pages/vendor/CreateTenantPage.tsx
vendored
@@ -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('');
|
||||
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
259
ui/src/pages/vendor/TenantDetailPage.tsx
vendored
259
ui/src/pages/vendor/TenantDetailPage.tsx
vendored
@@ -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}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { CertificatesPage } from './pages/vendor/CertificatesPage';
|
||||
import { InfrastructurePage } from './pages/vendor/InfrastructurePage';
|
||||
import { VendorMetricsPage } from './pages/vendor/VendorMetricsPage';
|
||||
import { EmailConfigPage } from './pages/vendor/EmailConfigPage';
|
||||
import { LicenseVerifyPage } from './pages/vendor/LicenseVerifyPage';
|
||||
import { TenantDashboardPage } from './pages/tenant/TenantDashboardPage';
|
||||
import { TenantLicensePage } from './pages/tenant/TenantLicensePage';
|
||||
import { SsoPage } from './pages/tenant/SsoPage';
|
||||
@@ -108,6 +109,11 @@ export function AppRouter() {
|
||||
<EmailConfigPage />
|
||||
</RequireScope>
|
||||
} />
|
||||
<Route path="/vendor/license-tools" element={
|
||||
<RequireScope scope="platform:admin" fallback={<Navigate to="/tenant" replace />}>
|
||||
<LicenseVerifyPage />
|
||||
</RequireScope>
|
||||
} />
|
||||
|
||||
{/* Tenant portal */}
|
||||
<Route path="/tenant" element={<TenantDashboardPage />} />
|
||||
|
||||
@@ -14,13 +14,50 @@ export interface LicenseResponse {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
tier: string;
|
||||
features: Record<string, boolean>;
|
||||
label: string | null;
|
||||
limits: Record<string, number>;
|
||||
gracePeriodDays: number;
|
||||
issuedAt: string;
|
||||
expiresAt: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface LicenseBundleResponse extends LicenseResponse {
|
||||
tenantSlug: string;
|
||||
publicKeyB64: string;
|
||||
pushedToServer: boolean;
|
||||
}
|
||||
|
||||
export interface MintLicenseRequest {
|
||||
tier?: string;
|
||||
limits?: Record<string, number>;
|
||||
expiresAt?: string;
|
||||
gracePeriodDays?: number;
|
||||
label?: string;
|
||||
pushToServer: boolean;
|
||||
}
|
||||
|
||||
export interface LicensePreset {
|
||||
tier: string;
|
||||
limits: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface VerifyLicenseRequest {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface VerifyLicenseResponse {
|
||||
valid: boolean;
|
||||
state: string;
|
||||
tenantId: string | null;
|
||||
label: string | null;
|
||||
limits: Record<string, number> | null;
|
||||
issuedAt: string | null;
|
||||
expiresAt: string | null;
|
||||
gracePeriodDays: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface MeResponse {
|
||||
userId: string;
|
||||
tenants: Array<{
|
||||
@@ -74,7 +111,6 @@ export interface DashboardData {
|
||||
licenseTier: string | null;
|
||||
licenseDaysRemaining: number;
|
||||
limits: Record<string, number>;
|
||||
features: Record<string, boolean>;
|
||||
agentCount: number;
|
||||
environmentCount: number;
|
||||
}
|
||||
@@ -82,8 +118,9 @@ export interface DashboardData {
|
||||
export interface TenantLicenseData {
|
||||
id: string;
|
||||
tier: string;
|
||||
features: Record<string, boolean>;
|
||||
label: string | null;
|
||||
limits: Record<string, number>;
|
||||
gracePeriodDays: number;
|
||||
issuedAt: string;
|
||||
expiresAt: string;
|
||||
token: string;
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
export function tierColor(tier: string): 'primary' | 'success' | 'warning' | 'error' | 'running' | 'auto' {
|
||||
switch (tier?.toUpperCase()) {
|
||||
case 'ENTERPRISE': return 'success';
|
||||
case 'PROFESSIONAL': return 'primary';
|
||||
case 'BUSINESS': return 'primary';
|
||||
case 'TEAM': return 'running';
|
||||
case 'STARTER': return 'warning';
|
||||
default: return 'auto';
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/config.ts","./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/api/ca-hooks.ts","./src/api/certificate-hooks.ts","./src/api/client.ts","./src/api/hooks.ts","./src/api/tenant-hooks.ts","./src/api/vendor-hooks.ts","./src/auth/callbackpage.tsx","./src/auth/loginpage.tsx","./src/auth/orgresolver.tsx","./src/auth/protectedroute.tsx","./src/auth/useauth.ts","./src/auth/useorganization.ts","./src/auth/usescopes.ts","./src/components/auditlogtable.tsx","./src/components/layout.tsx","./src/components/requirescope.tsx","./src/components/serverstatusbadge.tsx","./src/components/usageindicator.tsx","./src/pages/tenant/settingspage.tsx","./src/pages/tenant/ssopage.tsx","./src/pages/tenant/teampage.tsx","./src/pages/tenant/tenantauditpage.tsx","./src/pages/tenant/tenantdashboardpage.tsx","./src/pages/tenant/tenantlicensepage.tsx","./src/pages/vendor/certificatespage.tsx","./src/pages/vendor/createtenantpage.tsx","./src/pages/vendor/infrastructurepage.tsx","./src/pages/vendor/tenantdetailpage.tsx","./src/pages/vendor/vendorauditpage.tsx","./src/pages/vendor/vendortenantspage.tsx","./src/types/api.ts","./src/utils/slug.ts","./src/utils/tier.ts"],"version":"5.9.3"}
|
||||
{"root":["./src/config.ts","./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/api/ca-hooks.ts","./src/api/certificate-hooks.ts","./src/api/client.ts","./src/api/email-connector-hooks.ts","./src/api/hooks.ts","./src/api/tenant-hooks.ts","./src/api/vendor-hooks.ts","./src/auth/callbackpage.tsx","./src/auth/loginpage.tsx","./src/auth/orgresolver.tsx","./src/auth/protectedroute.tsx","./src/auth/registerpage.tsx","./src/auth/useauth.ts","./src/auth/useorganization.ts","./src/auth/usescopes.ts","./src/components/auditlogtable.tsx","./src/components/layout.tsx","./src/components/requirescope.tsx","./src/components/serverstatusbadge.tsx","./src/components/usageindicator.tsx","./src/pages/onboardingpage.tsx","./src/pages/tenant/settingspage.tsx","./src/pages/tenant/ssopage.tsx","./src/pages/tenant/teampage.tsx","./src/pages/tenant/tenantauditpage.tsx","./src/pages/tenant/tenantdashboardpage.tsx","./src/pages/tenant/tenantlicensepage.tsx","./src/pages/vendor/certificatespage.tsx","./src/pages/vendor/createtenantpage.tsx","./src/pages/vendor/emailconfigpage.tsx","./src/pages/vendor/infrastructurepage.tsx","./src/pages/vendor/licenseverifypage.tsx","./src/pages/vendor/tenantdetailpage.tsx","./src/pages/vendor/vendorauditpage.tsx","./src/pages/vendor/vendormetricspage.tsx","./src/pages/vendor/vendortenantspage.tsx","./src/types/api.ts","./src/utils/slug.ts","./src/utils/tier.ts"],"version":"5.9.3"}
|
||||
Reference in New Issue
Block a user