Files
Charon/docs/plans/current_spec.md
GitHub Actions 441864be95 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.
2026-03-22 13:30:41 +00:00

492 lines
21 KiB
Markdown

# 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
> (`<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
> migrate `CertificateCleanupDialog` to 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:
1. **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.
2. **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.
3. **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.
4. **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).
5. **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.
6. **Test: Successful deletion flow**
- Click delete on a deletable cert.
- Confirm in dialog.
- Verify cert disappears from list.
- Verify success toast appears.
7. **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:
1. Using the existing API to create proxy hosts with different SSL modes (which triggers
cert provisioning by Caddy).
2. For expired/custom certs, use the certificate upload API (`POST /api/v1/certificates`)
with pre-generated test certificates.
3. For in-use vs. not-in-use states, create/delete proxy host associations via the proxy
host API.
4. 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`
1. Extract `isDeletable(cert, hosts)` helper function.
2. Extract `isInUse(cert, hosts)` helper function.
3. Replace the inline delete button condition with `isDeletable()`.
4. Add disabled delete button with tooltip for in-use certs.
5. Replace `confirm()` with `DeleteCertificateDialog` state management:
- `const [certToDelete, setCertToDelete] = useState<Certificate | null>(null)`
- Open dialog: `setCertToDelete(cert)`
- Confirm: `deleteMutation.mutate(certToDelete.id)`
- Cancel/success: `setCertToDelete(null)`
6. 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`
1. **Test: Delete expired LE cert not in use succeeds** — ensures the backend does not block
expired LE certs from deletion.
2. **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`
1. Test `isDeletable()` helper with all provider/status/in-use combinations.
2. Test that delete button renders for deletable certs.
3. Test that delete button is disabled for in-use certs.
4. Test that delete button is hidden for valid production LE certs.
**File**: `frontend/src/components/dialogs/__tests__/DeleteCertificateDialog.test.tsx`
5. Test dialog renders with correct warning text per provider.
6. Test Cancel closes dialog.
7. 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 Conflict` if 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 pass
- `npx 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.