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 ? ( +
+ +
+
{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 && ( +
+
+ )} + + {result.errors.length > 0 && ( +
+
+ )} +
+ ) +} 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 ( + + + + {t('certificates.detailTitle')} + + + {isLoading && ( +
      +
      + )} + + {detail && ( +
      +
      +
      +
      {t('certificates.friendlyName')}
      +
      {detail.name || '-'}
      + +
      {t('certificates.commonName')}
      +
      {detail.common_name || '-'}
      + +
      {t('certificates.domains')}
      +
      {detail.domains || '-'}
      + +
      {t('certificates.issuerOrg')}
      +
      {detail.issuer_org || detail.issuer || '-'}
      + +
      {t('certificates.fingerprint')}
      +
      + {detail.fingerprint || '-'} +
      + +
      {t('certificates.serialNumber')}
      +
      + {detail.serial_number || '-'} +
      + +
      {t('certificates.keyType')}
      +
      {detail.key_type || '-'}
      + +
      {t('certificates.status')}
      +
      {detail.status}
      + +
      {t('certificates.provider')}
      +
      {detail.provider}
      + +
      {t('certificates.notBefore')}
      +
      + {detail.not_before ? new Date(detail.not_before).toLocaleDateString() : '-'} +
      + +
      {t('certificates.expiresAt')}
      +
      + {detail.expires_at ? new Date(detail.expires_at).toLocaleDateString() : '-'} +
      + +
      {t('certificates.autoRenew')}
      +
      + {detail.auto_renew ? t('common.yes') : t('common.no')} +
      + +
      {t('certificates.createdAt')}
      +
      + {detail.created_at ? new Date(detail.created_at).toLocaleDateString() : '-'} +
      + +
      {t('certificates.updatedAt')}
      +
      + {detail.updated_at ? new Date(detail.updated_at).toLocaleDateString() : '-'} +
      +
      +
      + +
      +

      + {t('certificates.assignedHosts')} +

      + {detail.assigned_hosts?.length > 0 ? ( +
        + {detail.assigned_hosts.map((host) => ( +
      • + {host.name} + {host.domain_names} +
      • + ))} +
      + ) : ( +

      + {t('certificates.noAssignedHosts')} +

      + )} +
      + +
      +

      + {t('certificates.certificateChain')} +

      + +
      +
      + )} +
      +
      + ) +} 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 ( + + + + + + + +
      +
      + +
      + {FORMAT_OPTIONS.map((opt) => ( + + ))} +
      +
      + + {certificate?.has_key && ( +
      + 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" + /> +
      + + {includeKey && ( +

      + {t('certificates.includePrivateKeyWarning')} +

      + )} +
      +
      + )} + + {includeKey && ( + setPassword(e.target.value)} + required + aria-required="true" + autoComplete="current-password" + /> + )} + + {format === 'pfx' && ( + setPfxPassword(e.target.value)} + autoComplete="off" + /> + )} + + + + + +
      +
      +
      + ) +} 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 ( + + + + {t('certificates.uploadCertificate')} + +
      + setName(e.target.value)} + placeholder="e.g. My Custom Cert" + required + aria-required="true" + /> + + { + setCertFile(f) + setValidationResult(null) + }} + required + formatBadge={certFormat} + /> + + {isPfx && ( +

      + {t('certificates.pfxDetected')} +

      + )} + + {!isPfx && ( + <> + { + setKeyFile(f) + setValidationResult(null) + }} + /> + + { + setChainFile(f) + setValidationResult(null) + }} + /> + + )} + + {certFile && !validationResult && ( + + )} + + {validationResult && ( + + )} + + + + + + +
      +
      + ) +} 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 ? ( +
      +
      + ) : ( +
      +
      + )} +
      +
      + ) +} 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 = ( - ) @@ -70,56 +30,7 @@ export default function Certificates() { - {/* Upload Certificate Dialog */} - - - - {t('certificates.uploadCertificate')} - -
      - setName(e.target.value)} - placeholder="e.g. My Custom Cert" - required - /> -
      - - 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 - /> -
      -
      - - 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 - /> -
      - - - - -
      -
      -
      + ) } diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 878fee0c..b5c2715e 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -44,8 +44,8 @@ export default function Dashboard() { const certifiedDomains = new Set() 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) } diff --git a/frontend/src/pages/ProxyHosts.tsx b/frontend/src/pages/ProxyHosts.tsx index 869f9957..59d1873f 100644 --- a/frontend/src/pages/ProxyHosts.tsx +++ b/frontend/src/pages/ProxyHosts.tsx @@ -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>(new Set()) @@ -103,7 +103,7 @@ export default function ProxyHosts() { const certStatusByDomain = useMemo(() => { const map: Record = {} 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 = new Map() + const certsToConsider: Map = 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 }) diff --git a/frontend/src/pages/__tests__/Certificates.test.tsx b/frontend/src/pages/__tests__/Certificates.test.tsx index c211726b..d86bc817 100644 --- a/frontend/src/pages/__tests__/Certificates.test.tsx +++ b/frontend/src/pages/__tests__/Certificates.test.tsx @@ -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 = { - '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) => { - 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: () =>
      CertificateList
      , + default: () =>
      CertificateList
      , })) -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 ? ( +
      + +
      + ) : 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() - 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() + 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() - 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() - 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() + expect(screen.getByText('certificates.noteText')).toBeInTheDocument() }) }) diff --git a/frontend/src/pages/__tests__/ProxyHosts-coverage.test.tsx b/frontend/src/pages/__tests__/ProxyHosts-coverage.test.tsx index 93647bb0..b25ed93e 100644 --- a/frontend/src/pages/__tests__/ProxyHosts-coverage.test.tsx +++ b/frontend/src/pages/__tests__/ProxyHosts-coverage.test.tsx @@ -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({}) diff --git a/frontend/src/pages/__tests__/ProxyHosts-extra.test.tsx b/frontend/src/pages/__tests__/ProxyHosts-extra.test.tsx index 8337fbc2..386c89e2 100644 --- a/frontend/src/pages/__tests__/ProxyHosts-extra.test.tsx +++ b/frontend/src/pages/__tests__/ProxyHosts-extra.test.tsx @@ -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, }, ], }),