fix: add DeleteCertificateDialog component with confirmation dialog for certificate deletion

- Implement DeleteCertificateDialog component to handle certificate deletion confirmation.
- Add tests for DeleteCertificateDialog covering various scenarios including rendering, confirmation, and cancellation.
- Update translation files for multiple languages to include new strings related to certificate deletion.
- Create end-to-end tests for certificate deletion UX, including button visibility, confirmation dialog, and success/failure scenarios.
This commit is contained in:
GitHub Actions
2026-03-22 13:25:15 +00:00
parent 2c9c791ae5
commit 441864be95
18 changed files with 1821 additions and 647 deletions

View File

@@ -1,37 +1,57 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Trash2, ChevronUp, ChevronDown } from 'lucide-react'
import { useState, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { LoadingSpinner, ConfigReloadOverlay } from './LoadingStates'
import { createBackup } from '../api/backups'
import { deleteCertificate } from '../api/certificates'
import DeleteCertificateDialog from './dialogs/DeleteCertificateDialog'
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 { 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 {
return hosts.some(h => (h.certificate_id ?? h.certificate?.id) === cert.id)
}
export function isDeletable(cert: Certificate, hosts: ProxyHost[]): boolean {
if (!cert.id) return false
if (isInUse(cert, hosts)) return false
return (
cert.provider === 'custom' ||
cert.provider === 'letsencrypt-staging' ||
cert.status === 'expired'
)
}
export default function CertificateList() {
const { certificates, isLoading, error } = useCertificates()
const { hosts } = useProxyHosts()
const queryClient = useQueryClient()
const { t } = useTranslation()
const [sortColumn, setSortColumn] = useState<SortColumn>('name')
const [sortDirection, setSortDirection] = useState<SortDirection>('asc')
const [certToDelete, setCertToDelete] = useState<Certificate | null>(null)
const deleteMutation = useMutation({
// Perform backup before actual deletion
mutationFn: async (id: number) => {
await createBackup()
await deleteCertificate(id)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['certificates'] })
queryClient.invalidateQueries({ queryKey: ['proxyHosts'] })
toast.success('Certificate deleted')
toast.success(t('certificates.deleteSuccess'))
setCertToDelete(null)
},
onError: (error: Error) => {
toast.error(`Failed to delete certificate: ${error.message}`)
toast.error(`${t('certificates.deleteFailed')}: ${error.message}`)
setCertToDelete(null)
},
})
@@ -142,34 +162,46 @@ export default function CertificateList() {
<StatusBadge status={cert.status} />
</td>
<td className="px-6 py-4">
{cert.id && (cert.provider === 'custom' || cert.issuer?.toLowerCase().includes('staging')) && (
<button
onClick={() => {
// Determine if certificate is in use by any proxy host
const inUse = hosts.some(h => {
const cid = h.certificate_id ?? h.certificate?.id
return cid === cert.id
})
{(() => {
const inUse = isInUse(cert, hosts)
const deletable = isDeletable(cert, hosts)
if (inUse) {
toast.error('Certificate cannot be deleted because it is in use by a proxy host')
return
}
if (cert.id && inUse && (cert.provider === 'custom' || cert.provider === 'letsencrypt-staging' || cert.status === 'expired')) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
aria-disabled="true"
aria-label={t('certificates.deleteTitle')}
className="text-red-400/40 cursor-not-allowed transition-colors"
onClick={(e) => e.preventDefault()}
>
<Trash2 className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent>
{t('certificates.deleteInUse')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
// Allow deletion for custom/staging certs not in use (status check removed)
const message = cert.provider === 'custom'
? 'Are you sure you want to delete this certificate? This will create a backup before deleting.'
: 'Delete this staging certificate? It will be regenerated on next request.'
if (confirm(message)) {
deleteMutation.mutate(cert.id!)
}
}}
className="text-red-400 hover:text-red-300 transition-colors"
title={cert.provider === 'custom' ? 'Delete Certificate' : 'Delete Staging Certificate'}
>
<Trash2 className="w-4 h-4" />
</button>
)}
if (deletable) {
return (
<button
onClick={() => setCertToDelete(cert)}
className="text-red-400 hover:text-red-300 transition-colors"
aria-label={t('certificates.deleteTitle')}
>
<Trash2 className="w-4 h-4" />
</button>
)
}
return null
})()}
</td>
</tr>
))
@@ -178,6 +210,17 @@ export default function CertificateList() {
</table>
</div>
</div>
<DeleteCertificateDialog
certificate={certToDelete}
open={certToDelete !== null}
onConfirm={() => {
if (certToDelete?.id) {
deleteMutation.mutate(certToDelete.id)
}
}}
onCancel={() => setCertToDelete(null)}
isDeleting={deleteMutation.isPending}
/>
</>
)
}

View File

@@ -1,12 +1,12 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { render, screen, waitFor } from '@testing-library/react'
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 { createTestQueryClient } from '../../test/createTestQueryClient'
import CertificateList from '../CertificateList'
import CertificateList, { isDeletable, isInUse } from '../CertificateList'
import type { Certificate } from '../../api/certificates'
import type { ProxyHost } from '../../api/proxyHosts'
@@ -23,6 +23,13 @@ vi.mock('../../api/backups', () => ({
createBackup: vi.fn(async () => ({ filename: 'backup-cert' })),
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: { language: 'en', changeLanguage: vi.fn() },
}),
}))
vi.mock('../../hooks/useProxyHosts', () => ({
useProxyHosts: vi.fn(),
}))
@@ -42,6 +49,8 @@ const createCertificatesValue = (overrides: Partial<ReturnType<typeof useCertifi
{ 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' },
]
return {
@@ -107,58 +116,122 @@ beforeEach(() => {
})
describe('CertificateList', () => {
it('deletes custom certificate when confirmed', async () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => true)
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)
})
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)
})
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)
})
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)
})
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)
})
it('returns false 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(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 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('renders delete button for deletable certs', async () => {
renderWithClient(<CertificateList />)
const rows = await screen.findAllByRole('row')
const customRow = rows.find(r => r.querySelector('td')?.textContent?.includes('CustomCert'))!
expect(within(customRow).getByRole('button', { name: 'certificates.deleteTitle' })).toBeInTheDocument()
})
it('renders delete button for expired LE cert not in use', async () => {
renderWithClient(<CertificateList />)
const rows = await screen.findAllByRole('row')
const expiredLeRow = rows.find(r => r.querySelector('td')?.textContent?.includes('ExpiredLE'))!
expect(within(expiredLeRow).getByRole('button', { name: 'certificates.deleteTitle' })).toBeInTheDocument()
})
it('renders aria-disabled delete button for in-use cert', async () => {
renderWithClient(<CertificateList />)
const rows = await screen.findAllByRole('row')
const activeRow = rows.find(r => r.querySelector('td')?.textContent?.includes('ActiveCert'))!
const btn = within(activeRow).getByRole('button', { name: 'certificates.deleteTitle' })
expect(btn).toHaveAttribute('aria-disabled', 'true')
})
it('hides delete button for valid production LE cert', async () => {
renderWithClient(<CertificateList />)
const rows = await screen.findAllByRole('row')
const validLeRow = rows.find(r => r.querySelector('td')?.textContent?.includes('ValidLE'))!
expect(within(validLeRow).queryByRole('button', { name: 'certificates.deleteTitle' })).not.toBeInTheDocument()
})
it('opens dialog and deletes cert on confirm', async () => {
const { deleteCertificate } = await import('../../api/certificates')
const { createBackup } = await import('../../api/backups')
const { toast } = await import('../../utils/toast')
const user = userEvent.setup()
renderWithClient(<CertificateList />)
const rows = await screen.findAllByRole('row')
const customRow = rows.find(r => r.querySelector('td')?.textContent?.includes('CustomCert')) as HTMLElement
expect(customRow).toBeTruthy()
const customBtn = customRow.querySelector('button[title="Delete Certificate"]') as HTMLButtonElement
expect(customBtn).toBeTruthy()
await user.click(customBtn)
const customRow = rows.find(r => r.querySelector('td')?.textContent?.includes('CustomCert'))!
await user.click(within(customRow).getByRole('button', { name: 'certificates.deleteTitle' }))
await waitFor(() => expect(createBackup).toHaveBeenCalled())
const dialog = await screen.findByRole('dialog')
expect(dialog).toBeInTheDocument()
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(toast.success).toHaveBeenCalledWith('Certificate deleted'))
confirmSpy.mockRestore()
})
it('deletes staging certificate when confirmed', async () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => true)
const { deleteCertificate } = await import('../../api/certificates')
const user = userEvent.setup()
renderWithClient(<CertificateList />)
const stagingButtons = await screen.findAllByTitle('Delete Staging Certificate')
expect(stagingButtons.length).toBeGreaterThan(0)
await user.click(stagingButtons[0])
await waitFor(() => expect(deleteCertificate).toHaveBeenCalledWith(2))
confirmSpy.mockRestore()
})
it('deletes valid custom certificate when not in use', async () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => true)
const { deleteCertificate } = await import('../../api/certificates')
it('does not call createBackup on delete (server handles it)', async () => {
const { createBackup } = await import('../../api/backups')
const user = userEvent.setup()
renderWithClient(<CertificateList />)
const rows = await screen.findAllByRole('row')
const unusedRow = rows.find(r => r.querySelector('td')?.textContent?.includes('UnusedValidCert')) as HTMLElement
expect(unusedRow).toBeTruthy()
const unusedButton = unusedRow.querySelector('button[title="Delete Certificate"]') as HTMLButtonElement
expect(unusedButton).toBeTruthy()
await user.click(unusedButton)
const customRow = rows.find(r => r.querySelector('td')?.textContent?.includes('CustomCert'))!
await user.click(within(customRow).getByRole('button', { name: 'certificates.deleteTitle' }))
await waitFor(() => expect(createBackup).toHaveBeenCalled())
await waitFor(() => expect(deleteCertificate).toHaveBeenCalledWith(4))
confirmSpy.mockRestore()
const dialog = await screen.findByRole('dialog')
await user.click(within(dialog).getByRole('button', { name: 'certificates.deleteButton' }))
await waitFor(() => expect(createBackup).not.toHaveBeenCalled())
})
it('renders empty state when no certificates exist', async () => {

View File

@@ -0,0 +1,80 @@
import { AlertTriangle } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '../ui/Button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '../ui/Dialog'
import type { Certificate } from '../../api/certificates'
interface DeleteCertificateDialogProps {
certificate: Certificate | null
open: boolean
onConfirm: () => void
onCancel: () => void
isDeleting: boolean
}
function getWarningKey(cert: Certificate): string {
if (cert.status === 'expired') return 'certificates.deleteConfirmExpired'
if (cert.provider === 'letsencrypt-staging') return 'certificates.deleteConfirmStaging'
return 'certificates.deleteConfirmCustom'
}
export default function DeleteCertificateDialog({
certificate,
open,
onConfirm,
onCancel,
isDeleting,
}: DeleteCertificateDialogProps) {
const { t } = useTranslation()
if (!certificate) return null
return (
<Dialog open={open} onOpenChange={(isOpen) => { if (!isOpen) onCancel() }}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('certificates.deleteTitle')}</DialogTitle>
<DialogDescription>
{certificate.name || certificate.domain}
</DialogDescription>
</DialogHeader>
<div className="px-6 space-y-4">
<div className="flex items-start gap-3 rounded-lg border border-red-900/50 bg-red-900/10 p-4">
<AlertTriangle className="h-5 w-5 shrink-0 text-red-400 mt-0.5" />
<p className="text-sm text-gray-300">
{t(getWarningKey(certificate))}
</p>
</div>
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm">
<dt className="text-gray-500">{t('certificates.domain')}</dt>
<dd className="text-white">{certificate.domain}</dd>
<dt className="text-gray-500">{t('certificates.status')}</dt>
<dd className="text-white capitalize">{certificate.status}</dd>
<dt className="text-gray-500">{t('certificates.provider')}</dt>
<dd className="text-white">{certificate.provider}</dd>
</dl>
</div>
<DialogFooter>
<Button variant="secondary" onClick={onCancel} disabled={isDeleting}>
{t('common.cancel')}
</Button>
<Button variant="danger" onClick={onConfirm} isLoading={isDeleting}>
{t('certificates.deleteButton')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,128 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'
import DeleteCertificateDialog from '../../dialogs/DeleteCertificateDialog'
import type { Certificate } from '../../../api/certificates'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: { language: 'en', changeLanguage: vi.fn() },
}),
}))
const baseCert: Certificate = {
id: 1,
name: 'Test Cert',
domain: 'test.example.com',
issuer: 'Custom CA',
expires_at: '2026-01-01T00:00:00Z',
status: 'valid',
provider: 'custom',
}
describe('DeleteCertificateDialog', () => {
it('renders warning text for custom cert', () => {
render(
<DeleteCertificateDialog
certificate={baseCert}
open={true}
onConfirm={vi.fn()}
onCancel={vi.fn()}
isDeleting={false}
/>
)
expect(screen.getByText('certificates.deleteConfirmCustom')).toBeInTheDocument()
expect(screen.getByText('certificates.deleteTitle')).toBeInTheDocument()
})
it('renders warning text for staging cert', () => {
const staging: Certificate = { ...baseCert, provider: 'letsencrypt-staging', status: 'untrusted' }
render(
<DeleteCertificateDialog
certificate={staging}
open={true}
onConfirm={vi.fn()}
onCancel={vi.fn()}
isDeleting={false}
/>
)
expect(screen.getByText('certificates.deleteConfirmStaging')).toBeInTheDocument()
})
it('renders warning text for expired cert', () => {
const expired: Certificate = { ...baseCert, provider: 'letsencrypt', status: 'expired' }
render(
<DeleteCertificateDialog
certificate={expired}
open={true}
onConfirm={vi.fn()}
onCancel={vi.fn()}
isDeleting={false}
/>
)
expect(screen.getByText('certificates.deleteConfirmExpired')).toBeInTheDocument()
})
it('calls onCancel when Cancel is clicked', async () => {
const onCancel = vi.fn()
const user = userEvent.setup()
render(
<DeleteCertificateDialog
certificate={baseCert}
open={true}
onConfirm={vi.fn()}
onCancel={onCancel}
isDeleting={false}
/>
)
await user.click(screen.getByRole('button', { name: 'common.cancel' }))
expect(onCancel).toHaveBeenCalled()
})
it('calls onConfirm when Delete is clicked', async () => {
const onConfirm = vi.fn()
const user = userEvent.setup()
render(
<DeleteCertificateDialog
certificate={baseCert}
open={true}
onConfirm={onConfirm}
onCancel={vi.fn()}
isDeleting={false}
/>
)
await user.click(screen.getByRole('button', { name: 'certificates.deleteButton' }))
expect(onConfirm).toHaveBeenCalled()
})
it('renders nothing when certificate is null', () => {
const { container } = render(
<DeleteCertificateDialog
certificate={null}
open={true}
onConfirm={vi.fn()}
onCancel={vi.fn()}
isDeleting={false}
/>
)
expect(container.innerHTML).toBe('')
})
it('renders expired warning for expired staging cert (priority ordering)', () => {
const expiredStaging: Certificate = { ...baseCert, provider: 'letsencrypt-staging', status: 'expired' }
render(
<DeleteCertificateDialog
certificate={expiredStaging}
open={true}
onConfirm={vi.fn()}
onCancel={vi.fn()}
isDeleting={false}
/>
)
expect(screen.getByText('certificates.deleteConfirmExpired')).toBeInTheDocument()
expect(screen.queryByText('certificates.deleteConfirmStaging')).not.toBeInTheDocument()
})
})