From 30c9d735aabcac5e951b871b42865ae5bdd8cdb5 Mon Sep 17 00:00:00 2001
From: GitHub Actions
Date: Sat, 11 Apr 2026 23:32:22 +0000
Subject: [PATCH] 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.
---
.../src/api/__tests__/certificates.test.ts | 10 +-
frontend/src/api/certificates.ts | 126 +++++++--
.../src/components/CertificateChainViewer.tsx | 72 +++++
frontend/src/components/CertificateList.tsx | 263 ++++++++++--------
.../src/components/CertificateStatusCard.tsx | 6 +-
.../CertificateValidationPreview.tsx | 107 +++++++
frontend/src/components/ProxyHostForm.tsx | 4 +-
.../__tests__/CertificateList.test.tsx | 186 ++++---------
.../__tests__/CertificateStatusCard.test.tsx | 26 +-
.../dialogs/BulkDeleteCertificateDialog.tsx | 4 +-
.../dialogs/CertificateCleanupDialog.tsx | 4 +-
.../dialogs/CertificateDetailDialog.tsx | 143 ++++++++++
.../dialogs/CertificateExportDialog.tsx | 187 +++++++++++++
.../dialogs/CertificateUploadDialog.tsx | 205 ++++++++++++++
.../dialogs/DeleteCertificateDialog.tsx | 4 +-
.../BulkDeleteCertificateDialog.test.tsx | 14 +-
.../DeleteCertificateDialog.test.tsx | 6 +-
frontend/src/components/ui/FileDropZone.tsx | 135 +++++++++
frontend/src/hooks/useCertificates.ts | 114 +++++++-
frontend/src/locales/en/translation.json | 63 ++++-
frontend/src/pages/Certificates.tsx | 101 +------
frontend/src/pages/Dashboard.tsx | 4 +-
frontend/src/pages/ProxyHosts.tsx | 20 +-
.../src/pages/__tests__/Certificates.test.tsx | 146 ++--------
.../__tests__/ProxyHosts-coverage.test.tsx | 4 +-
.../pages/__tests__/ProxyHosts-extra.test.tsx | 5 +-
26 files changed, 1428 insertions(+), 531 deletions(-)
create mode 100644 frontend/src/components/CertificateChainViewer.tsx
create mode 100644 frontend/src/components/CertificateValidationPreview.tsx
create mode 100644 frontend/src/components/dialogs/CertificateDetailDialog.tsx
create mode 100644 frontend/src/components/dialogs/CertificateExportDialog.tsx
create mode 100644 frontend/src/components/dialogs/CertificateUploadDialog.tsx
create mode 100644 frontend/src/components/ui/FileDropZone.tsx
diff --git a/frontend/src/api/__tests__/certificates.test.ts b/frontend/src/api/__tests__/certificates.test.ts
index 751e82b3..5777290f 100644
--- a/frontend/src/api/__tests__/certificates.test.ts
+++ b/frontend/src/api/__tests__/certificates.test.ts
@@ -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');
});
});
diff --git a/frontend/src/api/certificates.ts b/frontend/src/api/certificates.ts
index 154726ee..ff5bcd51 100644
--- a/frontend/src/api/certificates.ts
+++ b/frontend/src/api/certificates.ts
@@ -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 {
const response = await client.get('/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 {
+export async function getCertificateDetail(uuid: string): Promise {
+ const response = await client.get(`/certificates/${uuid}`)
+ return response.data
+}
+
+export async function uploadCertificate(
+ name: string,
+ certFile: File,
+ keyFile?: File,
+ chainFile?: File,
+): Promise {
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('/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 {
- await client.delete(`/certificates/${id}`)
+export async function updateCertificate(uuid: string, name: string): Promise {
+ const response = await client.put(`/certificates/${uuid}`, { name })
+ return response.data
+}
+
+export async function deleteCertificate(uuid: string): Promise {
+ await client.delete(`/certificates/${uuid}`)
+}
+
+export async function exportCertificate(
+ uuid: string,
+ format: string,
+ includeKey: boolean,
+ password?: string,
+ pfxPassword?: string,
+): Promise {
+ 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 {
+ 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('/certificates/validate', formData, {
+ headers: { 'Content-Type': 'multipart/form-data' },
+ })
+ return response.data
}
diff --git a/frontend/src/components/CertificateChainViewer.tsx b/frontend/src/components/CertificateChainViewer.tsx
new file mode 100644
index 00000000..02e5aa80
--- /dev/null
+++ b/frontend/src/components/CertificateChainViewer.tsx
@@ -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 (
+ {t('certificates.noChainData')}
+ )
+ }
+
+ return (
+
+ {chain.map((entry, index) => {
+ const label = getChainLabel(index, chain.length, t)
+ const isLast = index === chain.length - 1
+
+ return (
+
+
+
+
+ {index === 0 ? (
+
+ ) : (
+
+ )}
+
+ {!isLast && (
+
+ )}
+
+
+
+
+ {label}
+
+
+
+ {entry.subject}
+
+
+ {t('certificates.issuerOrg')}: {entry.issuer}
+
+
+ {t('certificates.expiresAt')}: {new Date(entry.expires_at).toLocaleDateString()}
+
+
+
+
+ )
+ })}
+
+ )
+}
diff --git a/frontend/src/components/CertificateList.tsx b/frontend/src/components/CertificateList.tsx
index 8866fa2e..1fc79921 100644
--- a/frontend/src/components/CertificateList.tsx
+++ b/frontend/src/components/CertificateList.tsx
@@ -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('name')
const [sortDirection, setSortDirection] = useState('asc')
const [certToDelete, setCertToDelete] = useState(null)
- const [selectedIds, setSelectedIds] = useState>(new Set())
+ const [certToView, setCertToView] = useState(null)
+ const [certToExport, setCertToExport] = useState(null)
+ const [selectedIds, setSelectedIds] = useState>(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>(() => {
- const ids = new Set()
+ const selectableCertIds = useMemo>(() => {
+ const ids = new Set()
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() {
) : (
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 (
-
+
{deletable && !inUse ? (
|
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 })}
/>
|
) : 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 })}
/>
@@ -279,7 +259,7 @@ export default function CertificateList() {
|
)}
{cert.name || '-'} |
- {cert.domain} |
+ {cert.domains} |
{cert.issuer}
@@ -291,49 +271,80 @@ export default function CertificateList() {
|
- {new Date(cert.expires_at).toLocaleDateString()}
+
+
+
+
+ {new Date(cert.expires_at).toLocaleDateString()}
+
+
+
+ {days > 0
+ ? t('certificates.expiresInDays', { days })
+ : t('certificates.expiredAgo', { days: Math.abs(days) })}
+
+
+
|
|
- {(() => {
- if (cert.id && inUse && (cert.provider === 'custom' || cert.provider === 'letsencrypt-staging' || cert.status === 'expired')) {
- return (
-
-
-
-
-
-
- {t('certificates.deleteInUse')}
-
-
-
- )
- }
+
+
+
+ {(() => {
+ if (inUse && (cert.provider === 'custom' || cert.provider === 'letsencrypt-staging' || cert.status === 'expired')) {
+ return (
+
+
+
+
+
+
+ {t('certificates.deleteInUse')}
+
+
+
+ )
+ }
- if (deletable) {
- return (
-
- )
- }
+ if (deletable) {
+ return (
+
+ )
+ }
- return null
- })()}
+ return null
+ })()}
+
|
)
@@ -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}
/>
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}
/>
+ { if (!open) setCertToView(null) }}
+ />
+ { if (!open) setCertToExport(null) }}
+ />
>
)
}
diff --git a/frontend/src/components/CertificateStatusCard.tsx b/frontend/src/components/CertificateStatusCard.tsx
index 65f98729..ac4cc00f 100644
--- a/frontend/src/components/CertificateStatusCard.tsx
+++ b/frontend/src/components/CertificateStatusCard.tsx
@@ -25,9 +25,9 @@ export default function CertificateStatusCard({ certificates, hosts, isLoading }
const domains = new Set()
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)
}
diff --git a/frontend/src/components/CertificateValidationPreview.tsx b/frontend/src/components/CertificateValidationPreview.tsx
new file mode 100644
index 00000000..9e5055d5
--- /dev/null
+++ b/frontend/src/components/CertificateValidationPreview.tsx
@@ -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 (
+
+
+ {result.valid ? (
+
+ ) : (
+
+ )}
+
+ {result.valid
+ ? t('certificates.validCertificate')
+ : t('certificates.invalidCertificate')}
+
+
+
+
+ - {t('certificates.commonName')}
+ - {result.common_name || '-'}
+
+ - {t('certificates.domains')}
+ -
+ {result.domains?.length ? result.domains.join(', ') : '-'}
+
+
+ - {t('certificates.issuerOrg')}
+ - {result.issuer_org || '-'}
+
+ - {t('certificates.expiresAt')}
+ -
+ {result.expires_at ? new Date(result.expires_at).toLocaleDateString() : '-'}
+
+
+ - {t('certificates.keyMatch')}
+ -
+ {result.key_match ? (
+ Yes
+ ) : (
+ No key provided
+ )}
+
+
+ - {t('certificates.chainValid')}
+ -
+ {result.chain_valid ? (
+ Yes
+ ) : (
+ Not verified
+ )}
+
+
+ {result.chain_depth > 0 && (
+ <>
+ - {t('certificates.chainDepth')}
+ - {result.chain_depth}
+ >
+ )}
+
+
+ {result.warnings.length > 0 && (
+
+
+
+
{t('certificates.warnings')}
+
+ {result.warnings.map((w, i) => (
+ - {w}
+ ))}
+
+
+
+ )}
+
+ {result.errors.length > 0 && (
+
+
+
+
{t('certificates.errors')}
+
+ {result.errors.map((e, i) => (
+ - {e}
+ ))}
+
+
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/ProxyHostForm.tsx b/frontend/src/components/ProxyHostForm.tsx
index 0a77144f..4cd0181f 100644
--- a/frontend/src/components/ProxyHostForm.tsx
+++ b/frontend/src/components/ProxyHostForm.tsx
@@ -917,8 +917,8 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
Auto-manage with Let's Encrypt (recommended)
{certificates.map(cert => (
-
- {(cert.name || cert.domain)}
+
+ {(cert.name || cert.domains)}
{cert.provider ? ` (${cert.provider})` : ''}
))}
diff --git a/frontend/src/components/__tests__/CertificateList.test.tsx b/frontend/src/components/__tests__/CertificateList.test.tsx
index ea63b910..7548f5a9 100644
--- a/frontend/src/components/__tests__/CertificateList.test.tsx
+++ b/frontend/src/components/__tests__/CertificateList.test.tsx
@@ -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({ui})
}
+const makeCert = (overrides: Partial = {}): 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> = {}) => {
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 = {}): 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 => ({
- 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
+let bulkDeleteMutateFn: ReturnType
+
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)
+ vi.mocked(useBulkDeleteCertificates).mockReturnValue({
+ mutate: bulkDeleteMutateFn,
+ isPending: false,
+ } as unknown as ReturnType)
})
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()
@@ -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()
- 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()
@@ -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()
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()
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()
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()
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()
@@ -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()
diff --git a/frontend/src/components/__tests__/CertificateStatusCard.test.tsx b/frontend/src/components/__tests__/CertificateStatusCard.test.tsx
index 6055672a..fb02d346 100644
--- a/frontend/src/components/__tests__/CertificateStatusCard.test.tsx
+++ b/frontend/src/components/__tests__/CertificateStatusCard.test.tsx
@@ -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()
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()
@@ -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()
@@ -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()
@@ -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 }
diff --git a/frontend/src/components/dialogs/BulkDeleteCertificateDialog.tsx b/frontend/src/components/dialogs/BulkDeleteCertificateDialog.tsx
index 17f867ac..458cb782 100644
--- a/frontend/src/components/dialogs/BulkDeleteCertificateDialog.tsx
+++ b/frontend/src/components/dialogs/BulkDeleteCertificateDialog.tsx
@@ -64,10 +64,10 @@ export default function BulkDeleteCertificateDialog({
>
{certificates.map((cert) => (
- {cert.name || cert.domain}
+ {cert.name || cert.domains}
{providerLabel(cert, t)}
))}
diff --git a/frontend/src/components/dialogs/CertificateCleanupDialog.tsx b/frontend/src/components/dialogs/CertificateCleanupDialog.tsx
index 210849b1..594ed9d3 100644
--- a/frontend/src/components/dialogs/CertificateCleanupDialog.tsx
+++ b/frontend/src/components/dialogs/CertificateCleanupDialog.tsx
@@ -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({
{certificates.map((cert) => (
- -
+
-
→
{cert.name || cert.domain}
({cert.domain})
diff --git a/frontend/src/components/dialogs/CertificateDetailDialog.tsx b/frontend/src/components/dialogs/CertificateDetailDialog.tsx
new file mode 100644
index 00000000..a8b38385
--- /dev/null
+++ b/frontend/src/components/dialogs/CertificateDetailDialog.tsx
@@ -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 (
+
+ )
+}
diff --git a/frontend/src/components/dialogs/CertificateExportDialog.tsx b/frontend/src/components/dialogs/CertificateExportDialog.tsx
new file mode 100644
index 00000000..fbe07823
--- /dev/null
+++ b/frontend/src/components/dialogs/CertificateExportDialog.tsx
@@ -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 (
+
+ )
+}
diff --git a/frontend/src/components/dialogs/CertificateUploadDialog.tsx b/frontend/src/components/dialogs/CertificateUploadDialog.tsx
new file mode 100644
index 00000000..bad87f0c
--- /dev/null
+++ b/frontend/src/components/dialogs/CertificateUploadDialog.tsx
@@ -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(null)
+ const [keyFile, setKeyFile] = useState(null)
+ const [chainFile, setChainFile] = useState(null)
+ const [validationResult, setValidationResult] = useState(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 (
+
+ )
+}
diff --git a/frontend/src/components/dialogs/DeleteCertificateDialog.tsx b/frontend/src/components/dialogs/DeleteCertificateDialog.tsx
index 68491eb6..14e4bf89 100644
--- a/frontend/src/components/dialogs/DeleteCertificateDialog.tsx
+++ b/frontend/src/components/dialogs/DeleteCertificateDialog.tsx
@@ -45,7 +45,7 @@ export default function DeleteCertificateDialog({
{t('certificates.deleteTitle')}
- {certificate.name || certificate.domain}
+ {certificate.name || certificate.domains}
@@ -59,7 +59,7 @@ export default function DeleteCertificateDialog({
- {t('certificates.domain')}
- - {certificate.domain}
+ - {certificate.domains}
- {t('certificates.status')}
- {certificate.status}
- {t('certificates.provider')}
diff --git a/frontend/src/components/dialogs/__tests__/BulkDeleteCertificateDialog.test.tsx b/frontend/src/components/dialogs/__tests__/BulkDeleteCertificateDialog.test.tsx
index 535074f8..20c7e56a 100644
--- a/frontend/src/components/dialogs/__tests__/BulkDeleteCertificateDialog.test.tsx
+++ b/frontend/src/components/dialogs/__tests__/BulkDeleteCertificateDialog.test.tsx
@@ -7,20 +7,22 @@ import BulkDeleteCertificateDialog from '../../dialogs/BulkDeleteCertificateDial
import type { Certificate } from '../../../api/certificates'
const makeCert = (overrides: Partial): 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(
({
}))
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', () => {
diff --git a/frontend/src/components/ui/FileDropZone.tsx b/frontend/src/components/ui/FileDropZone.tsx
new file mode 100644
index 00000000..19fb23b1
--- /dev/null
+++ b/frontend/src/components/ui/FileDropZone.tsx
@@ -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(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) => {
+ 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 (
+
+
+
+
+
+ {file ? (
+
+
+
+ {file.name}
+
+ {formatBadge && (
+
+ {formatBadge}
+
+ )}
+
+ ) : (
+
+
+ {t('certificates.dropFileHere')}
+
+ )}
+
+
+ )
+}
diff --git a/frontend/src/hooks/useCertificates.ts b/frontend/src/hooks/useCertificates.ts
index 228c3c7d..540280f0 100644
--- a/frontend/src/hooks/useCertificates.ts
+++ b/frontend/src/hooks/useCertificates.ts
@@ -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'] })
+ },
+ })
+}
diff --git a/frontend/src/locales/en/translation.json b/frontend/src/locales/en/translation.json
index 592a502f..56e5dce0 100644
--- a/frontend/src/locales/en/translation.json
+++ b/frontend/src/locales/en/translation.json
@@ -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",
diff --git a/frontend/src/pages/Certificates.tsx b/frontend/src/pages/Certificates.tsx
index 87f2bed7..472c6039 100644
--- a/frontend/src/pages/Certificates.tsx
+++ b/frontend/src/pages/Certificates.tsx
@@ -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(null)
- const [keyFile, setKeyFile] = useState(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 = (
-