feat: add certificate export and upload dialogs

- Implemented CertificateExportDialog for exporting certificates in various formats (PEM, PFX, DER) with options to include private keys and set passwords.
- Created CertificateUploadDialog for uploading certificates, including validation and support for multiple file types (certificates, private keys, chain files).
- Updated DeleteCertificateDialog to use 'domains' instead of 'domain' for consistency.
- Refactored BulkDeleteCertificateDialog and DeleteCertificateDialog tests to accommodate changes in certificate structure.
- Added FileDropZone component for improved file upload experience.
- Enhanced translation files with new keys for certificate management features.
- Updated Certificates page to utilize the new CertificateUploadDialog and clean up the upload logic.
- Adjusted Dashboard and ProxyHosts pages to reflect changes in certificate data structure.
This commit is contained in:
GitHub Actions
2026-04-11 23:32:22 +00:00
parent e49ea7061a
commit 30c9d735aa
26 changed files with 1428 additions and 531 deletions

View File

@@ -64,10 +64,10 @@ export default function BulkDeleteCertificateDialog({
>
{certificates.map((cert) => (
<li
key={cert.id ?? cert.domain}
key={cert.uuid}
className="flex items-center justify-between px-4 py-2"
>
<span className="text-sm text-white">{cert.name || cert.domain}</span>
<span className="text-sm text-white">{cert.name || cert.domains}</span>
<span className="text-xs text-gray-500">{providerLabel(cert, t)}</span>
</li>
))}

View File

@@ -3,7 +3,7 @@ import { AlertTriangle } from 'lucide-react'
interface CertificateCleanupDialogProps {
onConfirm: (deleteCerts: boolean) => void
onCancel: () => void
certificates: Array<{ id: number; name: string; domain: string }>
certificates: Array<{ uuid: string; name: string; domain: string }>
hostNames: string[]
isBulk?: boolean
}
@@ -82,7 +82,7 @@ export default function CertificateCleanupDialog({
</p>
<ul className="mt-2 space-y-1">
{certificates.map((cert) => (
<li key={cert.id} className="text-xs text-gray-300 flex items-center gap-2">
<li key={cert.uuid} className="text-xs text-gray-300 flex items-center gap-2">
<span className="text-orange-400"></span>
<span className="font-medium">{cert.name || cert.domain}</span>
<span className="text-gray-500">({cert.domain})</span>

View File

@@ -0,0 +1,143 @@
import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { Certificate } from '../../api/certificates'
import { useCertificateDetail } from '../../hooks/useCertificates'
import CertificateChainViewer from '../CertificateChainViewer'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '../ui'
interface CertificateDetailDialogProps {
certificate: Certificate | null
open: boolean
onOpenChange: (open: boolean) => void
}
export default function CertificateDetailDialog({
certificate,
open,
onOpenChange,
}: CertificateDetailDialogProps) {
const { t } = useTranslation()
const { detail, isLoading } = useCertificateDetail(
open && certificate ? certificate.uuid : null,
)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
data-testid="certificate-detail-dialog"
className="max-w-lg max-h-[85vh] overflow-y-auto"
>
<DialogHeader>
<DialogTitle>{t('certificates.detailTitle')}</DialogTitle>
</DialogHeader>
{isLoading && (
<div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" aria-hidden="true" />
</div>
)}
{detail && (
<div className="space-y-6 py-2">
<section>
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">
<dt className="text-content-muted">{t('certificates.friendlyName')}</dt>
<dd className="text-content-primary">{detail.name || '-'}</dd>
<dt className="text-content-muted">{t('certificates.commonName')}</dt>
<dd className="text-content-primary">{detail.common_name || '-'}</dd>
<dt className="text-content-muted">{t('certificates.domains')}</dt>
<dd className="text-content-primary">{detail.domains || '-'}</dd>
<dt className="text-content-muted">{t('certificates.issuerOrg')}</dt>
<dd className="text-content-primary">{detail.issuer_org || detail.issuer || '-'}</dd>
<dt className="text-content-muted">{t('certificates.fingerprint')}</dt>
<dd className="text-content-primary font-mono text-xs break-all">
{detail.fingerprint || '-'}
</dd>
<dt className="text-content-muted">{t('certificates.serialNumber')}</dt>
<dd className="text-content-primary font-mono text-xs break-all">
{detail.serial_number || '-'}
</dd>
<dt className="text-content-muted">{t('certificates.keyType')}</dt>
<dd className="text-content-primary">{detail.key_type || '-'}</dd>
<dt className="text-content-muted">{t('certificates.status')}</dt>
<dd className="text-content-primary capitalize">{detail.status}</dd>
<dt className="text-content-muted">{t('certificates.provider')}</dt>
<dd className="text-content-primary">{detail.provider}</dd>
<dt className="text-content-muted">{t('certificates.notBefore')}</dt>
<dd className="text-content-primary">
{detail.not_before ? new Date(detail.not_before).toLocaleDateString() : '-'}
</dd>
<dt className="text-content-muted">{t('certificates.expiresAt')}</dt>
<dd className="text-content-primary">
{detail.expires_at ? new Date(detail.expires_at).toLocaleDateString() : '-'}
</dd>
<dt className="text-content-muted">{t('certificates.autoRenew')}</dt>
<dd className="text-content-primary">
{detail.auto_renew ? t('common.yes') : t('common.no')}
</dd>
<dt className="text-content-muted">{t('certificates.createdAt')}</dt>
<dd className="text-content-primary">
{detail.created_at ? new Date(detail.created_at).toLocaleDateString() : '-'}
</dd>
<dt className="text-content-muted">{t('certificates.updatedAt')}</dt>
<dd className="text-content-primary">
{detail.updated_at ? new Date(detail.updated_at).toLocaleDateString() : '-'}
</dd>
</dl>
</section>
<section>
<h3 className="text-sm font-medium text-content-primary mb-3">
{t('certificates.assignedHosts')}
</h3>
{detail.assigned_hosts?.length > 0 ? (
<ul className="space-y-1.5">
{detail.assigned_hosts.map((host) => (
<li
key={host.uuid}
className="flex items-center justify-between rounded-md border border-gray-700 bg-surface-muted/30 px-3 py-2 text-sm"
>
<span className="text-content-primary font-medium">{host.name}</span>
<span className="text-content-muted text-xs">{host.domain_names}</span>
</li>
))}
</ul>
) : (
<p className="text-sm text-content-muted italic">
{t('certificates.noAssignedHosts')}
</p>
)}
</section>
<section>
<h3 className="text-sm font-medium text-content-primary mb-3">
{t('certificates.certificateChain')}
</h3>
<CertificateChainViewer chain={detail.chain || []} />
</section>
</div>
)}
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,187 @@
import { Download } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { Certificate } from '../../api/certificates'
import { useExportCertificate } from '../../hooks/useCertificates'
import { toast } from '../../utils/toast'
import {
Button,
Input,
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
Label,
} from '../ui'
interface CertificateExportDialogProps {
certificate: Certificate | null
open: boolean
onOpenChange: (open: boolean) => void
}
const FORMAT_OPTIONS = [
{ value: 'pem', label: 'exportFormatPem' },
{ value: 'pfx', label: 'exportFormatPfx' },
{ value: 'der', label: 'exportFormatDer' },
] as const
export default function CertificateExportDialog({
certificate,
open,
onOpenChange,
}: CertificateExportDialogProps) {
const { t } = useTranslation()
const [format, setFormat] = useState('pem')
const [includeKey, setIncludeKey] = useState(false)
const [password, setPassword] = useState('')
const [pfxPassword, setPfxPassword] = useState('')
const exportMutation = useExportCertificate()
function resetForm() {
setFormat('pem')
setIncludeKey(false)
setPassword('')
setPfxPassword('')
}
function handleClose(nextOpen: boolean) {
if (!nextOpen) resetForm()
onOpenChange(nextOpen)
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!certificate) return
exportMutation.mutate(
{
uuid: certificate.uuid,
format,
includeKey,
password: includeKey ? password : undefined,
pfxPassword: format === 'pfx' ? pfxPassword : undefined,
},
{
onSuccess: (blob) => {
const ext = format === 'pfx' ? 'pfx' : format === 'der' ? 'der' : 'pem'
const filename = `${certificate.name || 'certificate'}.${ext}`
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
URL.revokeObjectURL(url)
a.remove()
toast.success(t('certificates.exportSuccess'))
handleClose(false)
},
onError: (error: Error) => {
toast.error(`${t('certificates.exportFailed')}: ${error.message}`)
},
},
)
}
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent data-testid="certificate-export-dialog" className="max-w-md">
<DialogHeader>
<DialogTitle>
<Download className="inline h-5 w-5 mr-2" aria-hidden="true" />
{t('certificates.exportTitle')}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 py-2">
<div>
<Label htmlFor="export-format">{t('certificates.exportFormat')}</Label>
<div className="flex gap-2 mt-1.5" role="radiogroup" aria-label={t('certificates.exportFormat')}>
{FORMAT_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
role="radio"
aria-checked={format === opt.value}
onClick={() => setFormat(opt.value)}
className={`px-3 py-1.5 text-sm rounded-md border transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 ${
format === opt.value
? 'border-brand-500 bg-brand-500/20 text-brand-400'
: 'border-gray-700 text-content-muted hover:border-gray-600'
}`}
>
{t(`certificates.${opt.label}`)}
</button>
))}
</div>
</div>
{certificate?.has_key && (
<div className="flex items-start gap-3">
<input
id="include-key"
type="checkbox"
checked={includeKey}
onChange={(e) => setIncludeKey(e.target.checked)}
className="mt-1 h-4 w-4 rounded border-gray-700 bg-surface-muted text-brand-500 focus:ring-brand-500"
/>
<div>
<Label htmlFor="include-key" className="cursor-pointer">
{t('certificates.includePrivateKey')}
</Label>
{includeKey && (
<p className="text-xs text-yellow-400 mt-1">
{t('certificates.includePrivateKeyWarning')}
</p>
)}
</div>
</div>
)}
{includeKey && (
<Input
id="export-password"
label={t('certificates.exportPassword')}
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
aria-required="true"
autoComplete="current-password"
/>
)}
{format === 'pfx' && (
<Input
id="pfx-password"
label={t('certificates.exportPfxPassword')}
type="password"
value={pfxPassword}
onChange={(e) => setPfxPassword(e.target.value)}
autoComplete="off"
/>
)}
<DialogFooter className="pt-4">
<Button type="button" variant="secondary" onClick={() => handleClose(false)}>
{t('common.cancel')}
</Button>
<Button
type="submit"
isLoading={exportMutation.isPending}
data-testid="export-certificate-submit"
>
<Download className="h-4 w-4 mr-2" aria-hidden="true" />
{t('certificates.exportButton')}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,205 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { ValidationResult } from '../../api/certificates'
import CertificateValidationPreview from '../CertificateValidationPreview'
import {
Button,
Input,
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '../ui'
import { FileDropZone } from '../ui/FileDropZone'
import { useUploadCertificate, useValidateCertificate } from '../../hooks/useCertificates'
import { toast } from '../../utils/toast'
interface CertificateUploadDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
function detectFormat(file: File | null): string | null {
if (!file) return null
const ext = file.name.toLowerCase().split('.').pop()
if (ext === 'pfx' || ext === 'p12') return 'PFX/PKCS#12'
if (ext === 'pem' || ext === 'crt' || ext === 'cer') return 'PEM'
if (ext === 'der') return 'DER'
if (ext === 'key') return 'KEY'
return null
}
export default function CertificateUploadDialog({
open,
onOpenChange,
}: CertificateUploadDialogProps) {
const { t } = useTranslation()
const [name, setName] = useState('')
const [certFile, setCertFile] = useState<File | null>(null)
const [keyFile, setKeyFile] = useState<File | null>(null)
const [chainFile, setChainFile] = useState<File | null>(null)
const [validationResult, setValidationResult] = useState<ValidationResult | null>(null)
const uploadMutation = useUploadCertificate()
const validateMutation = useValidateCertificate()
const certFormat = detectFormat(certFile)
const isPfx = certFormat === 'PFX/PKCS#12'
function resetForm() {
setName('')
setCertFile(null)
setKeyFile(null)
setChainFile(null)
setValidationResult(null)
}
function handleClose(nextOpen: boolean) {
if (!nextOpen) resetForm()
onOpenChange(nextOpen)
}
function handleValidate() {
if (!certFile) return
validateMutation.mutate(
{ certFile, keyFile: keyFile ?? undefined, chainFile: chainFile ?? undefined },
{
onSuccess: (result) => {
setValidationResult(result)
},
onError: (error: Error) => {
toast.error(error.message)
},
},
)
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!certFile) return
uploadMutation.mutate(
{
name,
certFile,
keyFile: keyFile ?? undefined,
chainFile: chainFile ?? undefined,
},
{
onSuccess: () => {
toast.success(t('certificates.uploadSuccess'))
handleClose(false)
},
onError: (error: Error) => {
toast.error(`${t('certificates.uploadFailed')}: ${error.message}`)
},
},
)
}
const canValidate = !!certFile && !validateMutation.isPending
const canSubmit = !!certFile && !!name.trim()
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent data-testid="certificate-upload-dialog" className="max-w-lg">
<DialogHeader>
<DialogTitle>{t('certificates.uploadCertificate')}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 py-2">
<Input
id="certificate-name"
label={t('certificates.friendlyName')}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. My Custom Cert"
required
aria-required="true"
/>
<FileDropZone
id="cert-file"
label={t('certificates.certificateFile')}
accept=".pem,.crt,.cer,.pfx,.p12,.der"
file={certFile}
onFileChange={(f) => {
setCertFile(f)
setValidationResult(null)
}}
required
formatBadge={certFormat}
/>
{isPfx && (
<p className="text-xs text-content-muted italic">
{t('certificates.pfxDetected')}
</p>
)}
{!isPfx && (
<>
<FileDropZone
id="key-file"
label={t('certificates.privateKeyFile')}
accept=".pem,.key"
file={keyFile}
onFileChange={(f) => {
setKeyFile(f)
setValidationResult(null)
}}
/>
<FileDropZone
id="chain-file"
label={t('certificates.chainFile')}
accept=".pem,.crt,.cer"
file={chainFile}
onFileChange={(f) => {
setChainFile(f)
setValidationResult(null)
}}
/>
</>
)}
{certFile && !validationResult && (
<Button
type="button"
variant="secondary"
onClick={handleValidate}
disabled={!canValidate}
isLoading={validateMutation.isPending}
data-testid="validate-certificate-btn"
>
{validateMutation.isPending
? t('certificates.validating')
: t('certificates.validate')}
</Button>
)}
{validationResult && (
<CertificateValidationPreview result={validationResult} />
)}
<DialogFooter className="pt-4">
<Button type="button" variant="secondary" onClick={() => handleClose(false)}>
{t('common.cancel')}
</Button>
<Button
type="submit"
disabled={!canSubmit}
isLoading={uploadMutation.isPending}
data-testid="upload-certificate-submit"
>
{t('certificates.uploadAndSave')}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -45,7 +45,7 @@ export default function DeleteCertificateDialog({
<DialogHeader>
<DialogTitle>{t('certificates.deleteTitle')}</DialogTitle>
<DialogDescription>
{certificate.name || certificate.domain}
{certificate.name || certificate.domains}
</DialogDescription>
</DialogHeader>
@@ -59,7 +59,7 @@ export default function DeleteCertificateDialog({
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm">
<dt className="text-gray-500">{t('certificates.domain')}</dt>
<dd className="text-white">{certificate.domain}</dd>
<dd className="text-white">{certificate.domains}</dd>
<dt className="text-gray-500">{t('certificates.status')}</dt>
<dd className="text-white capitalize">{certificate.status}</dd>
<dt className="text-gray-500">{t('certificates.provider')}</dt>

View File

@@ -7,20 +7,22 @@ import BulkDeleteCertificateDialog from '../../dialogs/BulkDeleteCertificateDial
import type { Certificate } from '../../../api/certificates'
const makeCert = (overrides: Partial<Certificate>): Certificate => ({
id: 1,
uuid: 'cert-1',
name: 'Test Cert',
domain: 'test.example.com',
domains: 'test.example.com',
issuer: 'Custom CA',
expires_at: '2026-01-01T00:00:00Z',
status: 'valid',
provider: 'custom',
has_key: true,
in_use: false,
...overrides,
})
const certs: Certificate[] = [
makeCert({ id: 1, name: 'Cert One', domain: 'one.example.com' }),
makeCert({ id: 2, name: 'Cert Two', domain: 'two.example.com', provider: 'letsencrypt-staging', status: 'untrusted' }),
makeCert({ id: 3, name: 'Cert Three', domain: 'three.example.com', provider: 'letsencrypt', status: 'expired' }),
makeCert({ uuid: 'cert-1', name: 'Cert One', domains: 'one.example.com' }),
makeCert({ uuid: 'cert-2', name: 'Cert Two', domains: 'two.example.com', provider: 'letsencrypt-staging', status: 'untrusted' }),
makeCert({ uuid: 'cert-3', name: 'Cert Three', domains: 'three.example.com', provider: 'letsencrypt', status: 'expired' }),
]
describe('BulkDeleteCertificateDialog', () => {
@@ -121,7 +123,7 @@ describe('BulkDeleteCertificateDialog', () => {
})
it('renders "Expiring LE" label for a letsencrypt cert with status expiring', () => {
const expiringCert = makeCert({ id: 4, name: 'Expiring Cert', domain: 'expiring.example.com', provider: 'letsencrypt', status: 'expiring' })
const expiringCert = makeCert({ uuid: 'cert-4', name: 'Expiring Cert', domains: 'expiring.example.com', provider: 'letsencrypt', status: 'expiring' })
render(
<BulkDeleteCertificateDialog
certificates={[expiringCert]}

View File

@@ -14,13 +14,15 @@ vi.mock('react-i18next', () => ({
}))
const baseCert: Certificate = {
id: 1,
uuid: 'cert-1',
name: 'Test Cert',
domain: 'test.example.com',
domains: 'test.example.com',
issuer: 'Custom CA',
expires_at: '2026-01-01T00:00:00Z',
status: 'valid',
provider: 'custom',
has_key: true,
in_use: false,
}
describe('DeleteCertificateDialog', () => {