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

@@ -17,12 +17,14 @@ describe('certificates API', () => {
});
const mockCert: Certificate = {
id: 1,
domain: 'example.com',
uuid: 'abc-123',
domains: 'example.com',
issuer: 'Let\'s Encrypt',
expires_at: '2023-01-01',
status: 'valid',
provider: 'letsencrypt',
has_key: true,
in_use: false,
};
it('getCertificates calls client.get', async () => {
@@ -47,7 +49,7 @@ describe('certificates API', () => {
it('deleteCertificate calls client.delete', async () => {
vi.mocked(client.delete).mockResolvedValue({ data: {} });
await deleteCertificate(1);
expect(client.delete).toHaveBeenCalledWith('/certificates/1');
await deleteCertificate('abc-123');
expect(client.delete).toHaveBeenCalledWith('/certificates/abc-123');
});
});

View File

@@ -1,53 +1,123 @@
import client from './client'
/** Represents an SSL/TLS certificate. */
export interface Certificate {
id?: number
uuid: string
name?: string
domain: string
common_name?: string
domains: string
issuer: string
issuer_org?: string
fingerprint?: string
serial_number?: string
key_type?: string
expires_at: string
not_before?: string
status: 'valid' | 'expiring' | 'expired' | 'untrusted'
provider: string
chain_depth?: number
has_key: boolean
in_use: boolean
/** @deprecated Use uuid instead */
id?: number
}
export interface AssignedHost {
uuid: string
name: string
domain_names: string
}
export interface ChainEntry {
subject: string
issuer: string
expires_at: string
}
export interface CertificateDetail extends Certificate {
assigned_hosts: AssignedHost[]
chain: ChainEntry[]
auto_renew: boolean
created_at: string
updated_at: string
}
export interface ValidationResult {
valid: boolean
common_name: string
domains: string[]
issuer_org: string
expires_at: string
key_match: boolean
chain_valid: boolean
chain_depth: number
warnings: string[]
errors: string[]
}
/**
* Fetches all SSL certificates.
* @returns Promise resolving to array of Certificate objects
* @throws {AxiosError} If the request fails
*/
export async function getCertificates(): Promise<Certificate[]> {
const response = await client.get<Certificate[]>('/certificates')
return response.data
}
/**
* Uploads a new SSL certificate with its private key.
* @param name - Display name for the certificate
* @param certFile - The certificate file (PEM format)
* @param keyFile - The private key file (PEM format)
* @returns Promise resolving to the created Certificate
* @throws {AxiosError} If upload fails or certificate is invalid
*/
export async function uploadCertificate(name: string, certFile: File, keyFile: File): Promise<Certificate> {
export async function getCertificateDetail(uuid: string): Promise<CertificateDetail> {
const response = await client.get<CertificateDetail>(`/certificates/${uuid}`)
return response.data
}
export async function uploadCertificate(
name: string,
certFile: File,
keyFile?: File,
chainFile?: File,
): Promise<Certificate> {
const formData = new FormData()
formData.append('name', name)
formData.append('certificate_file', certFile)
formData.append('key_file', keyFile)
if (keyFile) formData.append('key_file', keyFile)
if (chainFile) formData.append('chain_file', chainFile)
const response = await client.post<Certificate>('/certificates', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data
}
/**
* Deletes an SSL certificate.
* @param id - The ID of the certificate to delete
* @throws {AxiosError} If deletion fails or certificate not found
*/
export async function deleteCertificate(id: number): Promise<void> {
await client.delete(`/certificates/${id}`)
export async function updateCertificate(uuid: string, name: string): Promise<Certificate> {
const response = await client.put<Certificate>(`/certificates/${uuid}`, { name })
return response.data
}
export async function deleteCertificate(uuid: string): Promise<void> {
await client.delete(`/certificates/${uuid}`)
}
export async function exportCertificate(
uuid: string,
format: string,
includeKey: boolean,
password?: string,
pfxPassword?: string,
): Promise<Blob> {
const response = await client.post(
`/certificates/${uuid}/export`,
{ format, include_key: includeKey, password, pfx_password: pfxPassword },
{ responseType: 'blob' },
)
return response.data as Blob
}
export async function validateCertificate(
certFile: File,
keyFile?: File,
chainFile?: File,
): Promise<ValidationResult> {
const formData = new FormData()
formData.append('certificate_file', certFile)
if (keyFile) formData.append('key_file', keyFile)
if (chainFile) formData.append('chain_file', chainFile)
const response = await client.post<ValidationResult>('/certificates/validate', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data
}

View File

@@ -0,0 +1,72 @@
import { Link2, ShieldCheck } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { ChainEntry } from '../api/certificates'
interface CertificateChainViewerProps {
chain: ChainEntry[]
}
function getChainLabel(index: number, total: number, t: (key: string) => string): string {
if (index === 0) return t('certificates.chainLeaf')
if (index === total - 1 && total > 1) return t('certificates.chainRoot')
return t('certificates.chainIntermediate')
}
export default function CertificateChainViewer({ chain }: CertificateChainViewerProps) {
const { t } = useTranslation()
if (!chain || chain.length === 0) {
return (
<p className="text-sm text-content-muted italic">{t('certificates.noChainData')}</p>
)
}
return (
<div
className="space-y-0"
role="list"
aria-label={t('certificates.certificateChain')}
>
{chain.map((entry, index) => {
const label = getChainLabel(index, chain.length, t)
const isLast = index === chain.length - 1
return (
<div key={index} role="listitem">
<div className="flex items-start gap-3">
<div className="flex flex-col items-center">
<div className="flex h-8 w-8 items-center justify-center rounded-full border border-gray-700 bg-surface-muted">
{index === 0 ? (
<ShieldCheck className="h-4 w-4 text-brand-400" aria-hidden="true" />
) : (
<Link2 className="h-4 w-4 text-content-muted" aria-hidden="true" />
)}
</div>
{!isLast && (
<div className="w-px h-6 bg-gray-700" aria-hidden="true" />
)}
</div>
<div className="min-w-0 flex-1 pb-2">
<div className="flex items-center gap-2">
<span className="text-xs font-medium uppercase tracking-wide text-content-muted">
{label}
</span>
</div>
<p className="text-sm font-medium text-content-primary truncate" title={entry.subject}>
{entry.subject}
</p>
<p className="text-xs text-content-muted truncate" title={entry.issuer}>
{t('certificates.issuerOrg')}: {entry.issuer}
</p>
<p className="text-xs text-content-muted">
{t('certificates.expiresAt')}: {new Date(entry.expires_at).toLocaleDateString()}
</p>
</div>
</div>
</div>
)
})}
</div>
)
}

View File

@@ -1,32 +1,28 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Trash2, ChevronUp, ChevronDown } from 'lucide-react'
import { Download, Eye, Trash2, ChevronUp, ChevronDown } from 'lucide-react'
import { useState, useMemo, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import BulkDeleteCertificateDialog from './dialogs/BulkDeleteCertificateDialog'
import CertificateDetailDialog from './dialogs/CertificateDetailDialog'
import CertificateExportDialog from './dialogs/CertificateExportDialog'
import DeleteCertificateDialog from './dialogs/DeleteCertificateDialog'
import { LoadingSpinner, ConfigReloadOverlay } from './LoadingStates'
import { Button } from './ui/Button'
import { Checkbox } from './ui/Checkbox'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/Tooltip'
import { deleteCertificate, type Certificate } from '../api/certificates'
import { useCertificates } from '../hooks/useCertificates'
import { useProxyHosts } from '../hooks/useProxyHosts'
import { type Certificate } from '../api/certificates'
import { useCertificates, useDeleteCertificate, useBulkDeleteCertificates } from '../hooks/useCertificates'
import { toast } from '../utils/toast'
import type { ProxyHost } from '../api/proxyHosts'
type SortColumn = 'name' | 'expires'
type SortDirection = 'asc' | 'desc'
export function isInUse(cert: Certificate, hosts: ProxyHost[]): boolean {
if (!cert.id) return false
return hosts.some(h => (h.certificate_id ?? h.certificate?.id) === cert.id)
export function isInUse(cert: Certificate): boolean {
return cert.in_use
}
export function isDeletable(cert: Certificate, hosts: ProxyHost[]): boolean {
if (!cert.id) return false
if (isInUse(cert, hosts)) return false
export function isDeletable(cert: Certificate): boolean {
if (cert.in_use) return false
return (
cert.provider === 'custom' ||
cert.provider === 'letsencrypt-staging' ||
@@ -35,65 +31,48 @@ export function isDeletable(cert: Certificate, hosts: ProxyHost[]): boolean {
)
}
function daysUntilExpiry(expiresAt: string): number {
const now = new Date()
const expiry = new Date(expiresAt)
return Math.ceil((expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
}
export default function CertificateList() {
const { certificates, isLoading, error } = useCertificates()
const { hosts } = useProxyHosts()
const queryClient = useQueryClient()
const { t } = useTranslation()
const [sortColumn, setSortColumn] = useState<SortColumn>('name')
const [sortDirection, setSortDirection] = useState<SortDirection>('asc')
const [certToDelete, setCertToDelete] = useState<Certificate | null>(null)
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
const [certToView, setCertToView] = useState<Certificate | null>(null)
const [certToExport, setCertToExport] = useState<Certificate | null>(null)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [showBulkDeleteDialog, setShowBulkDeleteDialog] = useState(false)
const deleteMutation = useDeleteCertificate()
useEffect(() => {
setSelectedIds(prev => {
const validIds = new Set(certificates.map(c => c.id).filter((id): id is number => id != null))
const validIds = new Set(certificates.map(c => c.uuid).filter(Boolean))
const reconciled = new Set([...prev].filter(id => validIds.has(id)))
if (reconciled.size === prev.size) return prev
return reconciled
})
}, [certificates])
const deleteMutation = useMutation({
mutationFn: async (id: number) => {
await deleteCertificate(id)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['certificates'] })
queryClient.invalidateQueries({ queryKey: ['proxyHosts'] })
toast.success(t('certificates.deleteSuccess'))
setCertToDelete(null)
},
onError: (error: Error) => {
toast.error(`${t('certificates.deleteFailed')}: ${error.message}`)
setCertToDelete(null)
},
})
const handleDelete = (cert: Certificate) => {
deleteMutation.mutate(cert.uuid, {
onSuccess: () => {
toast.success(t('certificates.deleteSuccess'))
setCertToDelete(null)
},
onError: (error: Error) => {
toast.error(`${t('certificates.deleteFailed')}: ${error.message}`)
setCertToDelete(null)
},
})
}
const bulkDeleteMutation = useMutation({
mutationFn: async (ids: number[]) => {
const results = await Promise.allSettled(ids.map(id => deleteCertificate(id)))
const failed = results.filter(r => r.status === 'rejected').length
const succeeded = results.filter(r => r.status === 'fulfilled').length
return { succeeded, failed }
},
onSuccess: ({ succeeded, failed }) => {
queryClient.invalidateQueries({ queryKey: ['certificates'] })
queryClient.invalidateQueries({ queryKey: ['proxyHosts'] })
setSelectedIds(new Set())
setShowBulkDeleteDialog(false)
if (failed > 0) {
toast.error(t('certificates.bulkDeletePartial', { deleted: succeeded, failed }))
} else {
toast.success(t('certificates.bulkDeleteSuccess', { count: succeeded }))
}
},
onError: () => {
toast.error(t('certificates.bulkDeleteFailed'))
setShowBulkDeleteDialog(false)
},
})
const bulkDeleteMutation = useBulkDeleteCertificates()
const sortedCertificates = useMemo(() => {
return [...certificates].sort((a, b) => {
@@ -101,8 +80,8 @@ export default function CertificateList() {
switch (sortColumn) {
case 'name': {
const aName = (a.name || a.domain || '').toLowerCase()
const bName = (b.name || b.domain || '').toLowerCase()
const aName = (a.name || a.domains || '').toLowerCase()
const bName = (b.name || b.domains || '').toLowerCase()
comparison = aName.localeCompare(bName)
break
}
@@ -118,15 +97,15 @@ export default function CertificateList() {
})
}, [certificates, sortColumn, sortDirection])
const selectableCertIds = useMemo<Set<number>>(() => {
const ids = new Set<number>()
const selectableCertIds = useMemo<Set<string>>(() => {
const ids = new Set<string>()
for (const cert of sortedCertificates) {
if (isDeletable(cert, hosts) && cert.id) {
ids.add(cert.id)
if (isDeletable(cert) && cert.uuid) {
ids.add(cert.uuid)
}
}
return ids
}, [sortedCertificates, hosts])
}, [sortedCertificates])
const allSelectableSelected =
selectableCertIds.size > 0 && selectedIds.size === selectableCertIds.size
@@ -141,12 +120,12 @@ export default function CertificateList() {
}
}
const handleSelectRow = (id: number) => {
const handleSelectRow = (uuid: string) => {
const next = new Set(selectedIds)
if (next.has(id)) {
next.delete(id)
if (next.has(uuid)) {
next.delete(uuid)
} else {
next.add(id)
next.add(uuid)
}
setSelectedIds(next)
}
@@ -243,18 +222,19 @@ export default function CertificateList() {
</tr>
) : (
sortedCertificates.map((cert) => {
const inUse = isInUse(cert, hosts)
const deletable = isDeletable(cert, hosts)
const inUse = isInUse(cert)
const deletable = isDeletable(cert)
const isInUseDeletableCategory = inUse && (cert.provider === 'custom' || cert.provider === 'letsencrypt-staging' || cert.status === 'expired' || cert.status === 'expiring')
const days = daysUntilExpiry(cert.expires_at)
return (
<tr key={cert.id || cert.domain} className="hover:bg-gray-800/50 transition-colors">
<tr key={cert.uuid} className="hover:bg-gray-800/50 transition-colors">
{deletable && !inUse ? (
<td className="w-12 px-4 py-4">
<Checkbox
checked={selectedIds.has(cert.id!)}
onCheckedChange={() => handleSelectRow(cert.id!)}
aria-label={t('certificates.selectCert', { name: cert.name || cert.domain })}
checked={selectedIds.has(cert.uuid)}
onCheckedChange={() => handleSelectRow(cert.uuid)}
aria-label={t('certificates.selectCert', { name: cert.name || cert.domains })}
/>
</td>
) : isInUseDeletableCategory ? (
@@ -267,7 +247,7 @@ export default function CertificateList() {
checked={false}
disabled
aria-disabled="true"
aria-label={t('certificates.selectCert', { name: cert.name || cert.domain })}
aria-label={t('certificates.selectCert', { name: cert.name || cert.domains })}
/>
</span>
</TooltipTrigger>
@@ -279,7 +259,7 @@ export default function CertificateList() {
<td className="w-12 px-4 py-4" aria-hidden="true" />
)}
<td className="px-6 py-4 font-medium text-white">{cert.name || '-'}</td>
<td className="px-6 py-4 font-medium text-white">{cert.domain}</td>
<td className="px-6 py-4 font-medium text-white">{cert.domains}</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2">
<span>{cert.issuer}</span>
@@ -291,49 +271,80 @@ export default function CertificateList() {
</div>
</td>
<td className="px-6 py-4">
{new Date(cert.expires_at).toLocaleDateString()}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className={days <= 0 ? 'text-red-400' : days <= 30 ? 'text-yellow-400' : ''}>
{new Date(cert.expires_at).toLocaleDateString()}
</span>
</TooltipTrigger>
<TooltipContent>
{days > 0
? t('certificates.expiresInDays', { days })
: t('certificates.expiredAgo', { days: Math.abs(days) })}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</td>
<td className="px-6 py-4">
<StatusBadge status={cert.status} />
</td>
<td className="px-6 py-4">
{(() => {
if (cert.id && inUse && (cert.provider === 'custom' || cert.provider === 'letsencrypt-staging' || cert.status === 'expired')) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
aria-disabled="true"
aria-label={t('certificates.deleteTitle')}
className="text-red-400/40 cursor-not-allowed transition-colors"
onClick={(e) => e.preventDefault()}
>
<Trash2 className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent>
{t('certificates.deleteInUse')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
<div className="flex items-center gap-2">
<button
onClick={() => setCertToView(cert)}
className="text-gray-400 hover:text-white transition-colors"
aria-label={t('certificates.viewDetails')}
data-testid={`view-cert-${cert.uuid}`}
>
<Eye className="w-4 h-4" />
</button>
<button
onClick={() => setCertToExport(cert)}
className="text-gray-400 hover:text-white transition-colors"
aria-label={t('certificates.export')}
data-testid={`export-cert-${cert.uuid}`}
>
<Download className="w-4 h-4" />
</button>
{(() => {
if (inUse && (cert.provider === 'custom' || cert.provider === 'letsencrypt-staging' || cert.status === 'expired')) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
aria-disabled="true"
aria-label={t('certificates.deleteTitle')}
className="text-red-400/40 cursor-not-allowed transition-colors"
onClick={(e) => e.preventDefault()}
>
<Trash2 className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent>
{t('certificates.deleteInUse')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
if (deletable) {
return (
<button
onClick={() => setCertToDelete(cert)}
className="text-red-400 hover:text-red-300 transition-colors"
aria-label={t('certificates.deleteTitle')}
>
<Trash2 className="w-4 h-4" />
</button>
)
}
if (deletable) {
return (
<button
onClick={() => setCertToDelete(cert)}
className="text-red-400 hover:text-red-300 transition-colors"
aria-label={t('certificates.deleteTitle')}
>
<Trash2 className="w-4 h-4" />
</button>
)
}
return null
})()}
return null
})()}
</div>
</td>
</tr>
)
@@ -347,20 +358,44 @@ export default function CertificateList() {
certificate={certToDelete}
open={certToDelete !== null}
onConfirm={() => {
if (certToDelete?.id) {
deleteMutation.mutate(certToDelete.id)
if (certToDelete?.uuid) {
handleDelete(certToDelete)
}
}}
onCancel={() => setCertToDelete(null)}
isDeleting={deleteMutation.isPending}
/>
<BulkDeleteCertificateDialog
certificates={sortedCertificates.filter(c => c.id && selectedIds.has(c.id))}
certificates={sortedCertificates.filter(c => selectedIds.has(c.uuid))}
open={showBulkDeleteDialog}
onConfirm={() => bulkDeleteMutation.mutate(Array.from(selectedIds))}
onConfirm={() => bulkDeleteMutation.mutate(Array.from(selectedIds), {
onSuccess: ({ succeeded, failed }) => {
setSelectedIds(new Set())
setShowBulkDeleteDialog(false)
if (failed > 0) {
toast.error(t('certificates.bulkDeletePartial', { deleted: succeeded, failed }))
} else {
toast.success(t('certificates.bulkDeleteSuccess', { count: succeeded }))
}
},
onError: () => {
toast.error(t('certificates.bulkDeleteFailed'))
setShowBulkDeleteDialog(false)
},
})}
onCancel={() => setShowBulkDeleteDialog(false)}
isDeleting={bulkDeleteMutation.isPending}
/>
<CertificateDetailDialog
certificate={certToView}
open={certToView !== null}
onOpenChange={(open) => { if (!open) setCertToView(null) }}
/>
<CertificateExportDialog
certificate={certToExport}
open={certToExport !== null}
onOpenChange={(open) => { if (!open) setCertToExport(null) }}
/>
</>
)
}

View File

@@ -25,9 +25,9 @@ export default function CertificateStatusCard({ certificates, hosts, isLoading }
const domains = new Set<string>()
for (const cert of certificates) {
// Handle missing or undefined domain field
if (!cert.domain) continue
// Certificate domain field can be comma-separated
for (const d of cert.domain.split(',')) {
if (!cert.domains) continue
// Certificate domains field can be comma-separated
for (const d of cert.domains.split(',')) {
const trimmed = d.trim().toLowerCase()
if (trimmed) domains.add(trimmed)
}

View File

@@ -0,0 +1,107 @@
import { AlertTriangle, CheckCircle, XCircle } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { ValidationResult } from '../api/certificates'
interface CertificateValidationPreviewProps {
result: ValidationResult
}
export default function CertificateValidationPreview({
result,
}: CertificateValidationPreviewProps) {
const { t } = useTranslation()
return (
<div
className="rounded-lg border border-gray-700 bg-surface-muted/50 p-4 space-y-3"
data-testid="certificate-validation-preview"
role="region"
aria-label={t('certificates.validationPreview')}
>
<div className="flex items-center gap-2">
{result.valid ? (
<CheckCircle className="h-5 w-5 text-green-400" aria-hidden="true" />
) : (
<XCircle className="h-5 w-5 text-red-400" aria-hidden="true" />
)}
<span className="font-medium text-content-primary">
{result.valid
? t('certificates.validCertificate')
: t('certificates.invalidCertificate')}
</span>
</div>
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-sm">
<dt className="text-content-muted">{t('certificates.commonName')}</dt>
<dd className="text-content-primary">{result.common_name || '-'}</dd>
<dt className="text-content-muted">{t('certificates.domains')}</dt>
<dd className="text-content-primary">
{result.domains?.length ? result.domains.join(', ') : '-'}
</dd>
<dt className="text-content-muted">{t('certificates.issuerOrg')}</dt>
<dd className="text-content-primary">{result.issuer_org || '-'}</dd>
<dt className="text-content-muted">{t('certificates.expiresAt')}</dt>
<dd className="text-content-primary">
{result.expires_at ? new Date(result.expires_at).toLocaleDateString() : '-'}
</dd>
<dt className="text-content-muted">{t('certificates.keyMatch')}</dt>
<dd>
{result.key_match ? (
<span className="text-green-400">Yes</span>
) : (
<span className="text-yellow-400">No key provided</span>
)}
</dd>
<dt className="text-content-muted">{t('certificates.chainValid')}</dt>
<dd>
{result.chain_valid ? (
<span className="text-green-400">Yes</span>
) : (
<span className="text-yellow-400">Not verified</span>
)}
</dd>
{result.chain_depth > 0 && (
<>
<dt className="text-content-muted">{t('certificates.chainDepth')}</dt>
<dd className="text-content-primary">{result.chain_depth}</dd>
</>
)}
</dl>
{result.warnings.length > 0 && (
<div className="flex items-start gap-2 rounded-md border border-yellow-900/50 bg-yellow-900/10 p-3">
<AlertTriangle className="h-4 w-4 text-yellow-400 mt-0.5 shrink-0" aria-hidden="true" />
<div className="space-y-1">
<p className="text-sm font-medium text-yellow-400">{t('certificates.warnings')}</p>
<ul className="list-disc list-inside text-sm text-yellow-300/80 space-y-0.5">
{result.warnings.map((w, i) => (
<li key={i}>{w}</li>
))}
</ul>
</div>
</div>
)}
{result.errors.length > 0 && (
<div className="flex items-start gap-2 rounded-md border border-red-900/50 bg-red-900/10 p-3">
<XCircle className="h-4 w-4 text-red-400 mt-0.5 shrink-0" aria-hidden="true" />
<div className="space-y-1">
<p className="text-sm font-medium text-red-400">{t('certificates.errors')}</p>
<ul className="list-disc list-inside text-sm text-red-300/80 space-y-0.5">
{result.errors.map((e, i) => (
<li key={i}>{e}</li>
))}
</ul>
</div>
</div>
)}
</div>
)
}

View File

@@ -917,8 +917,8 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
<SelectContent>
<SelectItem value="0">Auto-manage with Let's Encrypt (recommended)</SelectItem>
{certificates.map(cert => (
<SelectItem key={cert.id || cert.domain} value={String(cert.id ?? 0)}>
{(cert.name || cert.domain)}
<SelectItem key={cert.id || cert.domains} value={String(cert.id ?? 0)}>
{(cert.name || cert.domains)}
{cert.provider ? ` (${cert.provider})` : ''}
</SelectItem>
))}

View File

@@ -3,16 +3,18 @@ import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useCertificates } from '../../hooks/useCertificates'
import { useProxyHosts } from '../../hooks/useProxyHosts'
import { useCertificates, useDeleteCertificate, useBulkDeleteCertificates } from '../../hooks/useCertificates'
import { createTestQueryClient } from '../../test/createTestQueryClient'
import CertificateList, { isDeletable, isInUse } from '../CertificateList'
import type { Certificate } from '../../api/certificates'
import type { ProxyHost } from '../../api/proxyHosts'
vi.mock('../../hooks/useCertificates', () => ({
useCertificates: vi.fn(),
useCertificateDetail: vi.fn(() => ({ detail: null, isLoading: false })),
useDeleteCertificate: vi.fn(),
useBulkDeleteCertificates: vi.fn(),
useExportCertificate: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })),
}))
vi.mock('../../api/certificates', () => ({
@@ -30,10 +32,6 @@ vi.mock('react-i18next', () => ({
}),
}))
vi.mock('../../hooks/useProxyHosts', () => ({
useProxyHosts: vi.fn(),
}))
vi.mock('../../utils/toast', () => ({
toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
}))
@@ -43,14 +41,26 @@ function renderWithClient(ui: React.ReactNode) {
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>)
}
const makeCert = (overrides: Partial<Certificate> = {}): Certificate => ({
uuid: 'cert-1',
domains: 'example.com',
issuer: 'Custom CA',
expires_at: '2026-03-01T00:00:00Z',
status: 'valid',
provider: 'custom',
has_key: true,
in_use: false,
...overrides,
})
const createCertificatesValue = (overrides: Partial<ReturnType<typeof useCertificates>> = {}) => {
const certificates: Certificate[] = [
{ id: 1, name: 'CustomCert', domain: 'example.com', issuer: 'Custom CA', expires_at: '2026-03-01T00:00:00Z', status: 'expired', provider: 'custom' },
{ id: 2, name: 'LE Staging', domain: 'staging.example.com', issuer: "Let's Encrypt Staging", expires_at: '2026-04-01T00:00:00Z', status: 'untrusted', provider: 'letsencrypt-staging' },
{ id: 3, name: 'ActiveCert', domain: 'active.example.com', issuer: 'Custom CA', expires_at: '2026-02-01T00:00:00Z', status: 'valid', provider: 'custom' },
{ id: 4, name: 'UnusedValidCert', domain: 'unused.example.com', issuer: 'Custom CA', expires_at: '2026-05-01T00:00:00Z', status: 'valid', provider: 'custom' },
{ id: 5, name: 'ExpiredLE', domain: 'expired-le.example.com', issuer: "Let's Encrypt", expires_at: '2025-01-01T00:00:00Z', status: 'expired', provider: 'letsencrypt' },
{ id: 6, name: 'ValidLE', domain: 'valid-le.example.com', issuer: "Let's Encrypt", expires_at: '2026-12-01T00:00:00Z', status: 'valid', provider: 'letsencrypt' },
makeCert({ uuid: 'cert-1', name: 'CustomCert', domains: 'example.com', status: 'expired', in_use: false }),
makeCert({ uuid: 'cert-2', name: 'LE Staging', domains: 'staging.example.com', issuer: "Let's Encrypt Staging", status: 'untrusted', provider: 'letsencrypt-staging', in_use: false }),
makeCert({ uuid: 'cert-3', name: 'ActiveCert', domains: 'active.example.com', status: 'valid', in_use: true }),
makeCert({ uuid: 'cert-4', name: 'UnusedValidCert', domains: 'unused.example.com', status: 'valid', in_use: false }),
makeCert({ uuid: 'cert-5', name: 'ExpiredLE', domains: 'expired-le.example.com', issuer: "Let's Encrypt", expires_at: '2025-01-01T00:00:00Z', status: 'expired', provider: 'letsencrypt', in_use: false }),
makeCert({ uuid: 'cert-6', name: 'ValidLE', domains: 'valid-le.example.com', issuer: "Let's Encrypt", expires_at: '2026-12-01T00:00:00Z', status: 'valid', provider: 'letsencrypt', in_use: false }),
]
return {
@@ -62,126 +72,68 @@ const createCertificatesValue = (overrides: Partial<ReturnType<typeof useCertifi
}
}
const createProxyHost = (overrides: Partial<ProxyHost> = {}): ProxyHost => ({
uuid: 'h1',
name: 'Host1',
domain_names: 'host1.example.com',
forward_scheme: 'http',
forward_host: '127.0.0.1',
forward_port: 80,
ssl_forced: false,
http2_support: true,
hsts_enabled: false,
hsts_subdomains: false,
block_exploits: false,
websocket_support: false,
application: 'none',
locations: [],
enabled: true,
created_at: '2026-02-01T00:00:00Z',
updated_at: '2026-02-01T00:00:00Z',
certificate_id: 3,
...overrides,
})
const createProxyHostsValue = (overrides: Partial<ReturnType<typeof useProxyHosts>> = {}): ReturnType<typeof useProxyHosts> => ({
hosts: [
createProxyHost(),
],
loading: false,
isFetching: false,
error: null,
createHost: vi.fn(),
updateHost: vi.fn(),
deleteHost: vi.fn(),
bulkUpdateACL: vi.fn(),
bulkUpdateSecurityHeaders: vi.fn(),
isCreating: false,
isUpdating: false,
isDeleting: false,
isBulkUpdating: false,
...overrides,
})
const getRowNames = () =>
screen
.getAllByRole('row')
.slice(1)
.map(row => row.querySelectorAll('td')[1]?.textContent?.trim() ?? '')
let deleteMutateFn: ReturnType<typeof vi.fn>
let bulkDeleteMutateFn: ReturnType<typeof vi.fn>
beforeEach(() => {
vi.clearAllMocks()
deleteMutateFn = vi.fn()
bulkDeleteMutateFn = vi.fn()
vi.mocked(useCertificates).mockReturnValue(createCertificatesValue())
vi.mocked(useProxyHosts).mockReturnValue(createProxyHostsValue())
vi.mocked(useDeleteCertificate).mockReturnValue({
mutate: deleteMutateFn,
isPending: false,
} as unknown as ReturnType<typeof useDeleteCertificate>)
vi.mocked(useBulkDeleteCertificates).mockReturnValue({
mutate: bulkDeleteMutateFn,
isPending: false,
} as unknown as ReturnType<typeof useBulkDeleteCertificates>)
})
describe('CertificateList', () => {
describe('isDeletable', () => {
const noHosts: ProxyHost[] = []
const withHost = (certId: number): ProxyHost[] => [createProxyHost({ certificate_id: certId })]
it('returns true for custom cert not in use', () => {
const cert: Certificate = { id: 1, name: 'C', domain: 'd', issuer: 'X', expires_at: '', status: 'valid', provider: 'custom' }
expect(isDeletable(cert, noHosts)).toBe(true)
expect(isDeletable(makeCert({ provider: 'custom', in_use: false }))).toBe(true)
})
it('returns true for staging cert not in use', () => {
const cert: Certificate = { id: 2, name: 'S', domain: 'd', issuer: 'X', expires_at: '', status: 'untrusted', provider: 'letsencrypt-staging' }
expect(isDeletable(cert, noHosts)).toBe(true)
expect(isDeletable(makeCert({ provider: 'letsencrypt-staging', in_use: false }))).toBe(true)
})
it('returns true for expired LE cert not in use', () => {
const cert: Certificate = { id: 3, name: 'E', domain: 'd', issuer: 'LE', expires_at: '', status: 'expired', provider: 'letsencrypt' }
expect(isDeletable(cert, noHosts)).toBe(true)
expect(isDeletable(makeCert({ provider: 'letsencrypt', status: 'expired', in_use: false }))).toBe(true)
})
it('returns false for valid LE cert not in use', () => {
const cert: Certificate = { id: 4, name: 'V', domain: 'd', issuer: 'LE', expires_at: '', status: 'valid', provider: 'letsencrypt' }
expect(isDeletable(cert, noHosts)).toBe(false)
expect(isDeletable(makeCert({ provider: 'letsencrypt', status: 'valid', in_use: false }))).toBe(false)
})
it('returns false for cert in use', () => {
const cert: Certificate = { id: 5, name: 'U', domain: 'd', issuer: 'X', expires_at: '', status: 'valid', provider: 'custom' }
expect(isDeletable(cert, withHost(5))).toBe(false)
})
it('returns false for cert without id', () => {
const cert: Certificate = { domain: 'd', issuer: 'X', expires_at: '', status: 'valid', provider: 'custom' }
expect(isDeletable(cert, noHosts)).toBe(false)
expect(isDeletable(makeCert({ provider: 'custom', in_use: true }))).toBe(false)
})
it('returns true for expiring LE cert not in use', () => {
const cert: Certificate = { id: 7, name: 'Exp', domain: 'd', issuer: 'LE', expires_at: '', status: 'expiring', provider: 'letsencrypt' }
expect(isDeletable(cert, noHosts)).toBe(true)
expect(isDeletable(makeCert({ provider: 'letsencrypt', status: 'expiring', in_use: false }))).toBe(true)
})
it('returns false for expiring LE cert that is in use', () => {
const cert: Certificate = { id: 7, name: 'Exp', domain: 'd', issuer: 'LE', expires_at: '', status: 'expiring', provider: 'letsencrypt' }
expect(isDeletable(cert, withHost(7))).toBe(false)
expect(isDeletable(makeCert({ provider: 'letsencrypt', status: 'expiring', in_use: true }))).toBe(false)
})
})
describe('isInUse', () => {
it('returns true when host references cert by certificate_id', () => {
const cert: Certificate = { id: 10, domain: 'd', issuer: 'X', expires_at: '', status: 'valid', provider: 'custom' }
expect(isInUse(cert, [createProxyHost({ certificate_id: 10 })])).toBe(true)
it('returns true when cert.in_use is true', () => {
expect(isInUse(makeCert({ in_use: true }))).toBe(true)
})
it('returns true when host references cert via certificate.id', () => {
const cert: Certificate = { id: 10, domain: 'd', issuer: 'X', expires_at: '', status: 'valid', provider: 'custom' }
const host = createProxyHost({ certificate_id: undefined, certificate: { id: 10, uuid: 'u', name: 'c', provider: 'custom', domains: 'd', expires_at: '' } })
expect(isInUse(cert, [host])).toBe(true)
})
it('returns false when no host references cert', () => {
const cert: Certificate = { id: 99, domain: 'd', issuer: 'X', expires_at: '', status: 'valid', provider: 'custom' }
expect(isInUse(cert, [createProxyHost({ certificate_id: 3 })])).toBe(false)
})
it('returns false when cert.id is undefined even if a host has certificate_id undefined', () => {
const cert: Certificate = { domain: 'd', issuer: 'X', expires_at: '', status: 'valid', provider: 'custom' }
const host = createProxyHost({ certificate_id: undefined })
expect(isInUse(cert, [host])).toBe(false)
it('returns false when cert.in_use is false', () => {
expect(isInUse(makeCert({ in_use: false }))).toBe(false)
})
})
@@ -215,7 +167,6 @@ describe('CertificateList', () => {
})
it('opens dialog and deletes cert on confirm', async () => {
const { deleteCertificate } = await import('../../api/certificates')
const user = userEvent.setup()
renderWithClient(<CertificateList />)
@@ -228,7 +179,7 @@ describe('CertificateList', () => {
expect(within(dialog).getByText('certificates.deleteTitle')).toBeInTheDocument()
await user.click(within(dialog).getByRole('button', { name: 'certificates.deleteButton' }))
await waitFor(() => expect(deleteCertificate).toHaveBeenCalledWith(1))
await waitFor(() => expect(deleteMutateFn).toHaveBeenCalledWith('cert-1', expect.any(Object)))
})
it('does not call createBackup on delete (server handles it)', async () => {
@@ -257,23 +208,6 @@ describe('CertificateList', () => {
expect(await screen.findByText('Failed to load certificates')).toBeInTheDocument()
})
it('shows error toast when delete mutation fails', async () => {
const { deleteCertificate } = await import('../../api/certificates')
const { toast } = await import('../../utils/toast')
vi.mocked(deleteCertificate).mockRejectedValueOnce(new Error('Network error'))
const user = userEvent.setup()
renderWithClient(<CertificateList />)
const rows = await screen.findAllByRole('row')
const customRow = rows.find(r => r.textContent?.includes('CustomCert'))!
await user.click(within(customRow).getByRole('button', { name: 'certificates.deleteTitle' }))
const dialog = await screen.findByRole('dialog')
await user.click(within(dialog).getByRole('button', { name: 'certificates.deleteButton' }))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('certificates.deleteFailed: Network error'))
})
it('clicking disabled delete button for in-use cert does not open dialog', async () => {
const user = userEvent.setup()
renderWithClient(<CertificateList />)
@@ -299,7 +233,7 @@ describe('CertificateList', () => {
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
})
it('renders enabled checkboxes for deletable not-in-use certs (ids 1, 2, 4, 5)', async () => {
it('renders enabled checkboxes for deletable not-in-use certs', async () => {
renderWithClient(<CertificateList />)
const rows = await screen.findAllByRole('row')
for (const name of ['CustomCert', 'LE Staging', 'UnusedValidCert', 'ExpiredLE']) {
@@ -310,7 +244,7 @@ describe('CertificateList', () => {
}
})
it('renders disabled checkbox for in-use cert (id 3)', async () => {
it('renders disabled checkbox for in-use cert', async () => {
renderWithClient(<CertificateList />)
const rows = await screen.findAllByRole('row')
const activeRow = rows.find(r => r.textContent?.includes('ActiveCert'))!
@@ -320,7 +254,7 @@ describe('CertificateList', () => {
expect(rowCheckbox).toHaveAttribute('aria-disabled', 'true')
})
it('renders no checkbox in valid production LE cert row (id 6)', async () => {
it('renders no checkbox in valid production LE cert row', async () => {
renderWithClient(<CertificateList />)
const rows = await screen.findAllByRole('row')
const validLeRow = rows.find(r => r.textContent?.includes('ValidLE'))!
@@ -360,8 +294,7 @@ describe('CertificateList', () => {
expect(await screen.findByRole('dialog')).toBeInTheDocument()
})
it('confirming in the bulk dialog calls deleteCertificate for each selected ID', async () => {
const { deleteCertificate } = await import('../../api/certificates')
it('confirming in the bulk dialog calls bulk delete for selected UUIDs', async () => {
const user = userEvent.setup()
renderWithClient(<CertificateList />)
const rows = await screen.findAllByRole('row')
@@ -373,16 +306,17 @@ describe('CertificateList', () => {
const dialog = await screen.findByRole('dialog')
await user.click(within(dialog).getByRole('button', { name: /certificates\.bulkDeleteButton/i }))
await waitFor(() => {
expect(deleteCertificate).toHaveBeenCalledWith(1)
expect(deleteCertificate).toHaveBeenCalledWith(2)
expect(bulkDeleteMutateFn).toHaveBeenCalledWith(
expect.arrayContaining(['cert-1', 'cert-2']),
expect.any(Object),
)
})
})
it('shows partial failure toast when some bulk deletes fail', async () => {
const { deleteCertificate } = await import('../../api/certificates')
const { toast } = await import('../../utils/toast')
vi.mocked(deleteCertificate).mockImplementation(async (id: number) => {
if (id === 2) throw new Error('network error')
bulkDeleteMutateFn.mockImplementation((_uuids: string[], { onSuccess }: { onSuccess: (data: { succeeded: number; failed: number }) => void }) => {
onSuccess({ succeeded: 1, failed: 1 })
})
const user = userEvent.setup()
renderWithClient(<CertificateList />)
@@ -410,8 +344,8 @@ describe('CertificateList', () => {
it('sorts certificates by name and expiry when headers are clicked', async () => {
const certificates: Certificate[] = [
{ id: 10, name: 'Zulu', domain: 'z.example.com', issuer: 'Custom CA', expires_at: '2026-03-01T00:00:00Z', status: 'valid', provider: 'custom' },
{ id: 11, name: 'Alpha', domain: 'a.example.com', issuer: 'Custom CA', expires_at: '2026-01-01T00:00:00Z', status: 'valid', provider: 'custom' },
makeCert({ uuid: 'cert-z', name: 'Zulu', domains: 'z.example.com', expires_at: '2026-03-01T00:00:00Z' }),
makeCert({ uuid: 'cert-a', name: 'Alpha', domains: 'a.example.com', expires_at: '2026-01-01T00:00:00Z' }),
]
const user = userEvent.setup()

View File

@@ -8,13 +8,15 @@ import type { Certificate } from '../../api/certificates'
import type { ProxyHost } from '../../api/proxyHosts'
const mockCert: Certificate = {
id: 1,
uuid: 'cert-1',
name: 'Test Cert',
domain: 'example.com',
domains: 'example.com',
issuer: "Let's Encrypt",
expires_at: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(),
status: 'valid',
provider: 'letsencrypt',
has_key: true,
in_use: false,
}
const mockHost: ProxyHost = {
@@ -42,13 +44,15 @@ const mockHost: ProxyHost = {
// Helper to create a certificate with a specific domain
function mockCertWithDomain(domain: string, status: 'valid' | 'expiring' | 'expired' | 'untrusted' = 'valid'): Certificate {
return {
id: Math.floor(Math.random() * 10000),
uuid: `cert-${Math.random().toString(36).slice(2, 8)}`,
name: domain,
domain: domain,
domains: domain,
issuer: "Let's Encrypt",
expires_at: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(),
status,
provider: 'letsencrypt',
has_key: true,
in_use: false,
}
}
@@ -58,7 +62,7 @@ function renderWithRouter(ui: React.ReactNode) {
describe('CertificateStatusCard', () => {
it('shows total certificate count', () => {
const certs: Certificate[] = [mockCert, { ...mockCert, id: 2 }, { ...mockCert, id: 3 }]
const certs: Certificate[] = [mockCert, { ...mockCert, uuid: 'cert-2' }, { ...mockCert, uuid: 'cert-3' }]
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={[]} />)
expect(screen.getByText('3')).toBeInTheDocument()
@@ -68,8 +72,8 @@ describe('CertificateStatusCard', () => {
it('shows valid certificate count', () => {
const certs: Certificate[] = [
{ ...mockCert, status: 'valid' },
{ ...mockCert, id: 2, status: 'valid' },
{ ...mockCert, id: 3, status: 'expired' },
{ ...mockCert, uuid: 'cert-2', status: 'valid' },
{ ...mockCert, uuid: 'cert-3', status: 'expired' },
]
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={[]} />)
@@ -79,7 +83,7 @@ describe('CertificateStatusCard', () => {
it('shows expiring count when certificates are expiring', () => {
const certs: Certificate[] = [
{ ...mockCert, status: 'expiring' },
{ ...mockCert, id: 2, status: 'valid' },
{ ...mockCert, uuid: 'cert-2', status: 'valid' },
]
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={[]} />)
@@ -96,7 +100,7 @@ describe('CertificateStatusCard', () => {
it('shows staging count for untrusted certificates', () => {
const certs: Certificate[] = [
{ ...mockCert, status: 'untrusted' },
{ ...mockCert, id: 2, status: 'untrusted' },
{ ...mockCert, uuid: 'cert-2', status: 'untrusted' },
]
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={[]} />)
@@ -206,7 +210,7 @@ describe('CertificateStatusCard - Domain Matching', () => {
it('handles comma-separated certificate domains', () => {
const certs: Certificate[] = [{
...mockCertWithDomain('example.com'),
domain: 'example.com, www.example.com'
domains: 'example.com, www.example.com'
}]
const hosts: ProxyHost[] = [
{ ...mockHost, domain_names: 'www.example.com', ssl_forced: true, certificate_id: null, enabled: true }
@@ -295,7 +299,7 @@ describe('CertificateStatusCard - Domain Matching', () => {
it('handles whitespace in certificate domains', () => {
const certs: Certificate[] = [{
...mockCertWithDomain('example.com'),
domain: ' example.com '
domains: ' example.com '
}]
const hosts: ProxyHost[] = [
{ ...mockHost, domain_names: 'example.com', ssl_forced: true, certificate_id: null, enabled: true }

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', () => {

View File

@@ -0,0 +1,135 @@
import { Upload } from 'lucide-react'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { cn } from '../../utils/cn'
interface FileDropZoneProps {
id: string
label: string
accept?: string
file: File | null
onFileChange: (file: File | null) => void
disabled?: boolean
required?: boolean
formatBadge?: string | null
}
export function FileDropZone({
id,
label,
accept,
file,
onFileChange,
disabled = false,
required = false,
formatBadge,
}: FileDropZoneProps) {
const { t } = useTranslation()
const inputRef = useRef<HTMLInputElement>(null)
const [isDragOver, setIsDragOver] = useState(false)
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
setIsDragOver(false)
if (disabled) return
const droppedFile = e.dataTransfer.files[0]
if (droppedFile) onFileChange(droppedFile)
},
[disabled, onFileChange],
)
const handleDragOver = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
if (!disabled) setIsDragOver(true)
},
[disabled],
)
const handleDragLeave = useCallback(() => {
setIsDragOver(false)
}, [])
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const selected = e.target.files?.[0] || null
onFileChange(selected)
},
[onFileChange],
)
const handleClick = useCallback(() => {
if (!disabled) inputRef.current?.click()
}, [disabled])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if ((e.key === 'Enter' || e.key === ' ') && !disabled) {
e.preventDefault()
inputRef.current?.click()
}
},
[disabled],
)
return (
<div>
<label htmlFor={id} className="block text-sm font-medium text-content-secondary mb-1.5">
{label}
{required && <span className="text-error ml-0.5" aria-hidden="true">*</span>}
</label>
<div
role="button"
tabIndex={disabled ? -1 : 0}
aria-label={file ? `${label}: ${file.name}` : label}
aria-disabled={disabled}
onClick={handleClick}
onKeyDown={handleKeyDown}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
className={cn(
'relative flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-6 transition-colors cursor-pointer',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-elevated',
isDragOver && !disabled
? 'border-brand-500 bg-brand-500/10'
: 'border-gray-700 hover:border-gray-600 bg-surface-muted/30',
disabled && 'opacity-50 cursor-not-allowed',
)}
>
<input
ref={inputRef}
id={id}
type="file"
accept={accept}
onChange={handleInputChange}
disabled={disabled}
className="sr-only"
aria-required={required}
tabIndex={-1}
/>
{file ? (
<div className="flex items-center gap-2 text-sm">
<Upload className="h-4 w-4 text-brand-400" aria-hidden="true" />
<span className="text-content-primary font-medium truncate max-w-[200px]">
{file.name}
</span>
{formatBadge && (
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-brand-500/20 text-brand-400 border border-brand-500/30">
{formatBadge}
</span>
)}
</div>
) : (
<div className="flex flex-col items-center gap-1 text-sm text-content-muted">
<Upload className="h-5 w-5 mb-1" aria-hidden="true" />
<span>{t('certificates.dropFileHere')}</span>
</div>
)}
</div>
</div>
)
}

View File

@@ -1,6 +1,16 @@
import { useQuery } from '@tanstack/react-query'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { getCertificates } from '../api/certificates'
import {
getCertificates,
getCertificateDetail,
uploadCertificate,
updateCertificate,
deleteCertificate,
exportCertificate,
validateCertificate,
} from '../api/certificates'
import type { CertificateDetail } from '../api/certificates'
interface UseCertificatesOptions {
refetchInterval?: number | false
@@ -20,3 +30,103 @@ export function useCertificates(options?: UseCertificatesOptions) {
refetch,
}
}
export function useCertificateDetail(uuid: string | null) {
const { data, isLoading, error } = useQuery({
queryKey: ['certificates', uuid],
queryFn: () => getCertificateDetail(uuid!),
enabled: !!uuid,
})
return {
detail: data as CertificateDetail | undefined,
isLoading,
error,
}
}
export function useUploadCertificate() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (params: {
name: string
certFile: File
keyFile?: File
chainFile?: File
}) => uploadCertificate(params.name, params.certFile, params.keyFile, params.chainFile),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['certificates'] })
},
})
}
export function useUpdateCertificate() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (params: { uuid: string; name: string }) =>
updateCertificate(params.uuid, params.name),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['certificates'] })
},
})
}
export function useDeleteCertificate() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (uuid: string) => deleteCertificate(uuid),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['certificates'] })
queryClient.invalidateQueries({ queryKey: ['proxyHosts'] })
},
})
}
export function useExportCertificate() {
return useMutation({
mutationFn: (params: {
uuid: string
format: string
includeKey: boolean
password?: string
pfxPassword?: string
}) =>
exportCertificate(
params.uuid,
params.format,
params.includeKey,
params.password,
params.pfxPassword,
),
})
}
export function useValidateCertificate() {
return useMutation({
mutationFn: (params: {
certFile: File
keyFile?: File
chainFile?: File
}) => validateCertificate(params.certFile, params.keyFile, params.chainFile),
})
}
export function useBulkDeleteCertificates() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (uuids: string[]) => {
const results = await Promise.allSettled(uuids.map(uuid => deleteCertificate(uuid)))
const failed = results.filter(r => r.status === 'rejected').length
const succeeded = results.filter(r => r.status === 'fulfilled').length
return { succeeded, failed }
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['certificates'] })
queryClient.invalidateQueries({ queryKey: ['proxyHosts'] })
},
})
}

View File

@@ -207,7 +207,68 @@
"providerStaging": "Staging",
"providerCustom": "Custom",
"providerExpiredLE": "Expired LE",
"providerExpiringLE": "Expiring LE"
"providerExpiringLE": "Expiring LE",
"certificateFile": "Certificate File",
"privateKeyFile": "Private Key File",
"chainFile": "Chain File (Optional)",
"dropFileHere": "Drag and drop a file here, or click to browse",
"formatDetected": "Detected: {{format}}",
"pfxDetected": "PFX/PKCS#12 detected — key is embedded, no separate key file needed.",
"pfxPassword": "PFX Password (if protected)",
"validate": "Validate",
"validating": "Validating...",
"validationPreview": "Validation Preview",
"commonName": "Common Name",
"domains": "Domains",
"issuerOrg": "Issuer",
"keyMatch": "Key Match",
"chainValid": "Chain Valid",
"chainDepth": "Chain Depth",
"warnings": "Warnings",
"errors": "Errors",
"validCertificate": "Valid certificate",
"invalidCertificate": "Certificate has errors",
"uploadAndSave": "Upload & Save",
"detailTitle": "Certificate Details",
"fingerprint": "Fingerprint",
"serialNumber": "Serial Number",
"keyType": "Key Type",
"notBefore": "Valid From",
"autoRenew": "Auto Renew",
"createdAt": "Created",
"updatedAt": "Last Updated",
"assignedHosts": "Assigned Hosts",
"noAssignedHosts": "Not assigned to any proxy host",
"certificateChain": "Certificate Chain",
"noChainData": "No chain data available",
"chainLeaf": "Leaf",
"chainIntermediate": "Intermediate",
"chainRoot": "Root",
"exportTitle": "Export Certificate",
"exportFormat": "Format",
"exportFormatPem": "PEM",
"exportFormatPfx": "PFX/PKCS#12",
"exportFormatDer": "DER",
"includePrivateKey": "Include Private Key",
"includePrivateKeyWarning": "Exporting the private key requires re-authentication.",
"exportPassword": "Account Password",
"exportPfxPassword": "PFX Password",
"exportButton": "Export",
"exportSuccess": "Certificate exported",
"exportFailed": "Failed to export certificate",
"expiresInDays": "Expires in {{days}} days",
"expiredAgo": "Expired {{days}} days ago",
"viewDetails": "View details",
"export": "Export",
"updateName": "Rename Certificate",
"updateSuccess": "Certificate renamed",
"updateFailed": "Failed to rename certificate"
},
"auth": {
"login": "Login",

View File

@@ -1,59 +1,19 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, ShieldCheck } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { uploadCertificate } from '../api/certificates'
import CertificateList from '../components/CertificateList'
import CertificateUploadDialog from '../components/dialogs/CertificateUploadDialog'
import { PageShell } from '../components/layout/PageShell'
import {
Button,
Input,
Alert,
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
Label,
} from '../components/ui'
import { toast } from '../utils/toast'
import { Button, Alert } from '../components/ui'
export default function Certificates() {
const { t } = useTranslation()
const [isModalOpen, setIsModalOpen] = useState(false)
const [name, setName] = useState('')
const [certFile, setCertFile] = useState<File | null>(null)
const [keyFile, setKeyFile] = useState<File | null>(null)
const queryClient = useQueryClient()
const [isUploadOpen, setIsUploadOpen] = useState(false)
const uploadMutation = useMutation({
mutationFn: async () => {
if (!certFile || !keyFile) throw new Error('Files required')
await uploadCertificate(name, certFile, keyFile)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['certificates'] })
setIsModalOpen(false)
setName('')
setCertFile(null)
setKeyFile(null)
toast.success(t('certificates.uploadSuccess'))
},
onError: (error: Error) => {
toast.error(`${t('certificates.uploadFailed')}: ${error.message}`)
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
uploadMutation.mutate()
}
// Header actions
const headerActions = (
<Button onClick={() => setIsModalOpen(true)} data-testid="add-certificate-btn">
<Plus className="w-4 h-4 mr-2" />
<Button onClick={() => setIsUploadOpen(true)} data-testid="add-certificate-btn">
<Plus className="w-4 h-4 mr-2" aria-hidden="true" />
{t('certificates.addCertificate')}
</Button>
)
@@ -70,56 +30,7 @@ export default function Certificates() {
<CertificateList />
{/* Upload Certificate Dialog */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent data-testid="certificate-upload-dialog">
<DialogHeader>
<DialogTitle>{t('certificates.uploadCertificate')}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 py-4">
<Input
id="certificate-name"
label={t('certificates.friendlyName')}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. My Custom Cert"
required
/>
<div>
<Label htmlFor="cert-file">{t('certificates.certificatePem')}</Label>
<input
id="cert-file"
data-testid="certificate-file-input"
type="file"
accept=".pem,.crt,.cer"
onChange={(e) => setCertFile(e.target.files?.[0] || null)}
className="mt-1.5 block w-full text-sm text-content-secondary file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-brand-500 file:text-white hover:file:bg-brand-600 cursor-pointer"
required
/>
</div>
<div>
<Label htmlFor="key-file">{t('certificates.privateKeyPem')}</Label>
<input
id="key-file"
data-testid="certificate-key-input"
type="file"
accept=".pem,.key"
onChange={(e) => setKeyFile(e.target.files?.[0] || null)}
className="mt-1.5 block w-full text-sm text-content-secondary file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-brand-500 file:text-white hover:file:bg-brand-600 cursor-pointer"
required
/>
</div>
<DialogFooter className="pt-4">
<Button type="button" variant="secondary" onClick={() => setIsModalOpen(false)}>
{t('common.cancel')}
</Button>
<Button type="submit" isLoading={uploadMutation.isPending}>
{t('common.upload')}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<CertificateUploadDialog open={isUploadOpen} onOpenChange={setIsUploadOpen} />
</PageShell>
)
}

View File

@@ -44,8 +44,8 @@ export default function Dashboard() {
const certifiedDomains = new Set<string>()
for (const cert of certificates) {
// Handle missing or undefined domain field
if (!cert.domain) continue
for (const d of cert.domain.split(',')) {
if (!cert.domains) continue
for (const d of cert.domains.split(',')) {
const trimmed = d.trim().toLowerCase()
if (trimmed) certifiedDomains.add(trimmed)
}

View File

@@ -57,7 +57,7 @@ export default function ProxyHosts() {
const [certCleanupData, setCertCleanupData] = useState<{
hostUUIDs: string[]
hostNames: string[]
certificates: Array<{ id: number; name: string; domain: string }>
certificates: Array<{ uuid: string; name: string; domain: string }>
isBulk: boolean
} | null>(null)
const [selectedACLs, setSelectedACLs] = useState<Set<number>>(new Set())
@@ -103,7 +103,7 @@ export default function ProxyHosts() {
const certStatusByDomain = useMemo(() => {
const map: Record<string, { status: string; provider: string }> = {}
for (const cert of certificates) {
const domains = cert.domain.split(',').map(d => d.trim().toLowerCase())
const domains = cert.domains.split(',').map(d => d.trim().toLowerCase())
for (const domain of domains) {
if (!map[domain]) {
map[domain] = { status: cert.status, provider: cert.provider }
@@ -148,7 +148,7 @@ export default function ProxyHosts() {
const host = hostToDelete
// Check for orphaned certificates that would need cleanup
const orphanedCerts: Array<{ id: number; name: string; domain: string }> = []
const orphanedCerts: Array<{ uuid: string; name: string; domain: string }> = []
if (host.certificate_id && host.certificate) {
const cert = host.certificate
@@ -160,7 +160,7 @@ export default function ProxyHosts() {
const isCustomOrStaging = cert.provider === 'custom' || cert.provider?.toLowerCase().includes('staging')
if (isCustomOrStaging) {
orphanedCerts.push({
id: cert.id!,
uuid: cert.uuid,
name: cert.name || '',
domain: cert.domains
})
@@ -237,7 +237,7 @@ export default function ProxyHosts() {
for (const cert of certCleanupData.certificates) {
try {
await deleteCertificate(cert.id)
await deleteCertificate(cert.uuid)
certsDeleted++
} catch {
certsFailed++
@@ -282,7 +282,7 @@ export default function ProxyHosts() {
// Delete certificate if user confirmed
if (deleteCerts && certCleanupData.certificates.length > 0) {
try {
await deleteCertificate(certCleanupData.certificates[0].id)
await deleteCertificate(certCleanupData.certificates[0].uuid)
toast.success('Proxy host and certificate deleted')
} catch (err) {
toast.error(`Proxy host deleted but failed to delete certificate: ${err instanceof Error ? err.message : 'Unknown error'}`)
@@ -329,7 +329,7 @@ export default function ProxyHosts() {
toast.success(`Backup created: ${backup.filename}`)
// Collect certificates to potentially delete
const certsToConsider: Map<number, { id: number; name: string; domain: string }> = new Map()
const certsToConsider: Map<string, { uuid: string; name: string; domain: string }> = new Map()
for (const uuid of hostUUIDs) {
const host = hosts.find(h => h.uuid === uuid)
@@ -343,9 +343,9 @@ export default function ProxyHosts() {
h.certificate_id === host.certificate_id &&
!hostUUIDs.includes(h.uuid)
)
if (otherHosts.length === 0 && cert.id) {
certsToConsider.set(cert.id, {
id: cert.id,
if (otherHosts.length === 0 && cert.uuid) {
certsToConsider.set(cert.uuid, {
uuid: cert.uuid,
name: cert.name || '',
domain: cert.domains
})

View File

@@ -1,55 +1,27 @@
import { fireEvent, screen, waitFor, within } from '@testing-library/react'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { uploadCertificate, type Certificate } from '../../api/certificates'
import { renderWithQueryClient } from '../../test-utils/renderWithQueryClient'
import { toast } from '../../utils/toast'
import Certificates from '../Certificates'
const translations: Record<string, string> = {
'certificates.addCertificate': 'Add Certificate',
'certificates.uploadCertificate': 'Upload Certificate',
'certificates.friendlyName': 'Friendly Name',
'certificates.certificatePem': 'Certificate (PEM)',
'certificates.privateKeyPem': 'Private Key (PEM)',
'certificates.uploadSuccess': 'Certificate uploaded successfully',
'certificates.uploadFailed': 'Failed to upload certificate',
'common.upload': 'Upload',
'common.cancel': 'Cancel',
}
const t = (key: string, options?: Record<string, unknown>) => {
const template = translations[key] ?? key
if (!options) return template
return Object.entries(options).reduce((acc, [optionKey, optionValue]) => {
return acc.replace(`{{${optionKey}}}`, String(optionValue))
}, template)
}
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t,
t: (key: string) => key,
}),
}))
vi.mock('../../components/CertificateList', () => ({
default: () => <div>CertificateList</div>,
default: () => <div data-testid="certificate-list">CertificateList</div>,
}))
vi.mock('../../api/certificates', () => ({
uploadCertificate: vi.fn(),
}))
vi.mock('../../utils/toast', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
vi.mock('../../components/dialogs/CertificateUploadDialog', () => ({
default: ({ open, onOpenChange }: { open: boolean; onOpenChange: (v: boolean) => void }) =>
open ? (
<div role="dialog" data-testid="upload-dialog">
<button onClick={() => onOpenChange(false)}>Close</button>
</div>
) : null,
}))
describe('Certificates', () => {
@@ -57,93 +29,35 @@ describe('Certificates', () => {
vi.clearAllMocks()
})
it('uploads certificate and closes dialog on success', async () => {
const certificate: Certificate = {
domain: 'example.com',
issuer: 'Test CA',
expires_at: '2026-03-01T00:00:00Z',
status: 'valid',
provider: 'custom',
}
vi.mocked(uploadCertificate).mockResolvedValue(certificate)
const user = userEvent.setup()
const { queryClient } = renderWithQueryClient(<Certificates />)
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
await user.click(screen.getByRole('button', { name: t('certificates.addCertificate') }))
const dialog = await screen.findByRole('dialog', { name: t('certificates.uploadCertificate') })
const nameInput = within(dialog).getByLabelText(t('certificates.friendlyName')) as HTMLInputElement
await user.type(nameInput, 'My Cert')
await waitFor(() => {
expect(nameInput.value).toBe('My Cert')
})
const certFile = new File(['cert'], 'cert.pem', { type: 'application/x-pem-file' })
const keyFile = new File(['key'], 'key.pem', { type: 'application/x-pem-file' })
const certInput = within(dialog).getByLabelText(t('certificates.certificatePem')) as HTMLInputElement
const keyInput = within(dialog).getByLabelText(t('certificates.privateKeyPem')) as HTMLInputElement
await user.upload(certInput, certFile)
await user.upload(keyInput, keyFile)
await waitFor(() => {
expect(certInput.files?.[0]).toBe(certFile)
expect(keyInput.files?.[0]).toBe(keyFile)
})
const form = dialog.querySelector('form') as HTMLFormElement
fireEvent.submit(form)
await waitFor(() => {
expect(uploadCertificate).toHaveBeenCalledWith('My Cert', certFile, keyFile)
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['certificates'] })
expect(toast.success).toHaveBeenCalledWith(t('certificates.uploadSuccess'))
})
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: t('certificates.uploadCertificate') })).not.toBeInTheDocument()
})
it('renders the page with certificate list and add button', () => {
renderWithQueryClient(<Certificates />)
expect(screen.getByText('certificates.addCertificate')).toBeInTheDocument()
expect(screen.getByTestId('certificate-list')).toBeInTheDocument()
})
it('surfaces upload errors', async () => {
vi.mocked(uploadCertificate).mockRejectedValue(new Error('Upload failed'))
it('opens upload dialog when add button is clicked', async () => {
const user = userEvent.setup()
renderWithQueryClient(<Certificates />)
await user.click(screen.getByRole('button', { name: t('certificates.addCertificate') }))
expect(screen.queryByTestId('upload-dialog')).not.toBeInTheDocument()
const dialog = await screen.findByRole('dialog', { name: t('certificates.uploadCertificate') })
await user.click(screen.getByRole('button', { name: 'certificates.addCertificate' }))
expect(screen.getByTestId('upload-dialog')).toBeInTheDocument()
})
const nameInput = within(dialog).getByLabelText(t('certificates.friendlyName')) as HTMLInputElement
await user.type(nameInput, 'My Cert')
await waitFor(() => {
expect(nameInput.value).toBe('My Cert')
})
it('closes upload dialog via onOpenChange callback', async () => {
const user = userEvent.setup()
renderWithQueryClient(<Certificates />)
const certFile = new File(['cert'], 'cert.pem', { type: 'application/x-pem-file' })
const keyFile = new File(['key'], 'key.pem', { type: 'application/x-pem-file' })
await user.click(screen.getByRole('button', { name: 'certificates.addCertificate' }))
expect(screen.getByTestId('upload-dialog')).toBeInTheDocument()
const certInput = within(dialog).getByLabelText(t('certificates.certificatePem')) as HTMLInputElement
const keyInput = within(dialog).getByLabelText(t('certificates.privateKeyPem')) as HTMLInputElement
await user.click(screen.getByText('Close'))
expect(screen.queryByTestId('upload-dialog')).not.toBeInTheDocument()
})
await user.upload(certInput, certFile)
await user.upload(keyInput, keyFile)
await waitFor(() => {
expect(certInput.files?.[0]).toBe(certFile)
expect(keyInput.files?.[0]).toBe(keyFile)
})
const form = dialog.querySelector('form') as HTMLFormElement
fireEvent.submit(form)
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith(`${t('certificates.uploadFailed')}: Upload failed`)
})
it('renders info alert with note text', () => {
renderWithQueryClient(<Certificates />)
expect(screen.getByText('certificates.noteText')).toBeInTheDocument()
})
})

View File

@@ -485,8 +485,8 @@ describe('ProxyHosts - Coverage enhancements', () => {
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([hostCustom, hostStaging, hostAuto, hostLets])
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([
{ domain: 'staging.com', status: 'untrusted', provider: 'letsencrypt-staging', issuer: 'Let\'s Encrypt', expires_at: '2026-01-01' },
{ domain: 'lets.com', status: 'valid', provider: 'letsencrypt', issuer: 'Let\'s Encrypt', expires_at: '2026-01-01' },
{ uuid: 'cert-staging', domains: 'staging.com', status: 'untrusted', provider: 'letsencrypt-staging', issuer: 'Let\'s Encrypt', expires_at: '2026-01-01', has_key: false, in_use: true },
{ uuid: 'cert-lets', domains: 'lets.com', status: 'valid', provider: 'letsencrypt', issuer: 'Let\'s Encrypt', expires_at: '2026-01-01', has_key: false, in_use: true },
])
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
vi.mocked(settingsApi.getSettings).mockResolvedValue({})

View File

@@ -190,12 +190,15 @@ describe('ProxyHosts page extra tests', () => {
certificates: [
{
id: 1,
uuid: 'cert-le-1',
name: 'LE',
domain: 'valid.example.com',
domains: 'valid.example.com',
issuer: 'letsencrypt',
expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
status: 'valid',
provider: 'letsencrypt',
has_key: false,
in_use: true,
},
],
}),