feat: certificate management with stage/activate/restore lifecycle
Provider-based architecture (Docker now, K8s later): - CertificateManager interface + DockerCertificateManager (file-based) - Atomic swap via .wip files for safe cert replacement - Stage -> Activate -> Archive lifecycle with one-deep rollback - Bootstrap supports user-supplied certs via CERT_FILE/KEY_FILE/CA_FILE - CA bundle aggregates platform + tenant CAs, distributed to containers - Vendor UI: Certificates page with upload, activate, restore, discard - Stale tenant tracking (ca_applied_at) with restart banner - Conditional TLS skip removal when CA bundle exists Includes design spec, migration V012, service + controller tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
68
ui/src/api/certificate-hooks.ts
Normal file
68
ui/src/api/certificate-hooks.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from './client';
|
||||
|
||||
export interface CertificateResponse {
|
||||
id: string;
|
||||
status: string;
|
||||
subject: string;
|
||||
issuer: string;
|
||||
notBefore: string;
|
||||
notAfter: string;
|
||||
fingerprint: string;
|
||||
hasCa: boolean;
|
||||
selfSigned: boolean;
|
||||
activatedAt: string | null;
|
||||
archivedAt: string | null;
|
||||
}
|
||||
|
||||
export interface CertificateOverview {
|
||||
active: CertificateResponse | null;
|
||||
staged: CertificateResponse | null;
|
||||
archived: CertificateResponse | null;
|
||||
staleTenantCount: number;
|
||||
}
|
||||
|
||||
export interface StageResponse {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
certificate: CertificateResponse | null;
|
||||
}
|
||||
|
||||
export function useVendorCertificates() {
|
||||
return useQuery<CertificateOverview>({
|
||||
queryKey: ['vendor', 'certificates'],
|
||||
queryFn: () => api.get('/vendor/certificates'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useStageCertificate() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<StageResponse, Error, FormData>({
|
||||
mutationFn: (formData) => api.post('/vendor/certificates/stage', formData),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'certificates'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useActivateCertificate() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<void, Error, void>({
|
||||
mutationFn: () => api.post('/vendor/certificates/activate'),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'certificates'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRestoreCertificate() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<void, Error, void>({
|
||||
mutationFn: () => api.post('/vendor/certificates/restore'),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'certificates'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDiscardStaged() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<void, Error, void>({
|
||||
mutationFn: () => api.delete('/vendor/certificates/staged'),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'certificates'] }),
|
||||
});
|
||||
}
|
||||
@@ -83,6 +83,14 @@ export function Layout() {
|
||||
>
|
||||
Audit Log
|
||||
</div>
|
||||
<div
|
||||
style={{ padding: '6px 12px 6px 36px', fontSize: 13, cursor: 'pointer',
|
||||
fontWeight: isActive(location, '/vendor/certificates') ? 600 : 400,
|
||||
color: isActive(location, '/vendor/certificates') ? 'var(--amber)' : 'var(--text-muted)' }}
|
||||
onClick={() => navigate('/vendor/certificates')}
|
||||
>
|
||||
Certificates
|
||||
</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')}
|
||||
|
||||
285
ui/src/pages/vendor/CertificatesPage.tsx
vendored
Normal file
285
ui/src/pages/vendor/CertificatesPage.tsx
vendored
Normal file
@@ -0,0 +1,285 @@
|
||||
import { useRef } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Spinner,
|
||||
useToast,
|
||||
} from '@cameleer/design-system';
|
||||
import { Upload, ShieldCheck, RotateCcw, Trash2, AlertTriangle } from 'lucide-react';
|
||||
import {
|
||||
useVendorCertificates,
|
||||
useStageCertificate,
|
||||
useActivateCertificate,
|
||||
useRestoreCertificate,
|
||||
useDiscardStaged,
|
||||
} from '../../api/certificate-hooks';
|
||||
import type { CertificateResponse } from '../../api/certificate-hooks';
|
||||
import styles from '../../styles/platform.module.css';
|
||||
|
||||
function formatDate(iso: string | null | undefined): string {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function daysUntil(iso: string | null | undefined): number {
|
||||
if (!iso) return 0;
|
||||
return Math.ceil((new Date(iso).getTime() - Date.now()) / 86_400_000);
|
||||
}
|
||||
|
||||
function expiryColor(iso: string | null | undefined): string | undefined {
|
||||
const days = daysUntil(iso);
|
||||
if (days <= 0) return 'var(--error)';
|
||||
if (days <= 30) return 'var(--warning)';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function CertCard({
|
||||
cert,
|
||||
title,
|
||||
actions,
|
||||
}: {
|
||||
cert: CertificateResponse;
|
||||
title: string;
|
||||
actions?: React.ReactNode;
|
||||
}) {
|
||||
const days = daysUntil(cert.notAfter);
|
||||
return (
|
||||
<Card title={title}>
|
||||
<div className={styles.dividerList}>
|
||||
<div className={styles.kvRow}>
|
||||
<span className={styles.kvLabel}>Subject</span>
|
||||
<span className={styles.kvValue}>{cert.subject}</span>
|
||||
</div>
|
||||
<div className={styles.kvRow}>
|
||||
<span className={styles.kvLabel}>Issuer</span>
|
||||
<span className={styles.kvValue}>{cert.issuer}</span>
|
||||
</div>
|
||||
<div className={styles.kvRow}>
|
||||
<span className={styles.kvLabel}>Valid from</span>
|
||||
<span className={styles.kvValue}>{formatDate(cert.notBefore)}</span>
|
||||
</div>
|
||||
<div className={styles.kvRow}>
|
||||
<span className={styles.kvLabel}>Expires</span>
|
||||
<span className={styles.kvValue} style={{ color: expiryColor(cert.notAfter) }}>
|
||||
{formatDate(cert.notAfter)} ({days}d)
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.kvRow}>
|
||||
<span className={styles.kvLabel}>Fingerprint</span>
|
||||
<span className={styles.kvValueMono} style={{ fontSize: '0.7rem', maxWidth: 220, textAlign: 'right' }}>
|
||||
{cert.fingerprint}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.kvRow}>
|
||||
<span className={styles.kvLabel}>CA bundle</span>
|
||||
<span className={styles.kvValue}>{cert.hasCa ? 'Yes' : 'No'}</span>
|
||||
</div>
|
||||
{cert.selfSigned && (
|
||||
<div className={styles.kvRow}>
|
||||
<span className={styles.kvLabel}>Type</span>
|
||||
<Badge label="Self-signed" color="warning" />
|
||||
</div>
|
||||
)}
|
||||
{cert.activatedAt && (
|
||||
<div className={styles.kvRow}>
|
||||
<span className={styles.kvLabel}>Activated</span>
|
||||
<span className={styles.kvValue}>{formatDate(cert.activatedAt)}</span>
|
||||
</div>
|
||||
)}
|
||||
{actions && <div style={{ paddingTop: 8, display: 'flex', gap: 8 }}>{actions}</div>}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function CertificatesPage() {
|
||||
const { toast } = useToast();
|
||||
const { data, isLoading, isError } = useVendorCertificates();
|
||||
const stageMutation = useStageCertificate();
|
||||
const activateMutation = useActivateCertificate();
|
||||
const restoreMutation = useRestoreCertificate();
|
||||
const discardMutation = useDiscardStaged();
|
||||
|
||||
const certInputRef = useRef<HTMLInputElement>(null);
|
||||
const keyInputRef = useRef<HTMLInputElement>(null);
|
||||
const caInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 64 }}>
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Alert variant="error" title="Failed to load certificates">
|
||||
Could not fetch certificate data. Please refresh.
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function handleUpload() {
|
||||
const certFile = certInputRef.current?.files?.[0];
|
||||
const keyFile = keyInputRef.current?.files?.[0];
|
||||
const caFile = caInputRef.current?.files?.[0];
|
||||
|
||||
if (!certFile || !keyFile) {
|
||||
toast({ title: 'Certificate and key files are required', variant: 'error' });
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('cert', certFile);
|
||||
formData.append('key', keyFile);
|
||||
if (caFile) formData.append('ca', caFile);
|
||||
|
||||
try {
|
||||
const result = await stageMutation.mutateAsync(formData);
|
||||
if (result.valid) {
|
||||
toast({ title: 'Certificate staged successfully', variant: 'success' });
|
||||
// Clear file inputs
|
||||
if (certInputRef.current) certInputRef.current.value = '';
|
||||
if (keyInputRef.current) keyInputRef.current.value = '';
|
||||
if (caInputRef.current) caInputRef.current.value = '';
|
||||
} else {
|
||||
toast({ title: 'Validation failed', description: result.errors.join(', '), variant: 'error' });
|
||||
}
|
||||
} catch (err) {
|
||||
toast({ title: 'Upload failed', description: String(err), variant: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleActivate() {
|
||||
try {
|
||||
await activateMutation.mutateAsync();
|
||||
toast({ title: 'Certificate activated', variant: 'success' });
|
||||
} catch (err) {
|
||||
toast({ title: 'Activation failed', description: String(err), variant: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRestore() {
|
||||
try {
|
||||
await restoreMutation.mutateAsync();
|
||||
toast({ title: 'Certificate restored from archive', variant: 'success' });
|
||||
} catch (err) {
|
||||
toast({ title: 'Restore failed', description: String(err), variant: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDiscard() {
|
||||
try {
|
||||
await discardMutation.mutateAsync();
|
||||
toast({ title: 'Staged certificate discarded', variant: 'success' });
|
||||
} catch (err) {
|
||||
toast({ title: 'Discard failed', description: String(err), variant: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
const expired = data.archived
|
||||
? daysUntil(data.archived.notAfter) <= 0
|
||||
: false;
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<h1 className={styles.heading}>Certificates</h1>
|
||||
|
||||
{data.staleTenantCount > 0 && (
|
||||
<Alert variant="warning" title="CA bundle updated">
|
||||
<AlertTriangle size={14} style={{ verticalAlign: 'middle', marginRight: 6 }} />
|
||||
{data.staleTenantCount} tenant{data.staleTenantCount > 1 ? 's' : ''} need a restart
|
||||
to pick up the updated CA bundle.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 16 }}>
|
||||
{data.active && (
|
||||
<CertCard cert={data.active} title="Active Certificate" />
|
||||
)}
|
||||
|
||||
{data.staged && (
|
||||
<CertCard
|
||||
cert={data.staged}
|
||||
title="Staged Certificate"
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleActivate}
|
||||
loading={activateMutation.isPending}
|
||||
>
|
||||
<ShieldCheck size={14} style={{ marginRight: 6 }} />
|
||||
Activate
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleDiscard}
|
||||
loading={discardMutation.isPending}
|
||||
>
|
||||
<Trash2 size={14} style={{ marginRight: 6 }} />
|
||||
Discard
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{data.archived && (
|
||||
<CertCard
|
||||
cert={data.archived}
|
||||
title="Previous Certificate"
|
||||
actions={
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleRestore}
|
||||
loading={restoreMutation.isPending}
|
||||
disabled={expired}
|
||||
>
|
||||
<RotateCcw size={14} style={{ marginRight: 6 }} />
|
||||
{expired ? 'Expired' : 'Restore'}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Upload card */}
|
||||
<Card title="Upload Certificate">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 13, color: 'var(--text-secondary)', display: 'block', marginBottom: 4 }}>
|
||||
Certificate (PEM) *
|
||||
</label>
|
||||
<input ref={certInputRef} type="file" accept=".pem,.crt,.cer" />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 13, color: 'var(--text-secondary)', display: 'block', marginBottom: 4 }}>
|
||||
Private Key (PEM) *
|
||||
</label>
|
||||
<input ref={keyInputRef} type="file" accept=".pem,.key" />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 13, color: 'var(--text-secondary)', display: 'block', marginBottom: 4 }}>
|
||||
CA Bundle (PEM, optional)
|
||||
</label>
|
||||
<input ref={caInputRef} type="file" accept=".pem,.crt,.cer" />
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleUpload}
|
||||
loading={stageMutation.isPending}
|
||||
>
|
||||
<Upload size={14} style={{ marginRight: 6 }} />
|
||||
Stage Certificate
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { VendorTenantsPage } from './pages/vendor/VendorTenantsPage';
|
||||
import { CreateTenantPage } from './pages/vendor/CreateTenantPage';
|
||||
import { TenantDetailPage } from './pages/vendor/TenantDetailPage';
|
||||
import { VendorAuditPage } from './pages/vendor/VendorAuditPage';
|
||||
import { CertificatesPage } from './pages/vendor/CertificatesPage';
|
||||
import { TenantDashboardPage } from './pages/tenant/TenantDashboardPage';
|
||||
import { TenantLicensePage } from './pages/tenant/TenantLicensePage';
|
||||
import { SsoPage } from './pages/tenant/SsoPage';
|
||||
@@ -73,6 +74,11 @@ export function AppRouter() {
|
||||
<VendorAuditPage />
|
||||
</RequireScope>
|
||||
} />
|
||||
<Route path="/vendor/certificates" element={
|
||||
<RequireScope scope="platform:admin" fallback={<Navigate to="/tenant" replace />}>
|
||||
<CertificatesPage />
|
||||
</RequireScope>
|
||||
} />
|
||||
|
||||
{/* Tenant portal */}
|
||||
<Route path="/tenant" element={<TenantDashboardPage />} />
|
||||
|
||||
Reference in New Issue
Block a user