import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useNavigate, Link } from 'react-router-dom'; import { getIssuers, getAgents, getProfiles, getOwners, 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(''); // Pre-populate default values when a type is selected (matches IssuersPage behavior) function handleTypeSelect(typeId: string) { setSelectedType(typeId); const tc = issuerTypes.find(t => t.id === typeId); const defaults: Record = {}; tc?.configFields.forEach(f => { if (f.defaultValue !== undefined) defaults[f.key] = f.defaultValue; }); setConfigValues(defaults); } 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: `# Non-interactive install (recommended for curl | bash): curl -sSL https://raw.githubusercontent.com/shankar0123/certctl/master/install-agent.sh \\ | sudo bash -s -- \\ --server-url ${serverUrl} \\ --api-key ${apiKey} # The script downloads the agent binary, writes /etc/certctl/agent.env, # installs /etc/systemd/system/certctl-agent.service, and starts it. # Check status with: sudo systemctl status certctl-agent`, }, macos: { label: 'Install via shell script (launchd service)', code: `# Non-interactive install (recommended for curl | bash): curl -sSL https://raw.githubusercontent.com/shankar0123/certctl/master/install-agent.sh \\ | bash -s -- \\ --server-url ${serverUrl} \\ --api-key ${apiKey} # The script writes ~/.certctl/agent.env and loads # ~/Library/LaunchAgents/com.certctl.agent.plist. # Check status with: launchctl list | grep certctl`, }, 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 [ownerId, setOwnerId] = 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 { data: owners } = useQuery({ queryKey: ['owners'], queryFn: () => getOwners() }); 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, owner_id: ownerId, 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" />
{(owners?.data?.length ?? 0) === 0 && (

No owners yet — create one from the Owners page first, then return here.

)}
{/* 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 || !ownerId || 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} /> )}
); }