- 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.
21 KiB
Certificate Deletion Feature — Spec
Date: 2026-03-22 Priority: Medium Type: User Requested Feature Status: Approved — Supervisor Reviewed 2026-03-22
1. Introduction
Overview
Users accumulate expired and orphaned certificates in the Certificates UI over time. Currently,
the delete button is only shown for custom (manually uploaded) and staging certificates. Expired
production Let's Encrypt certificates that are no longer attached to any proxy host cannot be
removed, creating UI clutter and user confusion.
Objectives
- Allow deletion of expired certificates that are not attached to any proxy host.
- Allow deletion of custom (manually uploaded) certificates that are not attached to any proxy host, regardless of expiry status (already partially implemented).
- Allow deletion of staging certificates that are not attached to any proxy host (already partially implemented).
- Prevent deletion of any certificate currently attached to a proxy host.
- Replace the native
confirm()dialog with an accessible, themed confirmation dialog. - Provide clear visual feedback on why a certificate can or cannot be deleted.
Non-Goals
- Bulk certificate deletion (separate feature).
- Auto-cleanup / scheduled pruning of expired certificates.
- Changes to certificate auto-renewal logic.
2. Research Findings
2.1 Existing Backend Infrastructure
The backend already has complete delete support:
| Component | File | Status |
|---|---|---|
| Model | backend/internal/models/ssl_certificate.go |
SSLCertificate struct with Provider ("letsencrypt", "letsencrypt-staging", "custom"), ExpiresAt fields |
| Service | backend/internal/services/certificate_service.go |
DeleteCertificate(id), IsCertificateInUse(id) — fully implemented |
| Handler | backend/internal/api/handlers/certificate_handler.go |
Delete() — validates in-use, creates backup, deletes, sends notification |
| Route | backend/internal/api/routes/routes.go:673 |
DELETE /api/v1/certificates/:id — already registered |
| Error | backend/internal/services/certificate_service.go:23 |
ErrCertInUse sentinel error defined |
| Tests | backend/internal/api/handlers/certificate_handler_test.go |
Tests for in-use, backup, backup failure, auth, invalid ID, not found |
Key finding: The backend imposes NO provider or expiry restrictions on deletion. Any certificate
can be deleted as long as it is not referenced by a proxy host (certificate_id FK). The
backend is already correct for the requested feature.
2.2 Existing Frontend Infrastructure
| Component | File | Status |
|---|---|---|
| API client | frontend/src/api/certificates.ts |
deleteCertificate(id) — exists |
| Hook | frontend/src/hooks/useCertificates.ts |
useCertificates() — react-query based |
| List component | frontend/src/components/CertificateList.tsx |
Delete button and mutation — exists but gated incorrectly |
| Page | frontend/src/pages/Certificates.tsx |
Upload dialog only |
| Cleanup dialog | frontend/src/components/dialogs/CertificateCleanupDialog.tsx |
Used for proxy host deletion cleanup — not for standalone cert deletion |
| i18n | frontend/src/locales/en/translation.json:168-185 |
Certificate strings — needs new deletion strings |
2.3 Current Delete Button Visibility Logic (The Problem)
In frontend/src/components/CertificateList.tsx:145:
{cert.id && (cert.provider === 'custom' || cert.issuer?.toLowerCase().includes('staging')) && (
This condition excludes expired production Let's Encrypt certificates, which is the core issue. An expired LE cert not attached to any host should be deletable.
2.4 Certificate-to-ProxyHost Relationship
ProxyHost.CertificateID(*uint, nullable FK) →SSLCertificate.ID- Defined in
backend/internal/models/proxy_host.go:24-25 - GORM foreign key:
gorm:"foreignKey:CertificateID" - No cascade delete on the FK — deletion is manually guarded by
IsCertificateInUse() - Frontend checks in-use client-side via
hosts.some(h => h.certificate_id === cert.id)
2.5 Provider Values
| Provider Value | Source | Deletable? |
|---|---|---|
letsencrypt |
Auto-provisioned by Caddy ACME | Only when expired AND not in use |
letsencrypt-staging |
Staging ACME | When not in use (any status) |
custom |
User-uploaded via UI | When not in use (any status) |
Note
: The model comment in
ssl_certificate.golists"self-signed"as a possible provider, but no code path ever writes that value. The actual provider universe isletsencrypt,letsencrypt-staging,custom. The stale comment should be corrected as part of this PR.
Edge Case: expiring LE Cert Not In Use
An expiring Let's Encrypt certificate that is not attached to any proxy host is in limbo —
not expired yet, but no proxy host references it, so no renewal will be triggered. Decision:
accept this as intended behavior. The cert will eventually expire and become deletable. We do
not add expiring to the deletable set because Caddy may still auto-renew certificates
that were previously provisioned, even if no host currently references them.
2.6 Existing UX Issues
- Delete uses native
confirm()— not accessible, not themed. - No tooltip or visual indicator explaining why a cert cannot be deleted.
- The in-use check is duplicated: once client-side before
confirm(), once server-side in the handler. This is fine (defense in depth) but the server is the source of truth.
3. Technical Specifications
3.1 Backend Changes
No backend code changes required. The existing DELETE /api/v1/certificates/:id endpoint
already:
- Validates the certificate exists
- Checks
IsCertificateInUse()and returns409 Conflictif in use - Creates a backup before deletion
- Deletes the DB record (and ACME files for LE certs)
- Invalidates the cert cache
- Sends a notification
- Returns
200 OKon success
The backend does not restrict by provider or expiry — all deletion policy is enforced by the frontend's visibility of the delete button and confirmed server-side by the in-use check.
3.2 Frontend Changes
3.2.1 Delete Button Visibility — CertificateList.tsx
Replace the current delete button condition with new business logic:
isDeletable(cert, hosts) =
cert.id exists
AND NOT isInUse(cert, hosts)
AND (
cert.provider === 'custom'
OR cert.provider === 'letsencrypt-staging'
OR cert.status === 'expired'
)
Where isInUse(cert, hosts) checks:
hosts.some(h => (h.certificate_id ?? h.certificate?.id) === cert.id)
In plain terms:
- Custom / staging certs: deletable if not in use (any expiry status).
- Production LE certs: deletable only if expired AND not in use.
- Any cert in use by a proxy host: NOT deletable, regardless of status.
Important
: Use
cert.provider === 'letsencrypt-staging'for staging detection — notcert.issuer?.toLowerCase().includes('staging'). Theproviderfield is the canonical, authoritative classification. Issuer-based checks are fragile and may break if the ACME issuer string changes.
3.2.2 Confirmation Dialog — New DeleteCertificateDialog.tsx
Create frontend/src/components/dialogs/DeleteCertificateDialog.tsx:
- Reuse the existing
Dialog,DialogContent,DialogHeader,DialogTitle,DialogFooter,ButtonUI components fromfrontend/src/components/ui. - Show certificate name, domain, status, and provider.
- Warning text varies by cert type:
- Custom: "This will permanently delete this certificate. A backup will be created first."
- Staging: "This staging certificate will be removed. It will be regenerated on next request."
- Expired LE: "This expired certificate is no longer active and will be permanently removed."
- Two buttons: Cancel (secondary) and Delete (destructive).
- Props:
certificate: Certificate | null,onConfirm: () => void,onCancel: () => void,open: boolean,isDeleting: boolean. - Keyboard accessible: focus trap, Escape to close, Enter on Delete button.
3.2.3 Disabled Delete Button with Tooltip
When a certificate is in use by a proxy host, render the delete button as aria-disabled="true"
(not HTML disabled) with a Radix Tooltip explaining why. Using aria-disabled keeps the
button focusable, which is required for the tooltip to appear on hover/focus.
Use the existing Radix-based Tooltip component from frontend/src/components/ui/Tooltip.tsx
(Tooltip, TooltipTrigger, TooltipContent exports).
Tooltip text: "Cannot delete — certificate is attached to a proxy host".
When a production LE cert is valid/expiring (not expired) and not in use, do not show the delete button at all. Production LE certs in active use are auto-managed.
3.2.4 i18n Translation Keys
Add to frontend/src/locales/en/translation.json under "certificates":
"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"
A shared "common.cancel" key already exists — use t('common.cancel') for the Cancel
button instead of a certificate-specific key.
The same keys should be added to all other locale files (de, es, fr, pt) with
placeholder English values (to be translated later).
3.2.5 Data Flow
User clicks delete icon → isDeletable check (client) → open DeleteCertificateDialog
→ User confirms → deleteMutation fires:
1. deleteCertificate(id) → DELETE /api/v1/certificates/:id
→ Handler: IsCertificateInUse check (server)
→ Handler: createBackup (server)
→ Handler: DeleteCertificate (service)
→ Handler: notification
2. Invalidate react-query cache → UI refreshes
Note: Remove the duplicate client-side createBackup() call from the mutation — the server
already creates a backup. Keeping the client-side call creates two backups per deletion.
3.3 Database Considerations
- No schema changes needed. The
ssl_certificatestable andproxy_hosts.certificate_idFK are already correct. - No cascade behavior changes. Deletion is guarded by the in-use check, not by DB cascades.
- The existing backup-before-delete behavior in the handler is sufficient for data safety.
3.4 Security Considerations
- Authorization: The
DELETE /api/v1/certificates/:idroute is under themanagementgroup which requires authentication middleware. No changes needed. - Server-side validation:
IsCertificateInUse()is checked server-side as defense-in-depth, preventing deletion even if the frontend check is bypassed. - ID parameter: The handler uses numeric ID from URL param, validated with
strconv.ParseUint. This prevents injection. - Backup safety: A backup is created before every deletion. Low disk space is checked first (100MB minimum).
3.5 Accessibility Considerations
- New
DeleteCertificateDialogmust use the existingDialogcomponent which already provides focus trap,role="dialog", andaria-modal. - Disabled delete buttons must use
aria-disabled="true"(not HTMLdisabled) to remain focusable. Wrap in the RadixTooltip/TooltipTrigger/TooltipContentfromfrontend/src/components/ui/Tooltip.tsxfor an accessible visible tooltip (not justtitleattribute). - The delete icon button needs
aria-labelfor screen readers.
Known inconsistency: The existing
CertificateCleanupDialoguses a hand-rolled overlay (<div className="fixed inset-0 bg-black/50 ...">) instead of the Radix Dialog component. This is a pre-existing issue — not in scope for this PR. Flagged as a future chore to migrateCertificateCleanupDialogto Radix Dialog for consistency.
3.6 Config/Build File Review
| File | Change Needed? | Notes |
|---|---|---|
.gitignore |
No | No new artifacts to ignore |
codecov.yml |
No | New dialog component will be covered by existing frontend test config |
.dockerignore |
No | No new build inputs |
Dockerfile |
No | Frontend is built into dist/ as part of existing build stage |
4. Implementation Plan
Phase 1: Playwright E2E Tests (Test-First)
File: tests/certificate-delete.spec.ts
Write E2E tests that define expected behavior:
-
Test: Delete button visible for expired cert not in use
- Seed an expired custom cert with no proxy host attachment.
- Navigate to Certificates page.
- Verify delete button is visible for the expired cert row.
-
Test: Delete button visible for custom cert not in use
- Seed a custom cert not attached to any proxy host.
- Verify delete button is visible.
-
Test: Delete button disabled for cert in use
- Seed a cert attached to a proxy host.
- Verify delete button is
aria-disabled="true"with tooltip text.
-
Test: Delete button NOT visible for valid production LE cert
- Seed a valid LE cert not in use.
- Verify no delete button (auto-managed, not expired).
-
Test: Confirmation dialog appears on delete click
- Click delete on a deletable cert.
- Verify dialog opens with cert details and Cancel/Delete buttons.
- Click Cancel, verify dialog closes, cert still exists.
-
Test: Successful deletion flow
- Click delete on a deletable cert.
- Confirm in dialog.
- Verify cert disappears from list.
- Verify success toast appears.
-
Test: In-use cert shows disabled button with tooltip
- Seed a cert in use.
- Verify delete button has
aria-disabled="true"and tooltip is shown on hover.
E2E Seeding Strategy
Certificates are scanned from Caddy's certificate storage, not manually inserted. Tests should seed data by:
- Using the existing API to create proxy hosts with different SSL modes (which triggers cert provisioning by Caddy).
- For expired/custom certs, use the certificate upload API (
POST /api/v1/certificates) with pre-generated test certificates. - For in-use vs. not-in-use states, create/delete proxy host associations via the proxy host API.
- Direct database manipulation is a last resort and should be avoided to keep tests realistic.
Complexity: Low — straightforward UI interaction tests.
Phase 2: Frontend Implementation
Estimated changes: ~3 files modified, 1 file created.
Step 1: Create DeleteCertificateDialog
File: frontend/src/components/dialogs/DeleteCertificateDialog.tsx
Props:
- certificate: Certificate | null (from api/certificates.ts)
- open: boolean
- onConfirm: () => void
- onCancel: () => void
- isDeleting: boolean
Structure:
- Dialog (open, onOpenChange=onCancel)
- DialogContent
- DialogHeader
- DialogTitle: t('certificates.deleteTitle')
- Certificate info: name, domain, status badge, provider
- Warning text (varies by provider/status)
- DialogFooter
- Button (secondary): t('common.cancel')
- Button (destructive, loading=isDeleting): Delete
Step 2: Update CertificateList.tsx
- Extract
isDeletable(cert, hosts)helper function. - Extract
isInUse(cert, hosts)helper function. - Replace the inline delete button condition with
isDeletable(). - Add disabled delete button with tooltip for in-use certs.
- Replace
confirm()withDeleteCertificateDialogstate management:const [certToDelete, setCertToDelete] = useState<Certificate | null>(null)- Open dialog:
setCertToDelete(cert) - Confirm:
deleteMutation.mutate(certToDelete.id) - Cancel/success:
setCertToDelete(null)
- Remove the duplicate client-side
createBackup()call from the mutation — the server already creates a backup. Keeping the client-side call creates two backups per deletion.
Step 3: Add i18n keys
Files: All locale files under frontend/src/locales/*/translation.json
Add the keys from §3.2.4.
Complexity: Low — mostly UI wiring, no new APIs.
Phase 3: Backend Unit Tests (Gap Coverage)
While the backend code needs no changes, add tests for the newly-important scenarios:
File: backend/internal/api/handlers/certificate_handler_test.go
- Test: Delete expired LE cert not in use succeeds — ensures the backend does not block expired LE certs from deletion.
- Test: Delete valid LE cert not in use succeeds — confirms the backend has no provider-based restrictions (policy is frontend-only).
The IsCertificateInUse service-level tests already exist in certificate_service_test.go.
Do not duplicate them. Keep only the handler-level tests above that verify the HTTP layer
behavior for expired LE cert deletion.
Complexity: Low — standard Go table-driven tests.
Phase 4: Frontend Unit Tests
File: frontend/src/components/__tests__/CertificateList.test.tsx
- Test
isDeletable()helper with all provider/status/in-use combinations. - Test that delete button renders for deletable certs.
- Test that delete button is disabled for in-use certs.
- Test that delete button is hidden for valid production LE certs.
File: frontend/src/components/dialogs/__tests__/DeleteCertificateDialog.test.tsx
- Test dialog renders with correct warning text per provider.
- Test Cancel closes dialog.
- Test Delete calls onConfirm.
Complexity: Low.
Phase 5: Documentation
Update frontend/src/locales/en/translation.json key "noteText" to reflect the expanded
deletion policy:
"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."
No other documentation changes needed — the feature is self-explanatory in the UI.
5. Acceptance Criteria
- Expired certificates not attached to any proxy host show a delete button.
- Custom certificates not attached to any proxy host show a delete button.
- Staging certificates not attached to any proxy host show a delete button.
- Certificates attached to a proxy host show a disabled delete button with tooltip.
- Valid production LE certificates not in use do NOT show a delete button.
- Clicking delete opens an accessible confirmation dialog (not native
confirm()). - Dialog shows certificate details and appropriate warning text.
- Confirming deletion removes the certificate and shows a success toast.
- Canceling the dialog does not delete anything.
- Server returns
409 Conflictif the certificate becomes attached between client check and server delete (race condition safety). - A backup is created before each deletion (server-side).
- All new UI elements are keyboard navigable and screen-reader accessible.
- All Playwright E2E tests pass on Firefox, Chromium, and WebKit.
- All new backend unit tests pass.
- All new frontend unit tests pass.
- No regressions in existing certificate or proxy host tests.
6. Commit Slicing Strategy
Decision: Single PR
Rationale: The scope is small (1 new component, 2 modified files, i18n additions, and tests). All changes are tightly coupled — the new dialog component is only meaningful together with the updated delete button logic. Splitting this into multiple PRs would add review overhead without reducing risk.
PR-1: Certificate Deletion UX Enhancement
Scope: All phases (E2E tests, frontend implementation, backend test gaps, frontend unit tests, docs update).
Files:
| File | Action |
|---|---|
tests/certificate-delete.spec.ts |
Create |
frontend/src/components/dialogs/DeleteCertificateDialog.tsx |
Create |
frontend/src/components/dialogs/__tests__/DeleteCertificateDialog.test.tsx |
Create |
frontend/src/components/CertificateList.tsx |
Modify |
frontend/src/components/__tests__/CertificateList.test.tsx |
Modify |
frontend/src/locales/en/translation.json |
Modify |
frontend/src/locales/de/translation.json |
Modify |
frontend/src/locales/es/translation.json |
Modify |
frontend/src/locales/fr/translation.json |
Modify |
frontend/src/locales/pt/translation.json |
Modify |
backend/internal/api/handlers/certificate_handler_test.go |
Modify |
Dependencies: None — the backend API is already complete.
Validation Gates:
go test ./backend/...— all passnpx vitest run— all pass- Playwright E2E on Firefox, Chromium, WebKit — all pass
make lint-fast— no new warnings
Rollback: Revert the single PR. No database migrations to undo. No backend API changes.
Contingency: If E2E tests are flaky due to certificate seed data timing, add explicit
waitFor on the certificate list load state before asserting button visibility.