Introduces ApiError class in client.ts that parses Spring Boot error bodies to extract human-readable messages (message, error, detail fields). Adds errorMessage() helper used by all toast descriptions instead of raw String(err) which dumped JSON blobs to the user. Affected: all 10 page components that display error toasts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
297 lines
9.7 KiB
TypeScript
297 lines
9.7 KiB
TypeScript
import { useRef, useState } from 'react';
|
|
import { errorMessage } from '../../api/client';
|
|
import {
|
|
Alert,
|
|
Badge,
|
|
Button,
|
|
Card,
|
|
FileInput,
|
|
FormField,
|
|
Input,
|
|
Spinner,
|
|
useToast,
|
|
} from '@cameleer/design-system';
|
|
import type { FileInputHandle } from '@cameleer/design-system';
|
|
import { Upload, ShieldCheck, RotateCcw, Trash2, FileKey, KeyRound, ShieldPlus } 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 shortenFingerprint(fp: string | null | undefined): string {
|
|
if (!fp) return '—';
|
|
// Show first 23 chars (8 hex pairs) + ellipsis
|
|
return fp.length > 23 ? fp.slice(0, 23) + '...' : fp;
|
|
}
|
|
|
|
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} title={cert.fingerprint} style={{ fontSize: '0.7rem' }}>
|
|
{shortenFingerprint(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 certRef = useRef<FileInputHandle>(null);
|
|
const keyRef = useRef<FileInputHandle>(null);
|
|
const caRef = useRef<FileInputHandle>(null);
|
|
const [keyPassword, setKeyPassword] = useState('');
|
|
|
|
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 = certRef.current?.file;
|
|
const keyFile = keyRef.current?.file;
|
|
const caFile = caRef.current?.file;
|
|
|
|
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);
|
|
if (keyPassword) formData.append('password', keyPassword);
|
|
|
|
try {
|
|
const result = await stageMutation.mutateAsync(formData);
|
|
if (result.valid) {
|
|
toast({ title: 'Certificate staged successfully', variant: 'success' });
|
|
certRef.current?.clear();
|
|
keyRef.current?.clear();
|
|
caRef.current?.clear();
|
|
setKeyPassword('');
|
|
} else {
|
|
toast({ title: 'Validation failed', description: result.errors.join(', '), variant: 'error' });
|
|
}
|
|
} catch (err) {
|
|
toast({ title: 'Upload failed', description: errorMessage(err), variant: 'error' });
|
|
}
|
|
}
|
|
|
|
async function handleActivate() {
|
|
try {
|
|
await activateMutation.mutateAsync();
|
|
toast({ title: 'Certificate activated', variant: 'success' });
|
|
} catch (err) {
|
|
toast({ title: 'Activation failed', description: errorMessage(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: errorMessage(err), variant: 'error' });
|
|
}
|
|
}
|
|
|
|
async function handleDiscard() {
|
|
try {
|
|
await discardMutation.mutateAsync();
|
|
toast({ title: 'Staged certificate discarded', variant: 'success' });
|
|
} catch (err) {
|
|
toast({ title: 'Discard failed', description: errorMessage(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">
|
|
{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(340px, 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, verticalAlign: -2 }} />
|
|
Activate
|
|
</Button>
|
|
<Button
|
|
variant="secondary"
|
|
onClick={handleDiscard}
|
|
loading={discardMutation.isPending}
|
|
>
|
|
<Trash2 size={14} style={{ marginRight: 6, verticalAlign: -2 }} />
|
|
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, verticalAlign: -2 }} />
|
|
{expired ? 'Expired' : 'Restore'}
|
|
</Button>
|
|
}
|
|
/>
|
|
)}
|
|
|
|
{/* Upload card */}
|
|
<Card title="Upload Certificate">
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
<FormField label="Certificate (PEM) *">
|
|
<FileInput ref={certRef} accept=".pem,.crt,.cer" icon={<FileKey size={16} />} />
|
|
</FormField>
|
|
<FormField label="Private Key (PEM) *">
|
|
<FileInput ref={keyRef} accept=".pem,.key" icon={<KeyRound size={16} />} />
|
|
</FormField>
|
|
<FormField label="Key Password (if encrypted)">
|
|
<Input
|
|
type="password"
|
|
placeholder="Leave empty if key is not encrypted"
|
|
value={keyPassword}
|
|
onChange={(e) => setKeyPassword(e.target.value)}
|
|
/>
|
|
</FormField>
|
|
<FormField label="CA Bundle (PEM, optional)">
|
|
<FileInput ref={caRef} accept=".pem,.crt,.cer" icon={<ShieldPlus size={16} />} />
|
|
</FormField>
|
|
<Button
|
|
variant="primary"
|
|
onClick={handleUpload}
|
|
loading={stageMutation.isPending}
|
|
>
|
|
<Upload size={14} style={{ marginRight: 6, verticalAlign: -2 }} />
|
|
Stage Certificate
|
|
</Button>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|