import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate, Link } from 'react-router-dom';
import {
getIssuers, getAgents, getProfiles, getOwners, getTeams, getPolicies,
createIssuer, testIssuerConnection,
createCertificate, triggerRenewal,
createTeam, createOwner,
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}
{s.label}
{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 helpers: inline team + owner creation ───
// Inline CreateTeamModal — mirrors TeamsPage.tsx CreateTeamModal pattern.
// Used inside CertificateStep so users can create a team without leaving the wizard.
function CreateTeamModalInline({ isOpen, onClose, onCreated }: {
isOpen: boolean;
onClose: () => void;
onCreated: (teamId: string) => void;
}) {
const queryClient = useQueryClient();
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [error, setError] = useState('');
const mutation = useMutation({
mutationFn: () => createTeam({ name: name.trim(), description: description.trim() }),
onSuccess: (team) => {
queryClient.invalidateQueries({ queryKey: ['teams'] });
setName('');
setDescription('');
setError('');
onCreated(team.id);
onClose();
},
onError: (err: Error) => setError(err.message),
});
if (!isOpen) return null;
return (
e.stopPropagation()}>
Create Team
{error &&
{error}
}
);
}
// Inline CreateOwnerModal — mirrors OwnersPage.tsx CreateOwnerModal pattern.
// Used inside CertificateStep so users can create an owner without leaving the wizard.
function CreateOwnerModalInline({ isOpen, onClose, onCreated, teams }: {
isOpen: boolean;
onClose: () => void;
onCreated: (ownerId: string) => void;
teams: { id: string; name: string }[];
}) {
const queryClient = useQueryClient();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [teamId, setTeamId] = useState('');
const [error, setError] = useState('');
const mutation = useMutation({
mutationFn: () => createOwner({
name: name.trim(),
email: email.trim(),
team_id: teamId || undefined,
}),
onSuccess: (owner) => {
queryClient.invalidateQueries({ queryKey: ['owners'] });
setName('');
setEmail('');
setTeamId('');
setError('');
onCreated(owner.id);
onClose();
},
onError: (err: Error) => setError(err.message),
});
if (!isOpen) return null;
return (
e.stopPropagation()}>
Create Owner
{error &&
{error}
}
);
}
// ─── Step 3: Add a Certificate ───────────────────────
function CertificateStep({ onNext, onSkip, createdIssuerId }: {
onNext: (certName?: string) => void;
onSkip: () => void;
createdIssuerId: string | null;
}) {
const queryClient = useQueryClient();
const [name, setName] = useState('');
const [commonName, setCommonName] = useState('');
const [sans, setSans] = useState('');
const [issuerId, setIssuerId] = useState(createdIssuerId || '');
const [profileId, setProfileId] = useState('');
const [ownerId, setOwnerId] = useState('');
const [teamId, setTeamId] = useState('');
const [renewalPolicyId, setRenewalPolicyId] = useState('');
const [error, setError] = useState('');
const [created, setCreated] = useState(false);
// Inline-create modals so users never have to leave the wizard (UX-001).
const [teamModalOpen, setTeamModalOpen] = useState(false);
const [ownerModalOpen, setOwnerModalOpen] = useState(false);
// C-001: the server requires name, common_name, issuer_id, owner_id,
// team_id, and renewal_policy_id (handler in
// internal/api/handler/certificates.go + ManagedCertificate.required in
// api/openapi.yaml). The wizard must collect the same six fields so that
// "Issue Certificate" doesn't 400 at the API boundary.
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({ per_page: '500' }) });
const { data: teams } = useQuery({ queryKey: ['teams'], queryFn: () => getTeams({ per_page: '500' }) });
const { data: policies } = useQuery({ queryKey: ['renewal-policies'], queryFn: () => getPolicies({ per_page: '500' }) });
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({
name,
common_name: commonName,
sans: sanList,
issuer_id: issuerId,
certificate_profile_id: profileId || undefined,
owner_id: ownerId,
team_id: teamId,
renewal_policy_id: renewalPolicyId,
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.
setName(e.target.value)}
placeholder="API Production Cert"
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"
/>
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 —{' '}
.
)}
{(teams?.data?.length ?? 0) === 0 && (
No teams yet —{' '}
.
)}
{(policies?.data?.length ?? 0) === 0 && (
No renewal policies yet — create one from the Policies 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={
!name ||
!commonName ||
!issuerId ||
!ownerId ||
!teamId ||
!renewalPolicyId ||
createMutation.isPending
}
/>
setTeamModalOpen(false)}
onCreated={(id) => setTeamId(id)}
/>
setOwnerModalOpen(false)}
onCreated={(id) => setOwnerId(id)}
teams={(teams?.data ?? []).map(t => ({ id: t.id, name: t.name }))}
/>
);
}
// ─── 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 ? `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}
/>
)}
>
);
}