Files
cameleer-saas/ui/src/pages/vendor/CertificatesPage.tsx
hsiegeln dee1f39554
All checks were successful
CI / build (push) Successful in 2m8s
CI / docker (push) Successful in 1m41s
fix: align button icons and polish vendor sidebar
Fix vertical alignment of Lucide icons inside Button children across
all pages by adding verticalAlign offsets (-3px for 16px icons, -2px
for 14px icons). The design system Button wraps children in an inline
span, so SVG icons defaulted to baseline alignment.

Hide the redundant top-right "Create Tenant" button on VendorTenantsPage
when no tenants exist — the EmptyState already provides that action.

Add icons to all vendor sidebar sub-items for consistency (previously
only Email Connector had one).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 21:30:37 +02:00

296 lines
9.6 KiB
TypeScript

import { useRef, useState } from 'react';
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: 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">
{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>
);
}