# 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 1. Allow deletion of **expired** certificates that are not attached to any proxy host. 2. Allow deletion of **custom** (manually uploaded) certificates that are not attached to any proxy host, regardless of expiry status (already partially implemented). 3. Allow deletion of **staging** certificates that are not attached to any proxy host (already partially implemented). 4. **Prevent deletion** of any certificate currently attached to a proxy host. 5. Replace the native `confirm()` dialog with an accessible, themed confirmation dialog. 6. 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`: ```tsx {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.go` lists `"self-signed"` as a possible > provider, but no code path ever writes that value. The actual provider universe is > `letsencrypt`, `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 1. Delete uses native `confirm()` — not accessible, not themed. 2. No tooltip or visual indicator explaining why a cert cannot be deleted. 3. 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 returns `409 Conflict` if 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 OK` on 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 — not > `cert.issuer?.toLowerCase().includes('staging')`. The `provider` field 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`, `Button` UI components from `frontend/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"`: ```json "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_certificates` table and `proxy_hosts.certificate_id` FK 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/:id` route is under the `management` group 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 `DeleteCertificateDialog` must use the existing `Dialog` component which already provides focus trap, `role="dialog"`, and `aria-modal`. - Disabled delete buttons must use `aria-disabled="true"` (not HTML `disabled`) to remain focusable. Wrap in the Radix `Tooltip` / `TooltipTrigger` / `TooltipContent` from `frontend/src/components/ui/Tooltip.tsx` for an accessible visible tooltip (not just `title` attribute). - The delete icon button needs `aria-label` for screen readers. > **Known inconsistency**: The existing `CertificateCleanupDialog` uses a hand-rolled overlay > (`