- Documented certificate management security features in security.md, including backup and recovery processes. - Implemented CertificateCleanupDialog component for confirming deletion of orphaned certificates when deleting proxy hosts. - Enhanced ProxyHosts page to check for orphaned certificates and prompt users accordingly during deletion. - Added tests for certificate cleanup prompts and behaviors in ProxyHosts, ensuring correct handling of unique, shared, and production certificates.
24 KiB
Certificate Management Enhancement - Execution Plan
Issue: The Certificates page has no actions for deleting certificates, and proxy host deletion doesn't prompt about certificate cleanup.
Date: December 5, 2025 Status: Planning Complete - Ready for Implementation
Overview
This plan implements two related features:
- Certificate Deletion Actions: Add delete buttons to the Certificates page actions column for expired/unused certificates
- Proxy Host Deletion Certificate Prompt: When deleting a proxy host, prompt user to confirm deletion of the associated certificate (default: No)
Both features prioritize user safety with confirmation dialogs, automatic backups, and sensible defaults.
Architecture Analysis
Current State
Backend:
- Certificate model:
backend/internal/models/ssl_certificate.go- SSLCertificate with ID, UUID, Name, Provider, Domains, etc. - ProxyHost model:
backend/internal/models/proxy_host.go- HasCertificateID *uint(nullable foreign key) andCertificate *SSLCertificaterelationship - Certificate service:
backend/internal/services/certificate_service.go- Already has
DeleteCertificate(id uint) errormethod - Already has
IsCertificateInUse(id uint) (bool, error)- checks if cert is linked to any ProxyHost - Returns
ErrCertInUseerror if certificate is in use
- Already has
- Certificate handler:
backend/internal/api/handlers/certificate_handler.go- Already has
Delete(c *gin.Context)endpoint atDELETE /api/v1/certificates/:id - Creates backup before deletion (if backupService available)
- Checks if certificate is in use and returns 409 Conflict if so
- Returns appropriate error messages
- Already has
Frontend:
- CertificateList component:
frontend/src/components/CertificateList.tsx- Already checks if certificate is in use:
hosts.some(h => h.certificate_id === cert.id) - Already has delete button for custom and staging certificates
- Already shows appropriate confirmation messages
- Already creates backup before deletion
- Already checks if certificate is in use:
- ProxyHostForm:
frontend/src/components/ProxyHostForm.tsx- Certificate selector with dropdown showing available certificates
- No certificate deletion logic on proxy host deletion
- ProxyHosts page:
frontend/src/pages/ProxyHosts.tsx- Delete handler calls
deleteHost(uuid, deleteUptime?) - Currently prompts about uptime monitors but not certificates
- Delete handler calls
Key Relationships:
- One certificate can be used by multiple proxy hosts (one-to-many)
- Proxy hosts can have no certificate (certificate_id is nullable)
- Backend prevents deletion of certificates in use (409 Conflict)
- Frontend already checks usage and blocks deletion
Backend Requirements
Current Implementation is COMPLETE ✅
The backend already has all required functionality:
- ✅ DELETE /api/v1/certificates/:id endpoint exists
- ✅ Certificate usage validation (
IsCertificateInUse) - ✅ Backup creation before deletion
- ✅ Proper error responses (400, 404, 409, 500)
- ✅ Notification service integration
- ✅ GORM relationship handling
No backend changes required - the API fully supports certificate deletion with proper validation.
Proxy Host Deletion - No Changes Needed
The proxy host deletion endpoint (DELETE /api/v1/proxy-hosts/:uuid) already:
- Deletes the proxy host
- GORM cascade rules handle the relationship cleanup
- Does NOT delete the certificate (certificate is shared resource)
This is correct behavior - certificates should not be auto-deleted when a proxy host is removed, as they may be:
- Used by other proxy hosts
- Reusable for future proxy hosts
- Auto-managed by Let's Encrypt (shouldn't be manually deleted)
Frontend will handle certificate cleanup prompting - no backend API changes needed.
Frontend Requirements
1. Certificate Actions Column (Already Working)
Status: ✅ IMPLEMENTED in frontend/src/components/CertificateList.tsx
The actions column already shows delete buttons for:
- Custom certificates (
cert.provider === 'custom') - Staging certificates (
cert.issuer?.toLowerCase().includes('staging'))
The delete logic already:
- Checks if certificate is in use by proxy hosts
- Shows appropriate confirmation messages
- Creates backup before deletion
- Handles errors properly
Current implementation is correct and complete.
2. Proxy Host Deletion Certificate Prompt (NEW FEATURE)
File: frontend/src/pages/ProxyHosts.tsx
Location: handleDelete function (lines ~119-162)
Required Changes:
-
Update
handleDeletefunction to check for associated certificates:const handleDelete = async (uuid: string) => { const host = hosts.find(h => h.uuid === uuid) if (!host) return if (!confirm('Are you sure you want to delete this proxy host?')) return try { // Check for uptime monitors (existing code) let associatedMonitors: UptimeMonitor[] = [] // ... existing uptime monitor logic ... // NEW: Check for associated certificate let shouldDeleteCert = false if (host.certificate_id && host.certificate) { const cert = host.certificate // Check if this is the ONLY proxy host using this certificate const otherHostsUsingCert = hosts.filter(h => h.uuid !== uuid && h.certificate_id === host.certificate_id ).length if (otherHostsUsingCert === 0) { // This is the only host using the certificate // Only prompt for custom/staging certs (not production Let's Encrypt) if (cert.provider === 'custom' || cert.issuer?.toLowerCase().includes('staging')) { shouldDeleteCert = confirm( `This proxy host uses certificate "${cert.name || cert.domain}". ` + `Do you want to delete the certificate as well?\n\n` + `Click "Cancel" to keep the certificate (default).` ) } } } // Delete uptime monitors if confirmed (existing) if (associatedMonitors.length > 0) { const deleteUptime = confirm('...') await deleteHost(uuid, deleteUptime) } else { await deleteHost(uuid) } // NEW: Delete certificate if user confirmed if (shouldDeleteCert && host.certificate_id) { try { await deleteCertificate(host.certificate_id) toast.success('Proxy host and certificate deleted') } catch (err) { // Host is already deleted, just log cert deletion failure toast.error(`Proxy host deleted but failed to delete certificate: ${err instanceof Error ? err.message : 'Unknown error'}`) } } } catch (err) { alert(err instanceof Error ? err.message : 'Failed to delete') } } -
Import required API function:
import { deleteCertificate } from '../api/certificates' -
UI/UX Considerations:
- Show certificate prompt AFTER proxy host deletion confirmation
- Default is "No" (Cancel button) - safer option
- Only prompt for custom/staging certificates (not production Let's Encrypt)
- Only prompt if this is the ONLY host using the certificate
- Certificate deletion happens AFTER host deletion (host must be removed first to pass backend validation)
- Show appropriate toast messages for both actions
3. Bulk Proxy Host Deletion (Enhancement)
File: frontend/src/pages/ProxyHosts.tsx
Location: handleBulkDelete function (lines ~204-242)
Required Changes (similar pattern):
const handleBulkDelete = async () => {
const hostUUIDs = Array.from(selectedHosts)
setIsCreatingBackup(true)
try {
// Create automatic backup (existing)
toast.loading('Creating backup before deletion...')
const backup = await createBackup()
toast.dismiss()
toast.success(`Backup created: ${backup.filename}`)
// NEW: Collect certificates to potentially delete
const certsToConsider: Set<number> = new Set()
hostUUIDs.forEach(uuid => {
const host = hosts.find(h => h.uuid === uuid)
if (host?.certificate_id && host.certificate) {
const cert = host.certificate
// Only consider custom/staging certs
if (cert.provider === 'custom' || cert.issuer?.toLowerCase().includes('staging')) {
// Check if this cert is ONLY used by hosts being deleted
const otherHosts = hosts.filter(h =>
h.certificate_id === host.certificate_id &&
!hostUUIDs.includes(h.uuid)
)
if (otherHosts.length === 0) {
certsToConsider.add(host.certificate_id)
}
}
}
})
// NEW: Prompt for certificate deletion if any are orphaned
let shouldDeleteCerts = false
if (certsToConsider.size > 0) {
shouldDeleteCerts = confirm(
`${certsToConsider.size} certificate(s) will no longer be used after deleting these hosts. ` +
`Do you want to delete the unused certificates as well?\n\n` +
`Click "Cancel" to keep the certificates (default).`
)
}
// Delete each host (existing)
let deleted = 0
let failed = 0
for (const uuid of hostUUIDs) {
try {
await deleteHost(uuid)
deleted++
} catch {
failed++
}
}
// NEW: Delete certificates if user confirmed
if (shouldDeleteCerts && certsToConsider.size > 0) {
let certsDeleted = 0
let certsFailed = 0
for (const certId of certsToConsider) {
try {
await deleteCertificate(certId)
certsDeleted++
} catch {
certsFailed++
}
}
if (certsFailed > 0) {
toast.error(`Deleted ${deleted} host(s) and ${certsDeleted} certificate(s), ${certsFailed} certificate(s) failed`)
} else if (certsDeleted > 0) {
toast.success(`Deleted ${deleted} host(s) and ${certsDeleted} certificate(s)`)
}
} else {
// No certs deleted (existing logic)
if (failed > 0) {
toast.error(`Deleted ${deleted} host(s), ${failed} failed`)
} else {
toast.success(`Successfully deleted ${deleted} host(s). Backup available for restore.`)
}
}
setSelectedHosts(new Set())
setShowBulkDeleteModal(false)
} catch (err) {
toast.dismiss()
toast.error('Failed to create backup. Deletion cancelled.')
} finally {
setIsCreatingBackup(false)
}
}
Testing Strategy
Backend Tests (Already Exist) ✅
Location: backend/internal/api/handlers/certificate_handler_test.go
Existing tests cover:
- ✅ Delete certificate in use (409 Conflict)
- ✅ Delete certificate not in use (success with backup)
- ✅ Delete invalid ID (400 Bad Request)
- ✅ Delete non-existent certificate (404 Not Found)
- ✅ Delete without backup service (still succeeds)
No new backend tests required - coverage is complete.
Frontend Tests (Need Updates)
1. CertificateList Component Tests ✅
Location: frontend/src/components/__tests__/CertificateList.test.tsx
Already has tests for:
- ✅ Delete custom certificate with confirmation
- ✅ Delete staging certificate
- ✅ Block deletion when certificate is in use
- ✅ Block deletion when certificate is active (valid/expiring)
Current tests are sufficient.
2. ProxyHosts Component Tests (Need New Tests)
Location: frontend/src/pages/__tests__/ProxyHosts-coverage.test.tsx
New tests required:
describe('ProxyHosts - Certificate Deletion Prompts', () => {
it('prompts to delete certificate when deleting proxy host with unique custom cert', async () => {
const cert = { id: 1, provider: 'custom', name: 'CustomCert', domain: 'test.com' }
const host = baseHost({
uuid: 'h1',
name: 'Host1',
certificate_id: 1,
certificate: cert
})
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host])
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([cert])
const confirmSpy = vi.spyOn(window, 'confirm')
.mockReturnValueOnce(true) // Confirm proxy host deletion
.mockReturnValueOnce(true) // Confirm certificate deletion
renderWithProviders(<ProxyHosts />)
await waitFor(() => expect(screen.getByText('Host1')).toBeTruthy())
const deleteBtn = screen.getByText('Delete')
await userEvent.click(deleteBtn)
await waitFor(() => {
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('h1')
expect(certificatesApi.deleteCertificate).toHaveBeenCalledWith(1)
})
confirmSpy.mockRestore()
})
it('does NOT prompt for certificate deletion when cert is shared by multiple hosts', async () => {
const cert = { id: 1, provider: 'custom', name: 'SharedCert' }
const host1 = baseHost({ uuid: 'h1', certificate_id: 1, certificate: cert })
const host2 = baseHost({ uuid: 'h2', certificate_id: 1, certificate: cert })
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host1, host2])
const confirmSpy = vi.spyOn(window, 'confirm')
.mockReturnValueOnce(true) // Only asked once (proxy host deletion)
renderWithProviders(<ProxyHosts />)
await waitFor(() => expect(screen.getByText(host1.name)).toBeTruthy())
const deleteBtn = screen.getAllByText('Delete')[0]
await userEvent.click(deleteBtn)
await waitFor(() => expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('h1'))
expect(certificatesApi.deleteCertificate).not.toHaveBeenCalled()
expect(confirmSpy).toHaveBeenCalledTimes(1) // Only proxy host confirmation
confirmSpy.mockRestore()
})
it('does NOT prompt for production Let\'s Encrypt certificates', async () => {
const cert = { id: 1, provider: 'letsencrypt', issuer: 'Let\'s Encrypt', name: 'LE Prod' }
const host = baseHost({ uuid: 'h1', certificate_id: 1, certificate: cert })
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host])
const confirmSpy = vi.spyOn(window, 'confirm')
.mockReturnValueOnce(true) // Only proxy host deletion
renderWithProviders(<ProxyHosts />)
await waitFor(() => expect(screen.getByText(host.name)).toBeTruthy())
const deleteBtn = screen.getByText('Delete')
await userEvent.click(deleteBtn)
expect(confirmSpy).toHaveBeenCalledTimes(1) // No cert prompt
expect(certificatesApi.deleteCertificate).not.toHaveBeenCalled()
confirmSpy.mockRestore()
})
it('prompts for staging certificates', async () => {
const cert = {
id: 1,
provider: 'letsencrypt-staging',
issuer: 'Let\'s Encrypt Staging',
name: 'Staging Cert'
}
const host = baseHost({ uuid: 'h1', certificate_id: 1, certificate: cert })
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host])
const confirmSpy = vi.spyOn(window, 'confirm')
.mockReturnValueOnce(true) // Proxy host deletion
.mockReturnValueOnce(false) // Decline certificate deletion (default)
renderWithProviders(<ProxyHosts />)
await waitFor(() => expect(screen.getByText(host.name)).toBeTruthy())
const deleteBtn = screen.getByText('Delete')
await userEvent.click(deleteBtn)
await waitFor(() => expect(confirmSpy).toHaveBeenCalledTimes(2))
expect(certificatesApi.deleteCertificate).not.toHaveBeenCalled()
confirmSpy.mockRestore()
})
it('handles certificate deletion failure gracefully', async () => {
const cert = { id: 1, provider: 'custom', name: 'CustomCert' }
const host = baseHost({ uuid: 'h1', certificate_id: 1, certificate: cert })
vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue()
vi.mocked(certificatesApi.deleteCertificate).mockRejectedValue(
new Error('Certificate is still in use')
)
const confirmSpy = vi.spyOn(window, 'confirm')
.mockReturnValueOnce(true) // Proxy host
.mockReturnValueOnce(true) // Certificate
renderWithProviders(<ProxyHosts />)
await waitFor(() => expect(screen.getByText(host.name)).toBeTruthy())
const deleteBtn = screen.getByText('Delete')
await userEvent.click(deleteBtn)
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith(
expect.stringContaining('failed to delete certificate')
)
})
confirmSpy.mockRestore()
})
it('bulk delete prompts for orphaned certificates', async () => {
const cert = { id: 1, provider: 'custom', name: 'BulkCert' }
const host1 = baseHost({ uuid: 'h1', certificate_id: 1, certificate: cert })
const host2 = baseHost({ uuid: 'h2', certificate_id: 1, certificate: cert })
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host1, host2])
vi.mocked(backupsApi.createBackup).mockResolvedValue({ filename: 'backup.db' })
const confirmSpy = vi.spyOn(window, 'confirm')
.mockReturnValueOnce(true) // Confirm bulk delete modal
.mockReturnValueOnce(true) // Confirm certificate deletion
renderWithProviders(<ProxyHosts />)
await waitFor(() => expect(screen.getByText(host1.name)).toBeTruthy())
// Select both hosts
const checkboxes = screen.getAllByRole('checkbox')
await userEvent.click(checkboxes[0]) // Select all
// Click bulk delete
const bulkDeleteBtn = screen.getByText('Delete')
await userEvent.click(bulkDeleteBtn)
// Confirm in modal
await userEvent.click(screen.getByText('Delete Permanently'))
await waitFor(() => {
expect(confirmSpy).toHaveBeenCalledTimes(2)
expect(certificatesApi.deleteCertificate).toHaveBeenCalledWith(1)
})
confirmSpy.mockRestore()
})
})
3. Integration Tests
Manual Testing Checklist:
- Delete custom certificate from Certificates page
- Attempt to delete certificate in use (should show error)
- Delete proxy host with unique custom certificate (should prompt)
- Delete proxy host with shared certificate (should NOT prompt)
- Delete proxy host with production Let's Encrypt cert (should NOT prompt)
- Delete proxy host with staging certificate (should prompt)
- Decline certificate deletion (default) - only host deleted
- Accept certificate deletion - both deleted
- Bulk delete hosts with orphaned certificates
- Verify backups are created before deletions
- Check certificate deletion failure doesn't block host deletion
Security Considerations
Authorization
- ✅ All certificate endpoints protected by authentication middleware
- ✅ All proxy host endpoints protected by authentication middleware
- ✅ Only authenticated users can delete resources
Validation
- ✅ Backend validates certificate not in use before deletion (409 Conflict)
- ✅ Backend validates certificate ID is numeric and exists (400/404)
- ✅ Frontend checks certificate usage before allowing deletion
- ✅ Frontend validates proxy host UUID before deletion
Data Protection
- ✅ Automatic backup created before all deletions
- ✅ Soft deletes NOT used (certificates are fully removed)
- ✅ File system cleanup for Let's Encrypt certificates
- ✅ Database cascade rules properly configured
User Safety
- ✅ Confirmation dialogs required for all deletions
- ✅ Certificate deletion default is "No" (safer)
- ✅ Clear messaging about what will be deleted
- ✅ Descriptive toast messages for success/failure
- ✅ Only prompt for custom/staging certs (not production)
- ✅ Only prompt when certificate is orphaned (no other hosts)
Error Handling
- ✅ Graceful handling of certificate deletion failures
- ✅ Host deletion succeeds even if cert deletion fails
- ✅ Appropriate error messages shown to user
- ✅ Failed deletions don't block other operations
Implementation Order
Phase 1: Certificate Actions Column ✅
Status: COMPLETE - Already implemented correctly
No changes needed.
Phase 2: Single Proxy Host Deletion Certificate Prompt
Priority: HIGH Estimated Time: 2 hours
-
Update
frontend/src/pages/ProxyHosts.tsx:- Modify
handleDeletefunction to check for certificates - Add certificate deletion prompt logic
- Handle certificate deletion after host deletion
- Import
deleteCertificateAPI function
- Modify
-
Write unit tests in
frontend/src/pages/__tests__/ProxyHosts-coverage.test.tsx:- Test certificate prompt for unique custom cert
- Test no prompt for shared certificate
- Test no prompt for production Let's Encrypt
- Test prompt for staging certificates
- Test default "No" behavior
- Test certificate deletion failure handling
-
Manual testing:
- Test all scenarios in Testing Strategy checklist
- Verify toast messages are clear
- Verify backups are created
- Test error cases
Phase 3: Bulk Proxy Host Deletion Certificate Prompt
Priority: MEDIUM Estimated Time: 2 hours
-
Update
frontend/src/pages/ProxyHosts.tsx:- Modify
handleBulkDeletefunction - Add logic to identify orphaned certificates
- Add certificate deletion prompt
- Handle bulk certificate deletion
- Modify
-
Write unit tests:
- Test bulk deletion with orphaned certificates
- Test bulk deletion with shared certificates
- Test mixed scenarios
-
Manual testing:
- Bulk delete scenarios
- Multiple certificate handling
- Error recovery
Phase 4: Documentation & Polish
Priority: LOW Estimated Time: 1 hour
-
Update
docs/features.md:- Document certificate deletion feature
- Document proxy host certificate cleanup
-
Update
docs/api.md(if needed):- Verify certificate deletion endpoint documented
-
Code review:
- Review all changes
- Ensure consistent error messages
- Verify test coverage
Risk Assessment
Low Risk ✅
- Backend API already exists and is well-tested
- Certificate deletion already works correctly
- Backup system already in place
- Frontend certificate list already handles deletion
Medium Risk ⚠️
- User confusion about certificate deletion prompts
- Mitigation: Clear messaging, sensible defaults (No), only prompt for custom/staging
- Race conditions with shared certificates
- Mitigation: Check certificate usage at deletion time (backend validation)
- Certificate deletion failure after host deleted
- Mitigation: Graceful error handling, informative toast messages
No Risk ❌
- Data loss: Backups created before all deletions
- Accidental deletion: Multiple confirmations required
- Production certs: Never prompted for deletion
Success Criteria
Must Have ✅
- Certificate delete buttons visible in Certificates page actions column
- Delete buttons only shown for custom and staging certificates
- Certificate deletion blocked if in use by any proxy host
- Automatic backup created before certificate deletion
- Proxy host deletion prompts for certificate cleanup (default: No)
- Certificate prompt only shown for custom/staging certs
- Certificate prompt only shown when orphaned (no other hosts)
- All operations have clear confirmation dialogs
- All operations show appropriate toast messages
- Backend validation prevents invalid deletions
Nice to Have ✨
- Show certificate usage count in Certificates table
- Highlight orphaned certificates in the list
- Batch certificate cleanup tool
- Certificate expiry warnings before deletion
Open Questions
-
✅ Should production Let's Encrypt certificates ever be manually deletable?
- Answer: No, they are auto-managed by Caddy
-
✅ Should certificate deletion be allowed if status is "valid" or "expiring"?
- Answer: Yes, if not in use (user may want to replace)
-
✅ What happens if certificate deletion fails after host is deleted?
- Answer: Show error toast, certificate remains, user can delete later
-
✅ Should bulk host deletion prompt for each certificate individually?
- Answer: No, single prompt for all orphaned certificates
Notes
- Certificate deletion is a shared resource operation - multiple hosts can use the same certificate
- The backend correctly prevents deletion of in-use certificates (409 Conflict)
- The frontend already has all the UI components and logic needed
- Focus is on adding prompts to the proxy host deletion flow
- Default behavior is conservative (don't delete certificates) for safety
- Only custom and staging certificates are considered for cleanup
- Production Let's Encrypt certificates should never be manually deleted
Definition of Done
- Certificate delete buttons visible and functional
- Proxy host deletion prompts for certificate cleanup
- All confirmation dialogs use appropriate defaults
- Unit tests written and passing
- Manual testing completed
- Documentation updated
- Code reviewed
- No console errors or warnings
- Pre-commit checks pass
- Feature tested in local Docker environment
Plan Created: December 5, 2025 Plan Author: Planning Agent (Architect) Ready for Implementation: ✅ YES