From 8f146e08d65183409e0f31c0543f6cd45093cfd0 Mon Sep 17 00:00:00 2001 From: shankar0123 Date: Sat, 4 Apr 2026 19:27:01 -0400 Subject: [PATCH] feat(M36): onboarding wizard for first-run experience MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4-step wizard (Connect CA → Deploy Agent → Add Certificate → Done) shown on fresh installs when no user-configured issuers or certificates exist. Auto-seeded env var issuers (source="env") are excluded from first-run detection. Wizard state latches to prevent query refetches from dismissing it mid-flow. Split docker-compose into clean default (wizard-compatible) and demo override (seed_demo.sql). Added missing migrations 000009/000010 to test compose. Co-Authored-By: Claude Opus 4.6 --- deploy/docker-compose.demo.yml | 14 + deploy/docker-compose.test.yml | 6 +- deploy/docker-compose.yml | 5 +- web/src/pages/DashboardPage.tsx | 36 +- web/src/pages/OnboardingWizard.tsx | 692 +++++++++++++++++++++++++++++ 5 files changed, 748 insertions(+), 5 deletions(-) create mode 100644 deploy/docker-compose.demo.yml create mode 100644 web/src/pages/OnboardingWizard.tsx diff --git a/deploy/docker-compose.demo.yml b/deploy/docker-compose.demo.yml new file mode 100644 index 0000000..3e80c10 --- /dev/null +++ b/deploy/docker-compose.demo.yml @@ -0,0 +1,14 @@ +# Demo mode: pre-populated dashboard with 15 certificates, 5 agents, issuers, etc. +# Use this to showcase certctl's dashboard with realistic data. +# +# Usage: +# docker compose -f docker-compose.yml -f docker-compose.demo.yml up --build +# +# To start fresh (wipe previous data): +# docker compose -f docker-compose.yml -f docker-compose.demo.yml down -v +# docker compose -f docker-compose.yml -f docker-compose.demo.yml up --build + +services: + postgres: + volumes: + - ../migrations/seed_demo.sql:/docker-entrypoint-initdb.d/030_seed_demo.sql diff --git a/deploy/docker-compose.test.yml b/deploy/docker-compose.test.yml index 16f7340..7be3a03 100644 --- a/deploy/docker-compose.test.yml +++ b/deploy/docker-compose.test.yml @@ -45,8 +45,10 @@ services: - ../migrations/000006_discovery.up.sql:/docker-entrypoint-initdb.d/006_discovery.sql - ../migrations/000007_network_discovery.up.sql:/docker-entrypoint-initdb.d/007_network_discovery.sql - ../migrations/000008_verification.up.sql:/docker-entrypoint-initdb.d/008_verification.sql - - ../migrations/seed.sql:/docker-entrypoint-initdb.d/010_seed.sql - - ../migrations/seed_test.sql:/docker-entrypoint-initdb.d/015_seed_test.sql + - ../migrations/000009_issuer_config.up.sql:/docker-entrypoint-initdb.d/009_issuer_config.sql + - ../migrations/000010_target_config.up.sql:/docker-entrypoint-initdb.d/010_target_config.sql + - ../migrations/seed.sql:/docker-entrypoint-initdb.d/020_seed.sql + - ../migrations/seed_test.sql:/docker-entrypoint-initdb.d/025_seed_test.sql # No seed_demo.sql — start with a clean database for real testing networks: certctl-test: diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index ff506df..a43f841 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -19,8 +19,9 @@ services: - ../migrations/000006_discovery.up.sql:/docker-entrypoint-initdb.d/006_discovery.sql - ../migrations/000007_network_discovery.up.sql:/docker-entrypoint-initdb.d/007_network_discovery.sql - ../migrations/000008_verification.up.sql:/docker-entrypoint-initdb.d/008_verification.sql - - ../migrations/seed.sql:/docker-entrypoint-initdb.d/010_seed.sql - - ../migrations/seed_demo.sql:/docker-entrypoint-initdb.d/011_seed_demo.sql + - ../migrations/000009_issuer_config.up.sql:/docker-entrypoint-initdb.d/009_issuer_config.sql + - ../migrations/000010_target_config.up.sql:/docker-entrypoint-initdb.d/010_target_config.sql + - ../migrations/seed.sql:/docker-entrypoint-initdb.d/020_seed.sql networks: - certctl-network healthcheck: diff --git a/web/src/pages/DashboardPage.tsx b/web/src/pages/DashboardPage.tsx index 045ec7d..22bddc1 100644 --- a/web/src/pages/DashboardPage.tsx +++ b/web/src/pages/DashboardPage.tsx @@ -8,11 +8,12 @@ import { import { getCertificates, getAgents, getJobs, getNotifications, getHealth, getDashboardSummary, getCertificatesByStatus, getExpirationTimeline, - getJobTrends, getIssuanceRate, previewDigest, sendDigest, + getJobTrends, getIssuanceRate, previewDigest, sendDigest, getIssuers, } from '../api/client'; import PageHeader from '../components/PageHeader'; import StatusBadge from '../components/StatusBadge'; import { daysUntil, expiryColor, formatDate } from '../api/utils'; +import OnboardingWizard from './OnboardingWizard'; // Convert PascalCase status like "RenewalInProgress" to "Renewal In Progress" const formatStatus = (s: string) => s.replace(/([a-z])([A-Z])/g, '$1 $2'); @@ -162,8 +163,17 @@ function DigestCard() { export default function DashboardPage() { const navigate = useNavigate(); + // Onboarding wizard state: once shown, stays shown until explicitly dismissed. + // Uses a ref to "latch" the first-run detection so query refetches don't yank the wizard away. + const [onboardingDismissed, setOnboardingDismissed] = useState(() => { + try { return localStorage.getItem('certctl:onboarding-dismissed') === 'true'; } catch { return false; } + }); + const [showWizard, setShowWizard] = useState(false); + + // All hooks must be called unconditionally (React rules of hooks — no hooks after early returns) const { data: health } = useQuery({ queryKey: ['health'], queryFn: getHealth, refetchInterval: 30000 }); const { data: summary } = useQuery({ queryKey: ['dashboard-summary'], queryFn: getDashboardSummary, refetchInterval: 30000 }); + const { data: issuersData } = useQuery({ queryKey: ['issuers'], queryFn: () => getIssuers() }); const { data: statusCounts } = useQuery({ queryKey: ['certs-by-status'], queryFn: getCertificatesByStatus, refetchInterval: 30000 }); const { data: expirationTimeline } = useQuery({ queryKey: ['expiration-timeline'], queryFn: () => getExpirationTimeline(90), refetchInterval: 60000 }); const { data: jobTrends } = useQuery({ queryKey: ['job-trends'], queryFn: () => getJobTrends(30), refetchInterval: 30000 }); @@ -171,6 +181,30 @@ export default function DashboardPage() { const { data: certs } = useQuery({ queryKey: ['certificates', {}], queryFn: () => getCertificates(), refetchInterval: 30000 }); const { data: jobs } = useQuery({ queryKey: ['jobs', {}], queryFn: () => getJobs(), refetchInterval: 10000 }); + // Detect first-run ONCE: no user-configured issuers AND no certificates. + // Auto-seeded env var issuers (source="env") don't count — they exist on every fresh boot. + // Once showWizard latches true, it stays true until the user dismisses. + const userConfiguredIssuers = (issuersData?.data ?? []).filter((i: { source?: string }) => i.source !== 'env'); + const isFirstRun = !onboardingDismissed && + summary !== undefined && issuersData !== undefined && + summary.total_certificates === 0 && + userConfiguredIssuers.length === 0; + + if (isFirstRun && !showWizard) { + // Can't call setState during render — use a microtask + setTimeout(() => setShowWizard(true), 0); + } + + if (showWizard && !onboardingDismissed) { + return ( + { + try { localStorage.setItem('certctl:onboarding-dismissed', 'true'); } catch { /* noop */ } + setOnboardingDismissed(true); + setShowWizard(false); + }} /> + ); + } + const totalCerts = summary?.total_certificates || 0; const expiringSoon = summary?.expiring_certificates || 0; const expired = summary?.expired_certificates || 0; diff --git a/web/src/pages/OnboardingWizard.tsx b/web/src/pages/OnboardingWizard.tsx new file mode 100644 index 0000000..31edd13 --- /dev/null +++ b/web/src/pages/OnboardingWizard.tsx @@ -0,0 +1,692 @@ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useNavigate, Link } from 'react-router-dom'; +import { + getIssuers, getAgents, getProfiles, + createIssuer, testIssuerConnection, + createCertificate, triggerRenewal, + getApiKey, +} from '../api/client'; +import { issuerTypes, type IssuerTypeConfig } from '../config/issuerTypes'; +import ConfigForm from '../components/issuer/ConfigForm'; +import type { Issuer, Agent } from '../api/types'; + +// ─── Types ─────────────────────────────────────────── + +type WizardStep = 'issuer' | 'agent' | 'certificate' | 'complete'; + +const STEPS: { key: WizardStep; label: string }[] = [ + { key: 'issuer', label: 'Connect a CA' }, + { key: 'agent', label: 'Deploy Agent' }, + { key: 'certificate', label: 'Add Certificate' }, + { key: 'complete', label: 'Done' }, +]; + +// ─── Helpers ───────────────────────────────────────── + +function CodeBlock({ code, label }: { code: string; label?: string }) { + const [copied, setCopied] = useState(false); + return ( +
+ {label &&
{label}
} +
+        {code}
+      
+ +
+ ); +} + +function StepIndicator({ steps, current }: { steps: typeof STEPS; current: WizardStep }) { + const currentIdx = steps.findIndex(s => s.key === current); + return ( +
+ {steps.map((s, i) => { + const isCompleted = i < currentIdx; + const isCurrent = s.key === current; + return ( +
+
+ {isCompleted ? ( + + + + ) : i + 1} +
+ + {i < steps.length - 1 && ( +
+ )} +
+ ); + })} +
+ ); +} + +function WizardFooter({ onSkip, onNext, nextLabel, nextDisabled, showSkip = true }: { + onSkip?: () => void; + onNext?: () => void; + nextLabel?: string; + nextDisabled?: boolean; + showSkip?: boolean; +}) { + return ( +
+
+ {showSkip && onSkip && ( + + )} +
+ {onNext && ( + + )} +
+ ); +} + +// ─── Step 1: Connect a CA ──────────────────────────── + +function IssuerStep({ onNext, onSkip, onIssuerCreated }: { + onNext: () => void; + onSkip: () => void; + onIssuerCreated: (issuer: Issuer) => void; +}) { + const queryClient = useQueryClient(); + const [selectedType, setSelectedType] = useState(null); + const [configValues, setConfigValues] = useState>({}); + const [issuerName, setIssuerName] = useState(''); + const [error, setError] = useState(''); + const [testResult, setTestResult] = useState<{ ok: boolean; msg: string } | null>(null); + const [createdIssuer, setCreatedIssuer] = useState(null); + + const typeConfig = selectedType ? issuerTypes.find(t => t.id === selectedType) : null; + + const createMutation = useMutation({ + mutationFn: () => createIssuer({ + name: issuerName || `${typeConfig?.name || selectedType} Issuer`, + type: selectedType!, + config: configValues as Record, + }), + onSuccess: (issuer) => { + setCreatedIssuer(issuer); + onIssuerCreated(issuer); + queryClient.invalidateQueries({ queryKey: ['issuers'] }); + setError(''); + }, + onError: (err: Error) => setError(err.message), + }); + + const testMutation = useMutation({ + mutationFn: () => testIssuerConnection(createdIssuer!.id), + onSuccess: () => setTestResult({ ok: true, msg: 'Connection successful' }), + onError: (err: Error) => setTestResult({ ok: false, msg: err.message }), + }); + + // After issuer is created successfully + if (createdIssuer) { + return ( +
+

CA Connected

+
+
+ + + + + {createdIssuer.name} ({typeConfig?.name}) created successfully + +
+
+ + {!testResult && ( + + )} + + {testResult?.ok && ( +
+ Connection test passed. +
+ )} + {testResult && !testResult.ok && ( +
+ Connection test failed: {testResult.msg} +
+ )} + + +
+ ); + } + + // Type selection + if (!selectedType) { + return ( +
+

Connect a Certificate Authority

+

+ Choose a CA to issue and manage certificates. You can add more later from the Issuers page. +

+
+ {issuerTypes.filter(t => !t.comingSoon).map((type: IssuerTypeConfig) => ( + + ))} +
+ +
+ ); + } + + // Config form for selected type + const requiredFields = typeConfig?.configFields.filter(f => f.required) || []; + const allRequiredFilled = requiredFields.every(f => configValues[f.key]); + + return ( +
+
+ +

+ Configure {typeConfig?.name} +

+
+

{typeConfig?.description}

+ +
+ + setIssuerName(e.target.value)} + placeholder={`${typeConfig?.name || ''} Issuer`} + className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink placeholder-ink-faint focus:outline-none focus:border-brand-500 transition-colors" + /> +
+ + setConfigValues(prev => ({ ...prev, [key]: val }))} + /> + + {error && ( +
{error}
+ )} + + createMutation.mutate()} + nextLabel={createMutation.isPending ? 'Creating...' : 'Create Issuer'} + nextDisabled={!allRequiredFilled || createMutation.isPending} + /> +
+ ); +} + +// ─── Step 2: Deploy an Agent ───────────────────────── + +function AgentStep({ onNext, onSkip }: { onNext: () => void; onSkip: () => void }) { + const [activeTab, setActiveTab] = useState<'linux' | 'macos' | 'docker'>('linux'); + + const apiKey = getApiKey() || ''; + const serverUrl = typeof window !== 'undefined' ? `${window.location.protocol}//${window.location.hostname}:8443` : 'http://localhost:8443'; + + // Poll for agents every 5s + const { data: agents } = useQuery({ + queryKey: ['agents'], + queryFn: () => getAgents(), + refetchInterval: 5000, + }); + + const agentList = agents?.data || []; + const hasAgents = agentList.length > 0; + + const tabs = [ + { key: 'linux' as const, label: 'Linux' }, + { key: 'macos' as const, label: 'macOS' }, + { key: 'docker' as const, label: 'Docker' }, + ]; + + const commands: Record = { + linux: { + label: 'Install via shell script (systemd service)', + code: `curl -sSL https://raw.githubusercontent.com/shankar0123/certctl/master/install-agent.sh | bash + +# Then configure: +sudo systemctl edit certctl-agent +# Add: +# [Service] +# Environment="CERTCTL_SERVER_URL=${serverUrl}" +# Environment="CERTCTL_API_KEY=${apiKey}" + +sudo systemctl restart certctl-agent`, + }, + macos: { + label: 'Install via shell script (launchd service)', + code: `curl -sSL https://raw.githubusercontent.com/shankar0123/certctl/master/install-agent.sh | bash + +# Then configure: +# Edit /Library/LaunchDaemons/com.certctl.agent.plist +# Set CERTCTL_SERVER_URL to ${serverUrl} +# Set CERTCTL_API_KEY to ${apiKey} + +sudo launchctl unload /Library/LaunchDaemons/com.certctl.agent.plist +sudo launchctl load /Library/LaunchDaemons/com.certctl.agent.plist`, + }, + docker: { + label: 'Run as Docker container', + code: `docker run -d --name certctl-agent \\ + -e CERTCTL_SERVER_URL=${serverUrl} \\ + -e CERTCTL_API_KEY=${apiKey} \\ + ghcr.io/shankar0123/certctl-agent:latest`, + }, + }; + + return ( +
+

Deploy a certctl Agent

+

+ Agents run on your infrastructure to manage certificates, generate keys, and deploy to targets. + Install one now or skip to do it later. +

+ + {/* OS Tabs */} +
+ {tabs.map(t => ( + + ))} +
+ + + + {/* Agent detection */} +
+
+ {hasAgents ? ( + <> +
+
+
+ {agentList.length} agent{agentList.length !== 1 ? 's' : ''} detected +
+
+ {agentList.slice(0, 3).map(a => a.name || a.id).join(', ')} + {agentList.length > 3 && ` and ${agentList.length - 3} more`} +
+
+ + ) : ( + <> +
+
+ Waiting for an agent to connect... (polling every 5s) +
+ + )} +
+
+ + +
+ ); +} + +// ─── Step 3: Add a Certificate ─────────────────────── + +function CertificateStep({ onNext, onSkip, createdIssuerId }: { + onNext: (certName?: string) => void; + onSkip: () => void; + createdIssuerId: string | null; +}) { + const queryClient = useQueryClient(); + const [commonName, setCommonName] = useState(''); + const [sans, setSans] = useState(''); + const [issuerId, setIssuerId] = useState(createdIssuerId || ''); + const [profileId, setProfileId] = useState(''); + const [error, setError] = useState(''); + const [created, setCreated] = useState(false); + + const { data: issuers } = useQuery({ queryKey: ['issuers'], queryFn: () => getIssuers() }); + const { data: profiles } = useQuery({ queryKey: ['profiles'], queryFn: () => getProfiles() }); + const { data: agents } = useQuery({ queryKey: ['agents'], queryFn: () => getAgents() }); + + const hasAgents = (agents?.data?.length ?? 0) > 0; + + const createMutation = useMutation({ + mutationFn: async () => { + const sanList = sans.split(',').map(s => s.trim()).filter(Boolean); + const cert = await createCertificate({ + common_name: commonName, + sans: sanList, + issuer_id: issuerId, + certificate_profile_id: profileId || undefined, + environment: 'production', + }); + // Trigger issuance + await triggerRenewal(cert.id); + return cert; + }, + onSuccess: (cert) => { + setCreated(true); + queryClient.invalidateQueries({ queryKey: ['certificates'] }); + queryClient.invalidateQueries({ queryKey: ['dashboard-summary'] }); + setTimeout(() => onNext(cert.common_name), 1500); + }, + onError: (err: Error) => setError(err.message), + }); + + if (created) { + return ( +
+

Certificate Requested

+
+
+ + + + + Certificate for {commonName} has been requested. Moving to summary... + +
+
+
+ ); + } + + return ( +
+

Add a Certificate

+

+ Issue your first certificate, or skip this step and explore the dashboard. +

+ +
+
+ + setCommonName(e.target.value)} + placeholder="example.com" + className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink placeholder-ink-faint focus:outline-none focus:border-brand-500 transition-colors" + /> +
+ +
+ + setSans(e.target.value)} + placeholder="www.example.com, api.example.com" + className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink placeholder-ink-faint focus:outline-none focus:border-brand-500 transition-colors" + /> +
+ +
+
+ + +
+ +
+ + +
+
+
+ + {/* Discovery hint */} + {hasAgents && ( +
+ Already have certificates on disk?{' '} + Visit the Discovery page to + import and manage existing certificates found by your agents. +
+ )} + {!hasAgents && ( +
+ Tip: Deploy an agent with{' '} + CERTCTL_DISCOVERY_DIRS=/etc/ssl/certs{' '} + to automatically discover existing certificates on your infrastructure. +
+ )} + + {error && ( +
{error}
+ )} + + createMutation.mutate()} + nextLabel={createMutation.isPending ? 'Creating...' : 'Issue Certificate'} + nextDisabled={!commonName || !issuerId || createMutation.isPending} + /> +
+ ); +} + +// ─── Step 4: Complete ──────────────────────────────── + +function CompleteStep({ onFinish, issuerName, certName }: { + onFinish: () => void; + issuerName: string | null; + certName: string | null; +}) { + const { data: issuers } = useQuery({ queryKey: ['issuers'], queryFn: () => getIssuers() }); + const { data: agents } = useQuery({ queryKey: ['agents'], queryFn: () => getAgents() }); + + const issuerCount = issuers?.data?.length ?? 0; + const agentCount = agents?.data?.length ?? 0; + + return ( +
+
+ + + +
+ +

You're all set!

+

+ certctl is ready to manage your certificate lifecycle. Here's what's configured: +

+ + {/* Summary */} +
+
+
0 ? 'bg-emerald-100 text-emerald-600' : 'bg-gray-100 text-gray-400'}`}> + {issuerCount > 0 ? ( + + ) : '—'} +
+
+ + {issuerCount > 0 ? `${issuerCount} issuer${issuerCount !== 1 ? 's' : ''} configured` : 'No issuers configured'} + + {issuerName && ({issuerName})} +
+
+ +
+
0 ? 'bg-emerald-100 text-emerald-600' : 'bg-gray-100 text-gray-400'}`}> + {agentCount > 0 ? ( + + ) : '—'} +
+ + {agentCount > 0 ? `${agentCount} agent${agentCount !== 1 ? 's' : ''} connected` : 'No agents deployed yet'} + +
+ +
+
+ {certName ? ( + + ) : '—'} +
+ + {certName ? `Certificate requested: ${certName}` : 'No certificates added yet'} + +
+
+ + + + +
+ ); +} + +// ─── Main Wizard ───────────────────────────────────── + +export default function OnboardingWizard({ onDismiss }: { onDismiss: () => void }) { + const [step, setStep] = useState('issuer'); + const [createdIssuerId, setCreatedIssuerId] = useState(null); + const [issuerName, setIssuerName] = useState(null); + const [certName, setCertName] = useState(null); + const navigate = useNavigate(); + + const goTo = (s: WizardStep) => setStep(s); + + return ( + <> +
+
+

Welcome to certctl

+

Let's set up your certificate lifecycle management

+
+ +
+ +
+
+ + +
+ {step === 'issuer' && ( + goTo('agent')} + onSkip={() => goTo('agent')} + onIssuerCreated={(iss) => { setCreatedIssuerId(iss.id); setIssuerName(iss.name); }} + /> + )} + + {step === 'agent' && ( + goTo('certificate')} + onSkip={() => goTo('certificate')} + /> + )} + + {step === 'certificate' && ( + { if (name) setCertName(name); goTo('complete'); }} + onSkip={() => goTo('complete')} + createdIssuerId={createdIssuerId} + /> + )} + + {step === 'complete' && ( + { onDismiss(); navigate('/'); }} + issuerName={issuerName} + certName={certName} + /> + )} +
+
+
+ + ); +}