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:
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
143
frontend/src/components/dialogs/CertificateDetailDialog.tsx
Normal file
143
frontend/src/components/dialogs/CertificateDetailDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
187
frontend/src/components/dialogs/CertificateExportDialog.tsx
Normal file
187
frontend/src/components/dialogs/CertificateExportDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
205
frontend/src/components/dialogs/CertificateUploadDialog.tsx
Normal file
205
frontend/src/components/dialogs/CertificateUploadDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user