feat: Add Import Success Modal and Certificate Status Card features
- Implemented ImportSuccessModal to replace alert with a modal displaying import results and guidance. - Updated ImportCaddy to show the new modal with import summary and navigation options. - Created CertificateStatusCard to display certificate provisioning status on the dashboard. - Enhanced API types and hooks to support new features. - Added unit tests for ImportSuccessModal and CertificateStatusCard components. - Updated QA report to reflect the status of the new features and tests.
This commit is contained in:
@@ -1,645 +1,288 @@
|
||||
# Feature Spec: Post-Import Notification & Certificate Status Dashboard
|
||||
# Cerberus/Security Feature Issues - Diagnostic Plan
|
||||
|
||||
**Status:** Planning
|
||||
**Created:** December 11, 2025
|
||||
**Priority:** Medium
|
||||
**Date:** December 12, 2025
|
||||
**Status:** Analysis Complete
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
## Issue Summary
|
||||
|
||||
Two related features to improve user experience around the import workflow and certificate provisioning visibility:
|
||||
|
||||
1. **Post-Import Success Notification** - Replace the current `alert()` with a proper modal showing import results and guidance about certificate provisioning
|
||||
2. **Certificate Status Indicator on Dashboard** - Add visibility into certificate provisioning status with counts and visual indicators
|
||||
| # | Issue | Severity |
|
||||
|---|-------|----------|
|
||||
| 1 | Cerberus shows ON by default on first load (should be OFF) | High |
|
||||
| 2 | Cerberus dashboard header shows "disabled" even when enabled | Medium |
|
||||
| 3 | CrowdSec toggle auto-enables when Cerberus is enabled | Medium |
|
||||
| 4 | CrowdSec toggle unresponsive + Config button grayed out | High |
|
||||
|
||||
---
|
||||
|
||||
## Feature 1: Post-Import Success Notification
|
||||
## Root Cause Analysis
|
||||
|
||||
### Current State
|
||||
### Issue 1: Cerberus Shows ON by Default
|
||||
|
||||
In [ImportCaddy.tsx](../../../frontend/src/pages/ImportCaddy.tsx#L42), after a successful import commit:
|
||||
**Root Cause:** The `feature_flags_handler.go` has a default value of `true` for all feature flags including `feature.cerberus.enabled`.
|
||||
|
||||
```tsx
|
||||
const handleCommit = async (resolutions: Record<string, string>, names: Record<string, string>) => {
|
||||
try {
|
||||
await createBackup()
|
||||
await commit(resolutions, names)
|
||||
setContent('')
|
||||
setShowReview(false)
|
||||
alert('Import completed successfully!') // ← Replace this
|
||||
} catch {
|
||||
// Error is already set by hook
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Backend Response (import_handler.go)
|
||||
|
||||
The `/import/commit` endpoint returns a detailed response:
|
||||
|
||||
```json
|
||||
{
|
||||
"created": 5,
|
||||
"updated": 2,
|
||||
"skipped": 1,
|
||||
"errors": []
|
||||
}
|
||||
```
|
||||
|
||||
This data is currently **not captured** by the frontend.
|
||||
|
||||
### Requirements
|
||||
|
||||
1. Replace `alert()` with a modal dialog component
|
||||
2. Display import summary:
|
||||
- ✅ Number of hosts created
|
||||
- 🔄 Number of hosts updated (overwrites)
|
||||
- ⏭️ Number of hosts skipped
|
||||
- ❌ Any errors encountered
|
||||
3. Include informational message about certificate provisioning:
|
||||
- "Certificate provisioning may take 1-5 minutes"
|
||||
- "Monitor the Dashboard for certificate status"
|
||||
4. Provide navigation options:
|
||||
- "Go to Dashboard" button
|
||||
- "View Proxy Hosts" button
|
||||
- "Close" button
|
||||
|
||||
### Implementation Plan
|
||||
|
||||
#### 1. Create Import Success Modal Component
|
||||
|
||||
**File:** `frontend/src/components/dialogs/ImportSuccessModal.tsx`
|
||||
|
||||
```tsx
|
||||
interface ImportSuccessModalProps {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
onNavigateDashboard: () => void
|
||||
onNavigateHosts: () => void
|
||||
results: {
|
||||
created: number
|
||||
updated: number
|
||||
skipped: number
|
||||
errors: string[]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Design Pattern:** Follow existing modal patterns from:
|
||||
- [ImportSitesModal.tsx](../../../frontend/src/components/ImportSitesModal.tsx) - Portal/overlay structure
|
||||
- [CertificateCleanupDialog.tsx](../../../frontend/src/components/dialogs/CertificateCleanupDialog.tsx) - Form submission pattern
|
||||
|
||||
#### 2. Update API Types
|
||||
|
||||
**File:** `frontend/src/api/import.ts`
|
||||
|
||||
Add return type for commit:
|
||||
|
||||
```typescript
|
||||
export interface ImportCommitResult {
|
||||
created: number
|
||||
updated: number
|
||||
skipped: number
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export const commitImport = async (
|
||||
sessionUUID: string,
|
||||
resolutions: Record<string, string>,
|
||||
names: Record<string, string>
|
||||
): Promise<ImportCommitResult> => {
|
||||
const { data } = await client.post<ImportCommitResult>('/import/commit', {
|
||||
session_uuid: sessionUUID,
|
||||
resolutions,
|
||||
names
|
||||
})
|
||||
return data
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Update useImport Hook
|
||||
|
||||
**File:** `frontend/src/hooks/useImport.ts`
|
||||
|
||||
```typescript
|
||||
// Add to return type
|
||||
commitResult: ImportCommitResult | null
|
||||
|
||||
// Capture result in mutation
|
||||
const commitMutation = useMutation({
|
||||
mutationFn: async ({ resolutions, names }) => {
|
||||
const sessionId = statusQuery.data?.session?.id
|
||||
if (!sessionId) throw new Error("No active session")
|
||||
return commitImport(sessionId, resolutions, names) // Now returns result
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
setCommitResult(result) // New state
|
||||
setCommitSucceeded(true)
|
||||
// ... existing invalidation logic
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
#### 4. Update ImportCaddy.tsx
|
||||
|
||||
**File:** `frontend/src/pages/ImportCaddy.tsx`
|
||||
|
||||
```tsx
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import ImportSuccessModal from '../components/dialogs/ImportSuccessModal'
|
||||
|
||||
// In component:
|
||||
const navigate = useNavigate()
|
||||
const { commitResult, clearCommitResult } = useImport() // New fields
|
||||
const [showSuccessModal, setShowSuccessModal] = useState(false)
|
||||
|
||||
const handleCommit = async (resolutions, names) => {
|
||||
try {
|
||||
await createBackup()
|
||||
await commit(resolutions, names)
|
||||
setContent('')
|
||||
setShowReview(false)
|
||||
setShowSuccessModal(true) // Show modal instead of alert
|
||||
} catch {
|
||||
// Error is already set by hook
|
||||
}
|
||||
}
|
||||
|
||||
// In JSX:
|
||||
<ImportSuccessModal
|
||||
visible={showSuccessModal}
|
||||
onClose={() => {
|
||||
setShowSuccessModal(false)
|
||||
clearCommitResult()
|
||||
}}
|
||||
onNavigateDashboard={() => navigate('/')}
|
||||
onNavigateHosts={() => navigate('/proxy-hosts')}
|
||||
results={commitResult}
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Feature 2: Certificate Status Indicator on Dashboard
|
||||
|
||||
### Current State
|
||||
|
||||
In [Dashboard.tsx](../../../frontend/src/pages/Dashboard.tsx#L43-L47), the certificates card shows:
|
||||
|
||||
```tsx
|
||||
<Link to="/certificates" className="bg-dark-card p-6 rounded-lg...">
|
||||
<div className="text-sm text-gray-400 mb-2">SSL Certificates</div>
|
||||
<div className="text-3xl font-bold text-white mb-1">{certificates.length}</div>
|
||||
<div className="text-xs text-gray-500">{certificates.filter(c => c.status === 'valid').length} valid</div>
|
||||
</Link>
|
||||
```
|
||||
|
||||
### Requirements
|
||||
|
||||
1. Show certificate breakdown by status:
|
||||
- ✅ Valid (production, trusted)
|
||||
- ⏳ Pending (hosts without certs yet)
|
||||
- ⚠️ Expiring (within 30 days)
|
||||
- 🔸 Staging/Untrusted
|
||||
2. Visual progress indicator for pending certificates
|
||||
3. Link to filtered certificate list or proxy hosts without certs
|
||||
4. Auto-refresh to show provisioning progress
|
||||
|
||||
### Certificate Provisioning Detection
|
||||
|
||||
Certificates are provisioned by Caddy automatically. A host is "pending" if:
|
||||
- `ProxyHost.certificate_id` is NULL
|
||||
- `ProxyHost.ssl_forced` is true (expects a cert)
|
||||
- No matching certificate exists in the certificates list
|
||||
|
||||
**Key insight:** The certificate service ([certificate_service.go](../../../backend/internal/services/certificate_service.go)) syncs certificates from Caddy's cert directory every 5 minutes. New hosts won't have certificates immediately.
|
||||
|
||||
### Implementation Plan
|
||||
|
||||
#### 1. Create Certificate Status Summary API Endpoint (Optional Enhancement)
|
||||
|
||||
**File:** `backend/internal/api/handlers/certificate_handler.go`
|
||||
|
||||
Add new endpoint for dashboard summary:
|
||||
**File:** [backend/internal/api/handlers/feature_flags_handler.go#L39-L42](../../backend/internal/api/handlers/feature_flags_handler.go#L39-L42)
|
||||
|
||||
```go
|
||||
// GET /certificates/summary
|
||||
func (h *CertificateHandler) Summary(c *gin.Context) {
|
||||
certs, _ := h.service.ListCertificates()
|
||||
|
||||
summary := gin.H{
|
||||
"total": len(certs),
|
||||
"valid": 0,
|
||||
"expiring": 0,
|
||||
"expired": 0,
|
||||
"untrusted": 0, // staging certs
|
||||
// Line 39-42
|
||||
for _, key := range defaultFlags {
|
||||
defaultVal := true // <-- THIS IS THE BUG
|
||||
if v, ok := defaultFlagValues[key]; ok {
|
||||
defaultVal = v
|
||||
}
|
||||
|
||||
for _, c := range certs {
|
||||
switch c.Status {
|
||||
case "valid":
|
||||
summary["valid"] = summary["valid"].(int) + 1
|
||||
case "expiring":
|
||||
summary["expiring"] = summary["expiring"].(int) + 1
|
||||
case "expired":
|
||||
summary["expired"] = summary["expired"].(int) + 1
|
||||
case "untrusted":
|
||||
summary["untrusted"] = summary["untrusted"].(int) + 1
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, summary)
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** This is optional. The frontend can compute this from existing certificate list.
|
||||
|
||||
#### 2. Add Pending Hosts Detection
|
||||
|
||||
The more important metric is "hosts awaiting certificates":
|
||||
|
||||
**Option A: Client-side calculation (simpler, no backend change)**
|
||||
|
||||
```tsx
|
||||
// In Dashboard.tsx
|
||||
const hostsWithSSL = hosts.filter(h => h.ssl_forced && h.enabled)
|
||||
const hostsWithCerts = hosts.filter(h => h.certificate_id != null)
|
||||
const pendingCerts = hostsWithSSL.length - hostsWithCerts.length
|
||||
```
|
||||
|
||||
**Option B: Backend endpoint (more accurate)**
|
||||
|
||||
Add to proxy_host_handler.go:
|
||||
**Problem:** The code sets `defaultVal := true` for all flags, then only overrides it if the key exists in `defaultFlagValues`. However, `feature.cerberus.enabled` is NOT in `defaultFlagValues`:
|
||||
|
||||
```go
|
||||
// GET /proxy-hosts/cert-status
|
||||
func (h *ProxyHostHandler) CertStatus(c *gin.Context) {
|
||||
hosts, _ := h.service.List()
|
||||
|
||||
withSSL := 0
|
||||
withCert := 0
|
||||
|
||||
for _, h := range hosts {
|
||||
if h.SSLForced && h.Enabled {
|
||||
withSSL++
|
||||
if h.CertificateID != nil {
|
||||
withCert++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"total_ssl_enabled": withSSL,
|
||||
"with_certificate": withCert,
|
||||
"pending": withSSL - withCert,
|
||||
})
|
||||
// Line 29-31
|
||||
var defaultFlagValues = map[string]bool{
|
||||
"feature.crowdsec.console_enrollment": false,
|
||||
}
|
||||
```
|
||||
|
||||
**Recommendation:** Start with Option A (client-side) for simplicity.
|
||||
**Result:** On first load with an empty database, `feature.cerberus.enabled` defaults to `true` instead of `false`.
|
||||
|
||||
#### 3. Create CertificateStatusCard Component
|
||||
**Additional Context:**
|
||||
- The [backend/internal/config/config.go#L60](../../backend/internal/config/config.go#L60) correctly defaults `CerberusEnabled` to `false`:
|
||||
```go
|
||||
CerberusEnabled: getEnvAny("false", "CERBERUS_SECURITY_CERBERUS_ENABLED", ...) == "true"
|
||||
```
|
||||
- However, the feature flags handler ignores this config and uses its own default.
|
||||
|
||||
**File:** `frontend/src/components/CertificateStatusCard.tsx`
|
||||
---
|
||||
|
||||
### Issue 2: Dashboard Header Shows "Disabled" Even When Enabled
|
||||
|
||||
**Root Cause:** The header banner logic in `Security.tsx` checks `status.cerberus?.enabled` which comes from the security status API, but there's a **data source mismatch**.
|
||||
|
||||
**Files:**
|
||||
- [frontend/src/pages/Security.tsx#L141-L153](../../frontend/src/pages/Security.tsx#L141-L153) - Header banner logic
|
||||
- [backend/internal/api/handlers/security_handler.go#L35-L49](../../backend/internal/api/handlers/security_handler.go#L35-L49) - Security status API
|
||||
|
||||
**Problem Flow:**
|
||||
|
||||
1. **Security.tsx** checks `status.cerberus?.enabled` from `/api/v1/security/status`
|
||||
2. **security_handler.go** reads from config AND settings table:
|
||||
```go
|
||||
// Line 36-48
|
||||
enabled := h.cfg.CerberusEnabled
|
||||
var settingKey = "security.cerberus.enabled" // <-- WRONG KEY!
|
||||
if h.db != nil {
|
||||
var setting struct{ Value string }
|
||||
if err := h.db.Raw("SELECT value FROM settings WHERE key = ? LIMIT 1", settingKey).Scan(&setting).Error; ...
|
||||
```
|
||||
3. **SystemSettings.tsx** toggles `feature.cerberus.enabled` (via feature flags API)
|
||||
|
||||
**The Mismatch:**
|
||||
|
||||
| Component | Key Used |
|
||||
|-----------|----------|
|
||||
| SystemSettings toggle | `feature.cerberus.enabled` |
|
||||
| Security status API | `security.cerberus.enabled` |
|
||||
|
||||
The toggle writes to `feature.cerberus.enabled` but the security status reads from `security.cerberus.enabled` - **two different keys!**
|
||||
|
||||
---
|
||||
|
||||
### Issue 3: CrowdSec Auto-Enables When Cerberus is Enabled
|
||||
|
||||
**Root Cause:** The `docker-compose.override.yml` and `docker-compose.local.yml` both set `CHARON_SECURITY_CROWDSEC_MODE=local`:
|
||||
|
||||
**File:** [docker-compose.override.yml#L21](../../docker-compose.override.yml#L21)
|
||||
```yaml
|
||||
- CHARON_SECURITY_CROWDSEC_MODE=local
|
||||
```
|
||||
|
||||
**Problem:** When the container starts:
|
||||
1. Config loads with `CrowdSecMode: "local"` from env var
|
||||
2. Security status API returns `crowdsec.enabled: true` because mode is "local"
|
||||
3. Frontend shows CrowdSec as enabled
|
||||
|
||||
**File:** [backend/internal/api/handlers/security_handler.go#L59-L62](../../backend/internal/api/handlers/security_handler.go#L59-L62)
|
||||
```go
|
||||
// Allow runtime override for CrowdSec enabled flag via settings table
|
||||
crowdsecEnabled := mode == "local" // <-- Auto-true if mode is "local"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Issue 4: CrowdSec Toggle Unresponsive + Config Button Grayed Out
|
||||
|
||||
**Root Cause:** Multiple issues combine to break the toggle:
|
||||
|
||||
**A. Toggle Disabled Logic:**
|
||||
|
||||
**File:** [frontend/src/pages/Security.tsx#L127](../../frontend/src/pages/Security.tsx#L127)
|
||||
```tsx
|
||||
interface CertificateStatusCardProps {
|
||||
certificates: Certificate[]
|
||||
hosts: ProxyHost[]
|
||||
}
|
||||
|
||||
export default function CertificateStatusCard({ certificates, hosts }: CertificateStatusCardProps) {
|
||||
const validCount = certificates.filter(c => c.status === 'valid').length
|
||||
const expiringCount = certificates.filter(c => c.status === 'expiring').length
|
||||
const untrustedCount = certificates.filter(c => c.status === 'untrusted').length
|
||||
|
||||
// Pending = hosts with ssl_forced but no certificate_id
|
||||
const sslHosts = hosts.filter(h => h.ssl_forced && h.enabled)
|
||||
const hostsWithCerts = sslHosts.filter(h => h.certificate_id != null)
|
||||
const pendingCount = sslHosts.length - hostsWithCerts.length
|
||||
|
||||
const hasProvisioning = pendingCount > 0
|
||||
|
||||
return (
|
||||
<Link to="/certificates" className="bg-dark-card p-6 rounded-lg border border-gray-800...">
|
||||
<div className="text-sm text-gray-400 mb-2">SSL Certificates</div>
|
||||
<div className="text-3xl font-bold text-white mb-1">{certificates.length}</div>
|
||||
|
||||
{/* Status breakdown */}
|
||||
<div className="flex flex-wrap gap-2 mt-2 text-xs">
|
||||
<span className="text-green-400">{validCount} valid</span>
|
||||
{expiringCount > 0 && <span className="text-yellow-400">{expiringCount} expiring</span>}
|
||||
{untrustedCount > 0 && <span className="text-orange-400">{untrustedCount} staging</span>}
|
||||
</div>
|
||||
|
||||
{/* Pending indicator */}
|
||||
{hasProvisioning && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-700">
|
||||
<div className="flex items-center gap-2 text-blue-400 text-xs">
|
||||
<svg className="animate-spin h-3 w-3" ...>...</svg>
|
||||
<span>{pendingCount} host{pendingCount > 1 ? 's' : ''} awaiting certificate</span>
|
||||
</div>
|
||||
<div className="mt-1 h-1 bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-500 transition-all duration-500"
|
||||
style={{ width: `${(hostsWithCerts.length / sslHosts.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
const crowdsecToggleDisabled = cerberusDisabled || crowdsecPowerMutation.isPending
|
||||
```
|
||||
|
||||
#### 4. Update Dashboard.tsx
|
||||
|
||||
**File:** `frontend/src/pages/Dashboard.tsx`
|
||||
|
||||
**File:** [frontend/src/pages/Security.tsx#L126](../../frontend/src/pages/Security.tsx#L126)
|
||||
```tsx
|
||||
import CertificateStatusCard from '../components/CertificateStatusCard'
|
||||
|
||||
// Add auto-refresh when there are pending certs
|
||||
const hasPendingCerts = useMemo(() => {
|
||||
const sslHosts = hosts.filter(h => h.ssl_forced && h.enabled)
|
||||
return sslHosts.some(h => !h.certificate_id)
|
||||
}, [hosts])
|
||||
|
||||
// Use React Query with conditional refetch
|
||||
const { certificates } = useCertificates({
|
||||
refetchInterval: hasPendingCerts ? 15000 : false // Poll every 15s when pending
|
||||
})
|
||||
|
||||
// In JSX, replace static certificates card:
|
||||
<CertificateStatusCard certificates={certificates} hosts={hosts} />
|
||||
const cerberusDisabled = !status.cerberus?.enabled
|
||||
```
|
||||
|
||||
#### 5. Update useCertificates Hook
|
||||
Since `status.cerberus?.enabled` is `false` due to Issue 2 (wrong settings key), `cerberusDisabled` is `true`, making the toggle disabled.
|
||||
|
||||
**File:** `frontend/src/hooks/useCertificates.ts`
|
||||
**B. Config Button Disabled:**
|
||||
|
||||
```typescript
|
||||
interface UseCertificatesOptions {
|
||||
refetchInterval?: number | false
|
||||
}
|
||||
**File:** [frontend/src/pages/Security.tsx#L128](../../frontend/src/pages/Security.tsx#L128)
|
||||
```tsx
|
||||
const crowdsecControlsDisabled = cerberusDisabled || crowdsecPowerMutation.isPending
|
||||
```
|
||||
|
||||
export function useCertificates(options?: UseCertificatesOptions) {
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['certificates'],
|
||||
queryFn: getCertificates,
|
||||
refetchInterval: options?.refetchInterval,
|
||||
})
|
||||
Same logic - the controls are disabled because Cerberus appears disabled.
|
||||
|
||||
return {
|
||||
certificates: data || [],
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
}
|
||||
**C. Switch Component Event Handling:**
|
||||
|
||||
**File:** [frontend/src/components/ui/Switch.tsx#L17-L20](../../frontend/src/components/ui/Switch.tsx#L17-L20)
|
||||
|
||||
The Switch component passes `disabled` to the native checkbox input, which prevents click events. This is correct behavior - the issue is the `disabled` prop is incorrectly `true`.
|
||||
|
||||
---
|
||||
|
||||
## Recommended Fixes
|
||||
|
||||
### Fix 1: Update Feature Flag Defaults
|
||||
|
||||
**File:** `backend/internal/api/handlers/feature_flags_handler.go`
|
||||
|
||||
```go
|
||||
// Change defaultFlagValues to include cerberus.enabled as false
|
||||
var defaultFlagValues = map[string]bool{
|
||||
"feature.cerberus.enabled": false, // ADD THIS
|
||||
"feature.crowdsec.console_enrollment": false,
|
||||
"feature.uptime.enabled": true, // Uptime can default ON
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
### Fix 2: Align Settings Keys
|
||||
|
||||
## File Changes Summary
|
||||
**Option A (Recommended):** Update security_handler.go to read from feature flags key
|
||||
|
||||
### New Files
|
||||
**File:** `backend/internal/api/handlers/security_handler.go`
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `frontend/src/components/dialogs/ImportSuccessModal.tsx` | Modal for import completion |
|
||||
| `frontend/src/components/CertificateStatusCard.tsx` | Dashboard card with cert status |
|
||||
```go
|
||||
// Line 37: Change from
|
||||
var settingKey = "security.cerberus.enabled"
|
||||
// To
|
||||
var settingKey = "feature.cerberus.enabled"
|
||||
```
|
||||
|
||||
### Modified Files
|
||||
**Option B:** Create a sync mechanism between feature flags and security settings
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `frontend/src/api/import.ts` | Add `ImportCommitResult` type, update `commitImport` return |
|
||||
| `frontend/src/hooks/useImport.ts` | Capture and expose commit result |
|
||||
| `frontend/src/hooks/useCertificates.ts` | Add optional refetch interval |
|
||||
| `frontend/src/pages/ImportCaddy.tsx` | Replace alert with modal, add navigation |
|
||||
| `frontend/src/pages/Dashboard.tsx` | Use new CertificateStatusCard component |
|
||||
### Fix 3: Remove CrowdSec Mode Override from Docker Compose
|
||||
|
||||
### Optional Backend Changes (for enhanced accuracy)
|
||||
**Files:**
|
||||
- `docker-compose.override.yml`
|
||||
- `docker-compose.local.yml`
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `backend/internal/api/handlers/certificate_handler.go` | Add `/certificates/summary` endpoint |
|
||||
| `backend/internal/api/routes/routes.go` | Register summary route |
|
||||
```yaml
|
||||
# Remove or comment out:
|
||||
# - CHARON_SECURITY_CROWDSEC_MODE=local
|
||||
# Or change to:
|
||||
- CHARON_SECURITY_CROWDSEC_MODE=disabled
|
||||
```
|
||||
|
||||
### Fix 4: No Additional Fix Needed
|
||||
|
||||
Issue 4 is a symptom of Issues 1-2. Once those are fixed:
|
||||
- `cerberusDisabled` will be `false` when Cerberus is enabled
|
||||
- `crowdsecToggleDisabled` will be `false`
|
||||
- `crowdsecControlsDisabled` will be `false`
|
||||
- Toggle and Config button will be interactive
|
||||
|
||||
---
|
||||
|
||||
## Unit Test Requirements
|
||||
## Test Scenarios
|
||||
|
||||
### Feature 1: ImportSuccessModal
|
||||
|
||||
**File:** `frontend/src/components/dialogs/__tests__/ImportSuccessModal.test.tsx`
|
||||
|
||||
```typescript
|
||||
describe('ImportSuccessModal', () => {
|
||||
it('renders import summary correctly', () => {
|
||||
render(<ImportSuccessModal results={{ created: 5, updated: 2, skipped: 1, errors: [] }} ... />)
|
||||
expect(screen.getByText('5 hosts created')).toBeInTheDocument()
|
||||
expect(screen.getByText('2 hosts updated')).toBeInTheDocument()
|
||||
expect(screen.getByText('1 host skipped')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays certificate provisioning guidance', () => {
|
||||
render(<ImportSuccessModal results={{ created: 5, updated: 0, skipped: 0, errors: [] }} ... />)
|
||||
expect(screen.getByText(/certificate provisioning/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows errors when present', () => {
|
||||
render(<ImportSuccessModal results={{ created: 0, updated: 0, skipped: 0, errors: ['example.com: duplicate'] }} ... />)
|
||||
expect(screen.getByText('example.com: duplicate')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onNavigateDashboard when clicking Dashboard button', () => {
|
||||
const onNavigate = vi.fn()
|
||||
render(<ImportSuccessModal onNavigateDashboard={onNavigate} ... />)
|
||||
fireEvent.click(screen.getByText('Go to Dashboard'))
|
||||
expect(onNavigate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not render when visible is false', () => {
|
||||
render(<ImportSuccessModal visible={false} ... />)
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
### Test 1: Fresh Install Default State
|
||||
```
|
||||
Given: Clean database, no env vars set
|
||||
When: User loads the Settings > System page
|
||||
Then: Cerberus toggle should be OFF
|
||||
And: /api/v1/feature-flags returns { "feature.cerberus.enabled": false }
|
||||
```
|
||||
|
||||
### Feature 2: CertificateStatusCard
|
||||
|
||||
**File:** `frontend/src/components/__tests__/CertificateStatusCard.test.tsx`
|
||||
|
||||
```typescript
|
||||
describe('CertificateStatusCard', () => {
|
||||
it('shows valid certificate count', () => {
|
||||
render(<CertificateStatusCard certificates={mockCerts} hosts={mockHosts} />)
|
||||
expect(screen.getByText('3 valid')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows pending indicator when hosts lack certificates', () => {
|
||||
const hostsWithPending = [
|
||||
{ ...mockHost, ssl_forced: true, certificate_id: null, enabled: true },
|
||||
{ ...mockHost, ssl_forced: true, certificate_id: 1, enabled: true },
|
||||
]
|
||||
render(<CertificateStatusCard certificates={mockCerts} hosts={hostsWithPending} />)
|
||||
expect(screen.getByText(/1 host awaiting certificate/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides pending indicator when all hosts have certificates', () => {
|
||||
const hostsComplete = [
|
||||
{ ...mockHost, ssl_forced: true, certificate_id: 1, enabled: true },
|
||||
]
|
||||
render(<CertificateStatusCard certificates={mockCerts} hosts={hostsComplete} />)
|
||||
expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows expiring count when certificates are expiring', () => {
|
||||
const expiringCerts = [{ ...mockCert, status: 'expiring' }]
|
||||
render(<CertificateStatusCard certificates={expiringCerts} hosts={[]} />)
|
||||
expect(screen.getByText('1 expiring')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows staging count for untrusted certificates', () => {
|
||||
const stagingCerts = [{ ...mockCert, status: 'untrusted' }]
|
||||
render(<CertificateStatusCard certificates={stagingCerts} hosts={[]} />)
|
||||
expect(screen.getByText('1 staging')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calculates progress bar correctly', () => {
|
||||
const hosts = [
|
||||
{ ...mockHost, ssl_forced: true, certificate_id: 1, enabled: true },
|
||||
{ ...mockHost, ssl_forced: true, certificate_id: null, enabled: true },
|
||||
]
|
||||
const { container } = render(<CertificateStatusCard certificates={mockCerts} hosts={hosts} />)
|
||||
const progressBar = container.querySelector('[style*="width: 50%"]')
|
||||
expect(progressBar).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
### Test 2: Cerberus Toggle Sync
|
||||
```
|
||||
Given: User is on Settings > System page
|
||||
When: User enables Cerberus toggle
|
||||
Then: /api/v1/security/status returns { "cerberus": { "enabled": true } }
|
||||
And: Security dashboard header banner is NOT displayed
|
||||
```
|
||||
|
||||
### Integration Tests for useImport Hook
|
||||
### Test 3: CrowdSec Toggle Interaction
|
||||
```
|
||||
Given: Cerberus is enabled
|
||||
And: User is on Security dashboard
|
||||
When: User clicks CrowdSec toggle
|
||||
Then: Toggle should respond to click
|
||||
And: CrowdSec enabled state should change
|
||||
And: Toast notification should appear
|
||||
```
|
||||
|
||||
**File:** `frontend/src/hooks/__tests__/useImport.test.tsx`
|
||||
### Test 4: CrowdSec Config Button
|
||||
```
|
||||
Given: Cerberus is enabled
|
||||
And: User is on Security dashboard
|
||||
When: User clicks CrowdSec "Config" button
|
||||
Then: User should navigate to /security/crowdsec
|
||||
And: Button should NOT be grayed out
|
||||
```
|
||||
|
||||
```typescript
|
||||
describe('useImport - commit result', () => {
|
||||
it('captures commit result on success', async () => {
|
||||
mockCommitImport.mockResolvedValue({ created: 3, updated: 1, skipped: 0, errors: [] })
|
||||
|
||||
const { result } = renderHook(() => useImport(), { wrapper })
|
||||
await result.current.commit({}, {})
|
||||
|
||||
expect(result.current.commitResult).toEqual({ created: 3, updated: 1, skipped: 0, errors: [] })
|
||||
expect(result.current.commitSuccess).toBe(true)
|
||||
})
|
||||
})
|
||||
### Test 5: Environment Variable Override
|
||||
```
|
||||
Given: CERBERUS_SECURITY_CERBERUS_ENABLED=true set
|
||||
When: User loads Settings > System (fresh DB)
|
||||
Then: Cerberus toggle should be ON (env override)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## UI/UX Design Notes
|
||||
## Implementation Priority
|
||||
|
||||
### ImportSuccessModal
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ ✅ Import Completed │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────┐ │
|
||||
│ │ 📦 5 hosts created │ │
|
||||
│ │ 🔄 2 hosts updated │ │
|
||||
│ │ ⏭️ 1 host skipped │ │
|
||||
│ └────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ℹ️ Certificate Provisioning │
|
||||
│ SSL certificates will be automatically │
|
||||
│ provisioned by Let's Encrypt. This typically │
|
||||
│ takes 1-5 minutes per domain. │
|
||||
│ │
|
||||
│ Monitor the Dashboard to track certificate │
|
||||
│ provisioning progress. │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌───────────────┐ ┌────────┐ │
|
||||
│ │ Dashboard│ │ View Hosts │ │ Close │ │
|
||||
│ └──────────┘ └───────────────┘ └────────┘ │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Certificate Status Card (Dashboard)
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────┐
|
||||
│ SSL Certificates │
|
||||
│ │
|
||||
│ 12 │
|
||||
│ │
|
||||
│ ✅ 10 valid ⚠️ 1 expiring │
|
||||
│ 🔸 1 staging │
|
||||
│ │
|
||||
│ ────────────────────────────────────── │
|
||||
│ ⏳ 3 hosts awaiting certificate │
|
||||
│ ████████████░░░░░░░ 75% │
|
||||
└────────────────────────────────────────┘
|
||||
```
|
||||
| Priority | Fix | Effort | Impact |
|
||||
|----------|-----|--------|--------|
|
||||
| P0 | Fix 2 (Key alignment) | Low | High - Fixes Issues 2, 4 |
|
||||
| P1 | Fix 1 (Default values) | Low | High - Fixes Issue 1 |
|
||||
| P2 | Fix 3 (Docker compose) | Low | Medium - Fixes Issue 3 |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
## Files to Modify
|
||||
|
||||
1. **Phase 1: Import Success Modal** (Higher priority)
|
||||
- Update `api/import.ts` types
|
||||
- Update `useImport` hook
|
||||
- Create `ImportSuccessModal` component
|
||||
- Update `ImportCaddy.tsx`
|
||||
- Write unit tests
|
||||
|
||||
2. **Phase 2: Certificate Status Card** (Depends on Phase 1 for testing flow)
|
||||
- Update `useCertificates` hook with refetch option
|
||||
- Create `CertificateStatusCard` component
|
||||
- Update `Dashboard.tsx`
|
||||
- Write unit tests
|
||||
|
||||
3. **Phase 3: Polish**
|
||||
- Add loading states
|
||||
- Responsive design adjustments
|
||||
- Accessibility review (ARIA labels, focus management)
|
||||
1. **backend/internal/api/handlers/feature_flags_handler.go** - Add default value for cerberus
|
||||
2. **backend/internal/api/handlers/security_handler.go** - Change settings key to `feature.cerberus.enabled`
|
||||
3. **docker-compose.override.yml** - Remove or change CrowdSec mode
|
||||
4. **docker-compose.local.yml** - Remove or change CrowdSec mode
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
## Additional Observations
|
||||
|
||||
### Feature 1: Post-Import Success Notification
|
||||
1. **Dual Control Systems:** There are two overlapping control systems:
|
||||
- Feature flags (`feature.cerberus.enabled`) - toggled in SystemSettings.tsx
|
||||
- Security config (`SecurityConfig.Enabled` in DB) - used by Enable/Disable endpoints
|
||||
|
||||
- [ ] No `alert()` calls remain in import flow
|
||||
- [ ] Modal displays created/updated/skipped counts
|
||||
- [ ] Modal shows certificate provisioning guidance
|
||||
- [ ] Navigation buttons work correctly
|
||||
- [ ] Modal closes properly and clears state
|
||||
- [ ] Unit tests pass with >80% coverage
|
||||
Consider consolidating to one source of truth.
|
||||
|
||||
### Feature 2: Certificate Status Indicator
|
||||
2. **Config vs Settings:** The `config.SecurityConfig` struct loaded from env vars is separate from DB-backed `SecurityConfig` model. This creates confusion about which takes precedence.
|
||||
|
||||
- [ ] Dashboard shows certificate breakdown by status
|
||||
- [ ] Pending count reflects hosts without certificates
|
||||
- [ ] Progress bar animates as certs are provisioned
|
||||
- [ ] Auto-refresh when there are pending certificates
|
||||
- [ ] Links navigate to appropriate views
|
||||
- [ ] Unit tests pass with >80% coverage
|
||||
3. **No Migration:** When updating default values, existing users may need a migration or reset to see the new defaults.
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
## Code Reference Summary
|
||||
|
||||
- Existing modal pattern: [ImportSitesModal.tsx](../../../frontend/src/components/ImportSitesModal.tsx)
|
||||
- Dialog pattern: [CertificateCleanupDialog.tsx](../../../frontend/src/components/dialogs/CertificateCleanupDialog.tsx)
|
||||
- Toast utility: [toast.ts](../../../frontend/src/utils/toast.ts)
|
||||
- Certificate types: [certificates.ts](../../../frontend/src/api/certificates.ts)
|
||||
- Import hook: [useImport.ts](../../../frontend/src/hooks/useImport.ts)
|
||||
- Dashboard: [Dashboard.tsx](../../../frontend/src/pages/Dashboard.tsx)
|
||||
| File | Line | Purpose |
|
||||
|------|------|---------|
|
||||
| `feature_flags_handler.go` | L29-31 | Missing cerberus default |
|
||||
| `feature_flags_handler.go` | L39 | `defaultVal := true` bug |
|
||||
| `security_handler.go` | L37 | Wrong settings key |
|
||||
| `Security.tsx` | L126-128 | Disabled state logic |
|
||||
| `SystemSettings.tsx` | L99-105 | Feature toggle UI |
|
||||
| `docker-compose.override.yml` | L21 | CrowdSec mode env var |
|
||||
| `config.go` | L60 | Correct cerberus default |
|
||||
|
||||
Reference in New Issue
Block a user