# Feature Spec: Post-Import Notification & Certificate Status Dashboard **Status:** Planning **Created:** December 11, 2025 **Priority:** Medium --- ## Overview 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 --- ## Feature 1: Post-Import Success Notification ### Current State In [ImportCaddy.tsx](../../../frontend/src/pages/ImportCaddy.tsx#L42), after a successful import commit: ```tsx const handleCommit = async (resolutions: Record, names: Record) => { 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, names: Record ): Promise => { const { data } = await client.post('/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: { 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
SSL Certificates
{certificates.length}
{certificates.filter(c => c.status === 'valid').length} valid
``` ### 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: ```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 } 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: ```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, }) } ``` **Recommendation:** Start with Option A (client-side) for simplicity. #### 3. Create CertificateStatusCard Component **File:** `frontend/src/components/CertificateStatusCard.tsx` ```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 (
SSL Certificates
{certificates.length}
{/* Status breakdown */}
{validCount} valid {expiringCount > 0 && {expiringCount} expiring} {untrustedCount > 0 && {untrustedCount} staging}
{/* Pending indicator */} {hasProvisioning && (
... {pendingCount} host{pendingCount > 1 ? 's' : ''} awaiting certificate
)} ) } ``` #### 4. Update Dashboard.tsx **File:** `frontend/src/pages/Dashboard.tsx` ```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: ``` #### 5. Update useCertificates Hook **File:** `frontend/src/hooks/useCertificates.ts` ```typescript interface UseCertificatesOptions { refetchInterval?: number | false } export function useCertificates(options?: UseCertificatesOptions) { const { data, isLoading, error, refetch } = useQuery({ queryKey: ['certificates'], queryFn: getCertificates, refetchInterval: options?.refetchInterval, }) return { certificates: data || [], isLoading, error, refetch, } } ``` --- ## File Changes Summary ### New Files | File | Description | |------|-------------| | `frontend/src/components/dialogs/ImportSuccessModal.tsx` | Modal for import completion | | `frontend/src/components/CertificateStatusCard.tsx` | Dashboard card with cert status | ### Modified Files | 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 | ### Optional Backend Changes (for enhanced accuracy) | File | Changes | |------|---------| | `backend/internal/api/handlers/certificate_handler.go` | Add `/certificates/summary` endpoint | | `backend/internal/api/routes/routes.go` | Register summary route | --- ## Unit Test Requirements ### Feature 1: ImportSuccessModal **File:** `frontend/src/components/dialogs/__tests__/ImportSuccessModal.test.tsx` ```typescript describe('ImportSuccessModal', () => { it('renders import summary correctly', () => { render() 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() expect(screen.getByText(/certificate provisioning/i)).toBeInTheDocument() }) it('shows errors when present', () => { render() expect(screen.getByText('example.com: duplicate')).toBeInTheDocument() }) it('calls onNavigateDashboard when clicking Dashboard button', () => { const onNavigate = vi.fn() render() fireEvent.click(screen.getByText('Go to Dashboard')) expect(onNavigate).toHaveBeenCalled() }) it('does not render when visible is false', () => { render() expect(screen.queryByRole('dialog')).not.toBeInTheDocument() }) }) ``` ### Feature 2: CertificateStatusCard **File:** `frontend/src/components/__tests__/CertificateStatusCard.test.tsx` ```typescript describe('CertificateStatusCard', () => { it('shows valid certificate count', () => { render() 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() 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() expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument() }) it('shows expiring count when certificates are expiring', () => { const expiringCerts = [{ ...mockCert, status: 'expiring' }] render() expect(screen.getByText('1 expiring')).toBeInTheDocument() }) it('shows staging count for untrusted certificates', () => { const stagingCerts = [{ ...mockCert, status: 'untrusted' }] render() 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() const progressBar = container.querySelector('[style*="width: 50%"]') expect(progressBar).toBeInTheDocument() }) }) ``` ### Integration Tests for useImport Hook **File:** `frontend/src/hooks/__tests__/useImport.test.tsx` ```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) }) }) ``` --- ## UI/UX Design Notes ### 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% β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` --- ## Implementation Order 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) --- ## Acceptance Criteria ### Feature 1: Post-Import Success Notification - [ ] 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 ### Feature 2: Certificate Status Indicator - [ ] 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 --- ## References - 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)