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()
})
})

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "登录",