- Update isInUse function to handle certificates without an ID.
- Modify isDeletable function to include 'expiring' status as deletable.
- Adjust CertificateList component to reflect changes in deletable logic.
- Update BulkDeleteCertificateDialog and DeleteCertificateDialog to handle expiring certificates.
- Add tests for expiring certificates in CertificateList and BulkDeleteCertificateDialog.
- Update translations for expiring certificates in multiple languages.
# Fix: Allow deletion of expiring_soon certificates not in use
**Date**: 2026-03-22
**Priority**: Medium
**Type**: User Requested Feature
**Status**: Planning — Awaiting Implementation
> **Status note:** The bug report refers to the status as `expiring_soon`. In this codebase the actual status string is `'expiring'` (defined in `frontend/src/api/certificates.ts` line 10 and `backend/internal/services/certificate_service.go` line 33). All references below use `'expiring'`.
---
## 1. Problem Statement
## 1. Bug Root Cause
The Certificates page now supports individual deletion of custom, staging, and expired
production Let's Encrypt certificates. Users who accumulate many such certificates must
click through a confirmation dialog for each one. There is no mechanism to select and
destroy multiple certificates in a single operation.
This feature adds checkbox-based bulk selection and a single confirmation step to delete
N certificates at once, operating under exactly the same policy terms as the existing
individual delete affordance.
### Non-Goals
- Changes to the individual delete flow or `DeleteCertificateDialog`.
- A new backend batch endpoint (sequential per-cert calls are sufficient).
- Auto-cleanup / scheduled pruning.
- Migrating `CertificateList` from its current raw `<table>` to the `DataTable` component.
| custom / staging — not in use | ✅ true | ❌ false | Active delete Trash2 | ✅ Enabled checkbox |
| custom / staging / expired LE — in use | ❌ false | ✅ true | `aria-disabled` Trash2 + tooltip | ✅ Checkbox rendered but `disabled` + tooltip |
| expired LE — not in use | ✅ true | ❌ false | Active delete Trash2 | ✅ Enabled checkbox |
| valid/expiring LE — not in use | ❌ false | ❌ false | No affordance at all | No checkbox, no column cell |
| valid/expiring LE — in use | ❌ false | ✅ true | No affordance at all | No checkbox, no column cell |
A cert with `provider === 'letsencrypt'` and `status === 'expiring'` that is not attached to any proxy host evaluates to `false` because `'expiring' !== 'expired'`. No delete button is rendered, and the cert cannot be selected for bulk delete.
### 2.2 Backend — No New Endpoint Required
**Additional bug:**`isInUse` has a false-positive when `cert.id` is falsy — `undefined === undefined` would match any proxy host with an unset certificate reference, incorrectly treating the cert as in-use. Fix by adding `if (!cert.id) return false` as the first line of `isInUse`.
`DELETE /api/v1/certificates/:id` is registered at `backend/internal/api/routes/routes.go:673`
and already:
Three secondary locations propagate the same status blind-spot:
-Guards against in-use certs (`IsCertificateInUse` → `409 Conflict`).
-Creates a server-side backup before deletion.
- Deletes the DB record and ACME files.
- Invalidates the cert cache and fires a notification.
Bulk deletion will call this endpoint N times concurrently using `Promise.allSettled`,
exactly as the ProxyHosts bulk delete does for `deleteHost`. `ids.map(id => deleteCertificate(id))`
fires all promises concurrently; `Promise.allSettled` awaits all settlements before resolving.
No batch endpoint is warranted at this scale.
### 2.3 CertificateList Rendering Architecture
`CertificateList.tsx` renders a purpose-built raw `<table>` with a manual `sortedCertificates`
`useMemo`. It does **not** use the `DataTable` UI component. This plan does not migrate it —
the selection layer will be grafted directly onto the existing table.
The `Checkbox` component at `frontend/src/components/ui/Checkbox.tsx` supports an
`indeterminate` prop backed by Radix UI `CheckboxPrimitive`. This component will be reused
for both the header "select all" checkbox and each row checkbox, matching the rendering
- The `DataTable``selectable` prop handles the per-row checkbox column and the header
"select all" checkbox automatically, but `DataTable.handleSelectAll` selects **every** row.
- For certificates the "select all" must only select the `isDeletable && !isInUse` subset,
so we cannot delegate to `DataTable`'s built-in logic even if we migrated.
- The bulk action bar is a conditional `<div>` that appears only when
`selectedHosts.size > 0`, containing the count and action buttons.
-`frontend/src/components/dialogs/BulkDeleteCertificateDialog.tsx` — `providerLabel` falls through to `return cert.provider` (showing raw `"letsencrypt"`) for expiring certs instead of a human-readable label.
-`frontend/src/components/dialogs/DeleteCertificateDialog.tsx` — `getWarningKey` falls through to `'certificates.deleteConfirmCustom'` for expiring certs instead of a contextual message.
---
## 3. Technical Specification
## 2. Frontend Fix
### 3.1 State Changes — `CertificateList.tsx`
Add two pieces of state alongside the existing `certToDelete` and `sortColumn` state:
"bulkDeleteFailed":"Zertifikate konnten nicht 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.",
| 6 | **Cancel preserves certs**: cancelling the dialog leaves all three certs in the list |
| 7 | **Confirm deletes all selected**: confirming removes all selected certs from the table |
The "confirm deletes" test awaits the success or failure toast appearance (toast appearance
confirms all requests have settled via `Promise.allSettled`) before asserting the cert
names are no longer visible in the table.
---
## 5. Backend Considerations
### 5.1 No New Endpoint
The existing `DELETE /api/v1/certificates/:id` route at
`backend/internal/api/routes/routes.go:673` is the only backend touch point. Bulk deletion
is orchestrated entirely in the frontend using `Promise.allSettled`. This is intentional:
- The volume of certificates eligible for bulk deletion is small in practice.
- Each deletion independently creates a server-side backup. A batch endpoint would need
N individual backups anyway, yielding no efficiency gain.
- Concurrent `Promise.allSettled` provides natural per-item error isolation — a 409 on
one cert (race: cert becomes in-use between checkbox selection and confirmation) surfaces
as a failed count in the toast rather than an unhandled rejection.
### 5.2 No Backend Tests Required
The handler tests added in the single-delete PR already cover: success, in-use 409, auth
guard, invalid ID, not-found, and backup-failure paths. Bulk deletion calls the same handler
N times with no new code paths. Nothing new at the backend layer warrants new tests.
---
## 6. Security Considerations
- Bulk delete inherits all security properties of the individual delete endpoint:
authentication required, in-use guard server-side, numeric ID validation in the handler.
- The client-side `isDeletable` check is a UX gate, not a security gate; the server is the
authoritative enforcer.
-`Promise.allSettled` does not short-circuit — a 409 on one cert becomes a failed count,
not an unhandled exception, preserving the remaining deletions.
---
## 7. Commit Slicing Strategy
**Single PR.** The entire feature — `BulkDeleteCertificateDialog`, i18n keys in 5 locales,
selection layer in `CertificateList.tsx`, unit tests, and E2E tests — is one cohesive
change. The diff is small (< 400 lines of production code across ~6 files) and all parts
are interdependent. Splitting would temporarily ship a broken feature mid-PR.
Suggested commit title:
```
feat(certificates): add bulk deletion with checkbox selection
"noteText":"You can delete custom certificates, staging certificates, and expired or expiring production certificates that are not attached to any proxy host. Active production certificates are automatically renewed by Caddy.",
The current `certs` fixture (lines 28–30) covers: `custom/valid`, `letsencrypt-staging/untrusted`, `letsencrypt/expired`. No `expiring` fixture exists.
**Add** a standalone test verifying `providerLabel` renders the correct label for an expiring cert:
```ts
it('renders "Expiring LE" label for expiring letsencrypt certificate',()=>{
`DELETE /api/v1/certificates/:id` is handled by `CertificateHandler.Delete` in `backend/internal/api/handlers/certificate_handler.go` (line 140). The only guard is `IsCertificateInUse` (line 156). There is no status-based check — the handler invokes `service.DeleteCertificate(uint(id))` unconditionally once the in-use check passes.
`CertificateService.DeleteCertificate` in `backend/internal/services/certificate_service.go` (line 396) likewise only inspects `IsCertificateInUse` before proceeding. The cert's `Status` field is never read or compared during deletion.
A cert with `status = 'expiring'` that is not in use is deleted successfully by the backend today; the bug is frontend-only.
---
## 5. E2E Consideration
**File:**`tests/certificate-bulk-delete.spec.ts`
The spec currently has no fixture that places a cert into `expiring` status, because status is computed server-side at query time from the actual expiry date. Manufacturing an `expiring` cert in Playwright requires inserting a certificate whose expiry falls within the renewal window (≈30 days out).
**Recommended addition:** Add a test scenario that uploads a custom certificate with an expiry date 15 days from now and is not attached to any proxy host, then asserts:
1. Its row checkbox is enabled and its per-row delete button is present.
2. It can be selected and bulk-deleted via `BulkDeleteCertificateDialog`.
If producing a near-expiry cert at the E2E layer is not feasible, coverage from §3a and §3b is sufficient for this fix and the E2E test may be deferred to a follow-up.
---
## 6. Commit Slicing Strategy
**Single PR.** This is a self-contained bug fix with no API contract changes and no migration.
Suggested commit message:
```
fix(certificates): allow deletion of expiring certs not in use
Expiring Let's Encrypt certs not attached to any proxy host were
undeletable because isDeletable only permitted deletion when
status === 'expired'. Extends the condition to also allow
status === 'expiring', updates providerLabel and getWarningKey for
consistent UX, and corrects the existing unit test that was asserting
"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.",
"noteText":"You can delete custom certificates, staging certificates, and expired or expiring 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.",
"deleteConfirmExpiring":"This certificate is expiring soon. It will be permanently removed and will not be auto-renewed.",
"deleteSuccess":"Certificate deleted",
"deleteFailed":"Failed to delete certificate",
"deleteInUse":"Cannot delete — certificate is attached to a proxy host",
"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.",
"deleteConfirmExpiring":"This certificate is expiring soon. It will be permanently removed and will not be auto-renewed.",
"deleteSuccess":"Certificate deleted",
"deleteFailed":"Failed to delete certificate",
"deleteInUse":"Cannot delete — certificate is attached to a proxy host",
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.