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:
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
80
frontend/src/components/dialogs/DeleteCertificateDialog.tsx
Normal file
80
frontend/src/components/dialogs/DeleteCertificateDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -173,7 +173,16 @@
|
||||
"uploadSuccess": "Zertifikat erfolgreich hochgeladen",
|
||||
"uploadFailed": "Fehler beim Hochladen des Zertifikats",
|
||||
"note": "Hinweis",
|
||||
"noteText": "Sie können benutzerdefinierte Zertifikate und Staging-Zertifikate löschen. Produktions-Let's-Encrypt-Zertifikate werden automatisch erneuert und sollten nur beim Umgebungswechsel gelöscht werden."
|
||||
"noteText": "You can delete custom certificates, staging certificates, and expired production certificates that are not attached to any proxy host. Active production certificates are automatically renewed by Caddy.",
|
||||
"provider": "Provider",
|
||||
"deleteTitle": "Delete Certificate",
|
||||
"deleteConfirmCustom": "This will permanently delete this certificate. A backup will be created first.",
|
||||
"deleteConfirmStaging": "This staging certificate will be removed. It will be regenerated on next request.",
|
||||
"deleteConfirmExpired": "This expired certificate is no longer active and will be permanently removed.",
|
||||
"deleteSuccess": "Certificate deleted",
|
||||
"deleteFailed": "Failed to delete certificate",
|
||||
"deleteInUse": "Cannot delete — certificate is attached to a proxy host",
|
||||
"deleteButton": "Delete"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Anmelden",
|
||||
|
||||
@@ -182,7 +182,16 @@
|
||||
"uploadSuccess": "Certificate uploaded successfully",
|
||||
"uploadFailed": "Failed to upload certificate",
|
||||
"note": "Note",
|
||||
"noteText": "You can delete custom certificates and staging certificates. Production Let's Encrypt certificates are automatically renewed and should not be deleted unless switching environments."
|
||||
"noteText": "You can delete custom certificates, staging certificates, and expired production certificates that are not attached to any proxy host. Active production certificates are automatically renewed by Caddy.",
|
||||
"provider": "Provider",
|
||||
"deleteTitle": "Delete Certificate",
|
||||
"deleteConfirmCustom": "This will permanently delete this certificate. A backup will be created first.",
|
||||
"deleteConfirmStaging": "This staging certificate will be removed. It will be regenerated on next request.",
|
||||
"deleteConfirmExpired": "This expired certificate is no longer active and will be permanently removed.",
|
||||
"deleteSuccess": "Certificate deleted",
|
||||
"deleteFailed": "Failed to delete certificate",
|
||||
"deleteInUse": "Cannot delete — certificate is attached to a proxy host",
|
||||
"deleteButton": "Delete"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Login",
|
||||
|
||||
@@ -173,7 +173,16 @@
|
||||
"uploadSuccess": "Certificado subido exitosamente",
|
||||
"uploadFailed": "Error al subir el certificado",
|
||||
"note": "Nota",
|
||||
"noteText": "Puedes eliminar certificados personalizados y certificados de prueba. Los certificados de Let's Encrypt de producción se renuevan automáticamente y no deben eliminarse a menos que cambies de entorno."
|
||||
"noteText": "You can delete custom certificates, staging certificates, and expired production certificates that are not attached to any proxy host. Active production certificates are automatically renewed by Caddy.",
|
||||
"provider": "Provider",
|
||||
"deleteTitle": "Delete Certificate",
|
||||
"deleteConfirmCustom": "This will permanently delete this certificate. A backup will be created first.",
|
||||
"deleteConfirmStaging": "This staging certificate will be removed. It will be regenerated on next request.",
|
||||
"deleteConfirmExpired": "This expired certificate is no longer active and will be permanently removed.",
|
||||
"deleteSuccess": "Certificate deleted",
|
||||
"deleteFailed": "Failed to delete certificate",
|
||||
"deleteInUse": "Cannot delete — certificate is attached to a proxy host",
|
||||
"deleteButton": "Delete"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Iniciar Sesión",
|
||||
|
||||
@@ -173,7 +173,16 @@
|
||||
"uploadSuccess": "Certificat téléversé avec succès",
|
||||
"uploadFailed": "Échec du téléversement du certificat",
|
||||
"note": "Note",
|
||||
"noteText": "Vous pouvez supprimer les certificats personnalisés et les certificats de test. Les certificats Let's Encrypt de production sont renouvelés automatiquement et ne doivent pas être supprimés sauf en cas de changement d'environnement."
|
||||
"noteText": "You can delete custom certificates, staging certificates, and expired production certificates that are not attached to any proxy host. Active production certificates are automatically renewed by Caddy.",
|
||||
"provider": "Provider",
|
||||
"deleteTitle": "Delete Certificate",
|
||||
"deleteConfirmCustom": "This will permanently delete this certificate. A backup will be created first.",
|
||||
"deleteConfirmStaging": "This staging certificate will be removed. It will be regenerated on next request.",
|
||||
"deleteConfirmExpired": "This expired certificate is no longer active and will be permanently removed.",
|
||||
"deleteSuccess": "Certificate deleted",
|
||||
"deleteFailed": "Failed to delete certificate",
|
||||
"deleteInUse": "Cannot delete — certificate is attached to a proxy host",
|
||||
"deleteButton": "Delete"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Connexion",
|
||||
|
||||
@@ -173,7 +173,16 @@
|
||||
"uploadSuccess": "证书上传成功",
|
||||
"uploadFailed": "证书上传失败",
|
||||
"note": "注意",
|
||||
"noteText": "您可以删除自定义证书和测试证书。生产环境的Let's Encrypt证书会自动续期,除非切换环境否则不应删除。"
|
||||
"noteText": "You can delete custom certificates, staging certificates, and expired production certificates that are not attached to any proxy host. Active production certificates are automatically renewed by Caddy.",
|
||||
"provider": "Provider",
|
||||
"deleteTitle": "Delete Certificate",
|
||||
"deleteConfirmCustom": "This will permanently delete this certificate. A backup will be created first.",
|
||||
"deleteConfirmStaging": "This staging certificate will be removed. It will be regenerated on next request.",
|
||||
"deleteConfirmExpired": "This expired certificate is no longer active and will be permanently removed.",
|
||||
"deleteSuccess": "Certificate deleted",
|
||||
"deleteFailed": "Failed to delete certificate",
|
||||
"deleteInUse": "Cannot delete — certificate is attached to a proxy host",
|
||||
"deleteButton": "Delete"
|
||||
},
|
||||
"auth": {
|
||||
"login": "登录",
|
||||
|
||||
Reference in New Issue
Block a user