merge: reorder wizard steps (domain after components), revamp review

# Conflicts:
#	products/catalyst/bootstrap/ui/src/pages/wizard/steps/StepReview.tsx
This commit is contained in:
hatiyildiz 2026-04-29 10:42:24 +02:00
commit c78041c518
5 changed files with 616 additions and 247 deletions

View File

@ -12,14 +12,23 @@ import { OOLogo } from '@/shared/ui/OOLogo'
* navigate. StepSuccess is the terminal destination after StepReview launches
* provisioning; it is not part of the visible progress, so it is not in this
* list. Closes #174.
*
* Order matches WizardPage.tsx:
* 1. Organisation (profile only no domain/email here anymore)
* 2. Provider (Hetzner credentials per region)
* 3. Credentials (SSH key)
* 4. Topology (sizing control-plane SKU, worker SKU+count, HA)
* 5. Components (unified marketplace catalog)
* 6. Domain (pool/BYO + admin email)
* 7. Review (POST body preview + launch)
*/
export const WIZARD_STEPS = [
{ id: 1, label: 'Organisation', desc: 'Name, domain, contact' },
{ id: 2, label: 'Domain', desc: 'Pool or BYO + delegation' },
{ id: 3, label: 'Topology', desc: 'Regions and clusters' },
{ id: 4, label: 'Provider', desc: 'Cloud provider per region' },
{ id: 5, label: 'Credentials', desc: 'API access tokens' },
{ id: 6, label: 'Components', desc: 'Platform building blocks' },
{ id: 1, label: 'Organisation', desc: 'Industry, size, HQ, compliance' },
{ id: 2, label: 'Provider', desc: 'Cloud provider per region' },
{ id: 3, label: 'Credentials', desc: 'API token + SSH key' },
{ id: 4, label: 'Topology', desc: 'Sizing and HA' },
{ id: 5, label: 'Components', desc: 'Platform building blocks' },
{ id: 6, label: 'Domain', desc: 'Pool or BYO + admin email' },
{ id: 7, label: 'Review', desc: 'Confirm and provision' },
]

View File

@ -10,10 +10,22 @@ import { StepComponents } from './steps/StepComponents'
import { StepReview } from './steps/StepReview'
import { StepSuccess } from './steps/StepSuccess'
// StepDomain promoted into its own step for #169 — three-mode (pool /
// byo-manual / byo-api) UX needs more vertical space than fits inside the
// org-profile step.
const STEPS = [StepOrg, StepDomain, StepTopology, StepProvider, StepCredentials, StepComponents, StepReview, StepSuccess]
// StepDomain was promoted into its own step for #169. The order has since
// been reworked so the operator picks the platform first (provider creds,
// SSH key, sizing, marketplace) and only then names the Sovereign in DNS:
//
// 1. StepOrg — org profile (name / industry / size / HQ /
// compliance). NO email or domain capture here —
// the admin email moved to StepDomain (it pairs
// naturally with the Sovereign's external surface).
// 2. StepProvider — Hetzner credentials.
// 3. StepCredentials — SSH key (auto-generate or paste).
// 4. StepTopology — sizing (control-plane + worker SKUs, HA flag).
// 5. StepComponents — unified marketplace catalog.
// 6. StepDomain — pool subdomain or BYO domain + admin email.
// 7. StepReview — single source of truth for the POST body.
// 8. StepSuccess — provisioning result.
const STEPS = [StepOrg, StepProvider, StepCredentials, StepTopology, StepComponents, StepDomain, StepReview, StepSuccess]
const variants = {
enter: (dir: number) => ({ x: dir > 0 ? 32 : -32, opacity: 0 }),

View File

@ -1,12 +1,21 @@
/**
* StepDomain sovereign-domain capture, three-mode (pool / byo-manual /
* byo-api). Closes #169 ([I] wizard: StepDomain Bring Your Own Domain).
* byo-api), plus the admin-contact email. Closes #169 ([I] wizard:
* StepDomain Bring Your Own Domain).
*
* The wizard's previous "domain" UX lived as a section inside StepOrg. With
* BYO bringing two delegation flows (manual NS edit, registrar-API NS flip)
* the section grew past what fits beneath the org-profile fields, so #169
* promotes it to its own step.
*
* The admin-contact email also lives on this step. It used to live on
* StepOrg next to the org name, which made the opening screen feel like a
* sign-up form and asked for personal contact data before the operator
* had any idea what they were configuring. Pairing the email with the
* Sovereign FQDN matches the way it's actually used downstream — Let's
* Encrypt registration, deployment-completion notifications, and the
* console's "platform owner" badge are all keyed off this address.
*
* All three modes end at the SAME outcome: a per-Sovereign zone exists in
* OpenOva PowerDNS so cert-manager DNS-01 + the sovereign LB can resolve.
*
@ -119,6 +128,8 @@ export function StepDomain() {
{store.sovereignDomainMode === 'pool' && <PoolModeBody availability={availability} />}
{store.sovereignDomainMode === 'byo-manual' && <ByoManualBody />}
{store.sovereignDomainMode === 'byo-api' && <ByoApiBody />}
<AdminEmailField />
</StepShell>
)
}
@ -127,6 +138,10 @@ function computeNextDisabled(
s: ReturnType<typeof useWizardStore.getState>,
availabilityStatus: import('@/shared/lib/useSubdomainAvailability').AvailabilityStatus,
): boolean {
// Admin email is required regardless of which domain mode the operator
// picked. cert-manager registers it as the Let's Encrypt account email,
// and the catalyst-api uses it for the deployment-completion notification.
if (!isValidAdminEmail(s.orgEmail)) return true
if (s.sovereignDomainMode === 'pool') {
if (!s.sovereignSubdomain) return true
return availabilityStatus !== 'available'
@ -143,6 +158,57 @@ function computeNextDisabled(
return true
}
/**
* Minimal RFC-5321-ish email validator. Accepts the common case
* (local@domain.tld) without trying to chase the full RFC. Empty / blank
* strings fail; the wizard's "default" placeholder ('platform@acme.io')
* passes on purpose so the operator can proceed without retyping when the
* pre-filled value matches their setup.
*/
function isValidAdminEmail(value: string): boolean {
const v = value.trim()
if (!v) return false
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)
}
function AdminEmailField() {
const store = useWizardStore()
const valid = !store.orgEmail || isValidAdminEmail(store.orgEmail)
return (
<fieldset
style={{
border: 'none',
padding: 0,
margin: 0,
display: 'flex',
flexDirection: 'column',
gap: 8,
paddingTop: 4,
}}
>
<legend style={{ fontSize: 12, fontWeight: 500, color: 'var(--wiz-text-lo)', marginBottom: 4 }}>
Admin contact email <span style={{ fontSize: 11, color: 'var(--wiz-text-hint)' }}>required</span>
</legend>
<input
type="email"
data-testid="admin-email-input"
placeholder="platform@acme.io"
value={store.orgEmail}
onChange={e => store.setOrgEmail(e.target.value)}
aria-invalid={!valid}
autoComplete="email"
spellCheck={false}
style={inputStyle(valid ? 'idle' : 'error')}
/>
<span style={{ fontSize: 11, color: 'var(--wiz-text-hint)', lineHeight: 1.5 }}>
Used as the Let's Encrypt account email for TLS issuance, and as the
deployment-completion notification address. We do not send marketing
from this address.
</span>
</fieldset>
)
}
function ModeCard({
id, label, sub, active, onSelect,
}: { id: string; label: string; sub: string; active: boolean; onSelect: () => void }) {

View File

@ -7,9 +7,26 @@ import { StepShell, useStepNav } from './_shared'
/**
* StepOrg captures the organisation profile.
*
* The Sovereign-domain capture used to live as a section inside this step;
* #169 promoted it to a dedicated StepDomain (next step) so the three-mode
* (pool / byo-manual / byo-api) UX can render at full width.
* Scope on this step is intentionally narrow: name, industry, size,
* headquarters, and compliance frameworks. These five inputs are the
* profile signal the rest of the wizard reads when proposing topology
* sizing and component defaults.
*
* What is NOT captured here:
* - Sovereign DNS surface (pool / BYO) lives in StepDomain.
* - Admin contact email also moved to StepDomain. The admin email
* pairs naturally with the deployment's external surface (cert
* issuance, registration notifications) and asking for it on the
* opening screen made the org profile feel like a sign-up form.
* - "Org domain" free-text field removed entirely. The Sovereign FQDN
* captured in StepDomain replaces it; the orgDomain store slot is
* preserved for backwards-compat with persisted state but is no
* longer rendered.
*
* #169 promoted the Sovereign-domain capture into a dedicated step so
* the three-mode (pool / byo-manual / byo-api) UX can render at full
* width; this revision goes the rest of the way and strips the residual
* domain + email inputs from the org page.
*/
const INDUSTRIES = [
@ -119,11 +136,6 @@ export function StepOrg() {
>
<div style={{ display: 'grid', gridTemplateColumns: bp === 'mobile' ? col1 : col2, gap: 14 }}>
<SmartField required label="Organisation name" defaultValue={ORG_DEFAULTS.name} value={store.orgName} onChange={store.setOrgName} />
<SmartField label="Domain" defaultValue={ORG_DEFAULTS.domain} value={store.orgDomain} onChange={store.setOrgDomain} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: bp === 'mobile' ? col1 : col2, gap: 14 }}>
<SmartField label="Platform team email" defaultValue={ORG_DEFAULTS.email} value={store.orgEmail} onChange={store.setOrgEmail} type="email" />
<SmartField label="Headquarters" defaultValue={ORG_DEFAULTS.headquarters} value={store.orgHeadquarters} onChange={store.setOrgHeadquarters} />
</div>

View File

@ -1,9 +1,35 @@
/**
* StepReview single source of truth for the POST body.
*
* Every section on this page corresponds 1:1 to a field that the
* `provision()` callback below sends to `POST /v1/deployments`. If a
* field is in the request body, it appears here; if a field is not in
* the request body, it is NOT shown here. That contract is what makes
* this step trustworthy as a "what am I about to launch" surface.
*
* Section order (matches the wizard's step order):
* 1. Organisation name / industry / size / HQ / compliance
* 2. Provider Hetzner project ID + masked token + region
* 3. SSH fingerprint + how the key was sourced
* 4. Topology control-plane SKU, worker SKU + count, HA flag
* 5. Components product-family summary (M / R / O counts)
* 6. Domain pool subdomain + FQDN OR BYO + admin email
*
* Per docs/INVIOLABLE-PRINCIPLES.md #10 (credential hygiene) the Hetzner
* token and any registrar token are rendered as a fixed-length mask plus
* the character count never the plaintext. Same posture as the SSH
* private key.
*/
import { useState } from 'react'
import { Zap } from 'lucide-react'
import { useWizardStore } from '@/entities/deployment/store'
import {
PROVIDER_REGIONS,
resolveSovereignDomain,
SOVEREIGN_POOL_DOMAINS,
} from '@/entities/deployment/model'
import type { CloudProvider } from '@/entities/deployment/model'
import { TOPOLOGY_REGION_LABELS, PROVIDER_REGIONS, resolveSovereignDomain, SOVEREIGN_POOL_DOMAINS } from '@/entities/deployment/model'
import { HETZNER_NODE_SIZES } from '@/shared/constants/hetzner'
import { useBreakpoint } from '@/shared/lib/useBreakpoint'
import { API_BASE, path } from '@/shared/config/urls'
import { StepShell, useStepNav } from './_shared'
@ -11,11 +37,11 @@ import { GROUPS } from './componentGroups'
/* ── Provider logos ──────────────────────────────────────────────── */
const PROVIDER_LOGOS: Record<CloudProvider, React.ReactNode> = {
hetzner: <svg viewBox="0 0 24 24" width={14} height={14} style={{flexShrink:0}}><rect width={24} height={24} rx={3} fill="#D50C2D"/><path d="M5 6h5v12H5zM14 6h5v12h-5z" fill="#fff"/></svg>,
huawei: <svg viewBox="0 0 24 24" width={14} height={14} style={{flexShrink:0}}><rect width={24} height={24} rx={3} fill="#CF0A2C"/><path d="M12 5L14 9.5L19 9.5L15 12.5L17 17L12 14L7 17L9 12.5L5 9.5L10 9.5Z" fill="#fff"/></svg>,
oci: <svg viewBox="0 0 24 24" width={14} height={14} style={{flexShrink:0}}><rect width={24} height={24} rx={3} fill="#F80000"/><ellipse cx={12} cy={12} rx={7} ry={4.5} fill="none" stroke="#fff" strokeWidth={1.5}/></svg>,
aws: <svg viewBox="0 0 24 24" width={14} height={14} style={{flexShrink:0}}><rect width={24} height={24} rx={3} fill="#232F3E"/><path d="M7 15c2.5 1.8 7.5 1.8 10 0" stroke="#FF9900" strokeWidth={1.5} fill="none" strokeLinecap="round"/><path d="M12 8v5" stroke="#FF9900" strokeWidth={1.5} strokeLinecap="round"/><path d="M10 11l2-3 2 3" stroke="#FF9900" strokeWidth={1.2} fill="none" strokeLinecap="round"/></svg>,
azure: <svg viewBox="0 0 24 24" width={14} height={14} style={{flexShrink:0}}><rect width={24} height={24} rx={3} fill="#0078D4"/><path d="M11 7L7 17h4l2-4 2 4h4L15 7z" fill="#fff" opacity={0.9}/></svg>,
hetzner: <svg viewBox="0 0 24 24" width={14} height={14} style={{ flexShrink: 0 }}><rect width={24} height={24} rx={3} fill="#D50C2D" /><path d="M5 6h5v12H5zM14 6h5v12h-5z" fill="#fff" /></svg>,
huawei: <svg viewBox="0 0 24 24" width={14} height={14} style={{ flexShrink: 0 }}><rect width={24} height={24} rx={3} fill="#CF0A2C" /><path d="M12 5L14 9.5L19 9.5L15 12.5L17 17L12 14L7 17L9 12.5L5 9.5L10 9.5Z" fill="#fff" /></svg>,
oci: <svg viewBox="0 0 24 24" width={14} height={14} style={{ flexShrink: 0 }}><rect width={24} height={24} rx={3} fill="#F80000" /><ellipse cx={12} cy={12} rx={7} ry={4.5} fill="none" stroke="#fff" strokeWidth={1.5} /></svg>,
aws: <svg viewBox="0 0 24 24" width={14} height={14} style={{ flexShrink: 0 }}><rect width={24} height={24} rx={3} fill="#232F3E" /><path d="M7 15c2.5 1.8 7.5 1.8 10 0" stroke="#FF9900" strokeWidth={1.5} fill="none" strokeLinecap="round" /><path d="M12 8v5" stroke="#FF9900" strokeWidth={1.5} strokeLinecap="round" /><path d="M10 11l2-3 2 3" stroke="#FF9900" strokeWidth={1.2} fill="none" strokeLinecap="round" /></svg>,
azure: <svg viewBox="0 0 24 24" width={14} height={14} style={{ flexShrink: 0 }}><rect width={24} height={24} rx={3} fill="#0078D4" /><path d="M11 7L7 17h4l2-4 2 4h4L15 7z" fill="#fff" opacity={0.9} /></svg>,
}
const PROVIDER_NAMES: Record<CloudProvider, string> = {
@ -26,20 +52,46 @@ const PROVIDER_NAMES: Record<CloudProvider, string> = {
azure: 'Microsoft Azure',
}
const TOPOLOGY_NAMES: Record<string, string> = {
citadel: 'CITADEL — 4 regions, 6 clusters, 6 vClusters',
dual: 'DUAL — 2 regions, 6 clusters, 6 vClusters',
zoned: 'ZONED — 2 regions, 4 clusters, 6 vClusters',
compact: 'COMPACT — 2 regions, 2 clusters, 6 vClusters',
solo: 'SOLO — 1 region, 1 cluster, 3 vClusters',
const DOMAIN_MODE_LABELS: Record<'pool' | 'byo-manual' | 'byo-api', string> = {
'pool': 'OpenOva pool domain',
'byo-manual': 'Bring Your Own — manual NS',
'byo-api': 'Bring Your Own — registrar API',
}
/* ── Section shell ───────────────────────────────────────────────── */
function Section({ title, children, style }: { title: React.ReactNode; children: React.ReactNode; style?: React.CSSProperties }) {
function Section({
title,
children,
style,
}: {
title: React.ReactNode
children: React.ReactNode
style?: React.CSSProperties
}) {
return (
<div style={{ borderRadius: 10, border: '1px solid var(--wiz-border-sub)', background: 'var(--wiz-bg-xs)', overflow: 'hidden', display: 'flex', flexDirection: 'column', ...style }}>
<div
style={{
borderRadius: 10,
border: '1px solid var(--wiz-border-sub)',
background: 'var(--wiz-bg-xs)',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
...style,
}}
>
<div style={{ padding: '6px 14px', borderBottom: '1px solid var(--wiz-border-sub)', flexShrink: 0 }}>
<span style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.12em', textTransform: 'uppercase', color: 'var(--wiz-text-sub)' }}>{title}</span>
<span
style={{
fontSize: 10,
fontWeight: 700,
letterSpacing: '0.12em',
textTransform: 'uppercase',
color: 'var(--wiz-text-sub)',
}}
>
{title}
</span>
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>{children}</div>
</div>
@ -49,9 +101,37 @@ function Section({ title, children, style }: { title: React.ReactNode; children:
/* ── Compact label/value row ─────────────────────────────────────── */
function Row({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div style={{ display: 'flex', alignItems: 'flex-start', padding: '5px 14px', borderBottom: '1px solid var(--wiz-border-sub)' }}>
<span style={{ width: 90, flexShrink: 0, fontSize: 10, fontWeight: 500, color: 'var(--wiz-text-sub)', lineHeight: 1.45 }}>{label}</span>
<span style={{ fontSize: 11, color: 'var(--wiz-text-md)', lineHeight: 1.45, wordBreak: 'break-all' }}>{value}</span>
<div
style={{
display: 'flex',
alignItems: 'flex-start',
padding: '5px 14px',
borderBottom: '1px solid var(--wiz-border-sub)',
}}
>
<span
style={{
width: 110,
flexShrink: 0,
fontSize: 10,
fontWeight: 500,
color: 'var(--wiz-text-sub)',
lineHeight: 1.45,
}}
>
{label}
</span>
<span
style={{
fontSize: 11,
color: 'var(--wiz-text-md)',
lineHeight: 1.45,
wordBreak: 'break-all',
flex: 1,
}}
>
{value}
</span>
</div>
)
}
@ -72,65 +152,122 @@ function GroupMiniCard({ gid }: { gid: string }) {
const hasAny = total > 0
return (
<div style={{
borderRadius: 8, padding: '8px 10px',
<div
style={{
borderRadius: 8,
padding: '8px 10px',
border: `1px solid ${hasAny ? 'var(--wiz-border-sub)' : 'rgba(255,255,255,0.04)'}`,
background: hasAny ? 'var(--wiz-bg-xs)' : 'transparent',
opacity: hasAny ? 1 : 0.38,
display: 'flex', flexDirection: 'column', gap: 4,
}}>
{/* Line 1: product name (left) + color-coded counts (right) */}
display: 'flex',
flexDirection: 'column',
gap: 4,
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 4 }}>
<span style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.06em', color: hasAny ? 'var(--wiz-text-hi)' : 'var(--wiz-text-hint)' }}>{group.productName}</span>
<span
style={{
fontSize: 10,
fontWeight: 700,
letterSpacing: '0.06em',
color: hasAny ? 'var(--wiz-text-hi)' : 'var(--wiz-text-hint)',
}}
>
{group.productName}
</span>
<div style={{ display: 'flex', gap: 3, flexShrink: 0 }}>
{counts.mandatory > 0 && (
<span style={{ fontSize: 9, fontWeight: 700, color: '#4ADE80', background: 'rgba(74,222,128,0.1)', borderRadius: 3, padding: '1px 5px' }}>M {counts.mandatory}</span>
<span
title="mandatory (incl. transitive-mandatory)"
style={{ fontSize: 9, fontWeight: 700, color: '#4ADE80', background: 'rgba(74,222,128,0.1)', borderRadius: 3, padding: '1px 5px' }}
>
M {counts.mandatory}
</span>
)}
{counts.recommended > 0 && (
<span style={{ fontSize: 9, fontWeight: 700, color: '#38BDF8', background: 'rgba(56,189,248,0.1)', borderRadius: 3, padding: '1px 5px' }}>R {counts.recommended}</span>
<span
title="recommended"
style={{ fontSize: 9, fontWeight: 700, color: '#38BDF8', background: 'rgba(56,189,248,0.1)', borderRadius: 3, padding: '1px 5px' }}
>
R {counts.recommended}
</span>
)}
{counts.optional > 0 && (
<span style={{ fontSize: 9, fontWeight: 700, color: '#A78BFA', background: 'rgba(167,139,250,0.1)', borderRadius: 3, padding: '1px 5px' }}>O {counts.optional}</span>
)}
{total === 0 && (
<span style={{ fontSize: 9, color: 'var(--wiz-text-hint)' }}></span>
<span
title="user-selected optional"
style={{ fontSize: 9, fontWeight: 700, color: '#A78BFA', background: 'rgba(167,139,250,0.1)', borderRadius: 3, padding: '1px 5px' }}
>
O {counts.optional}
</span>
)}
{total === 0 && <span style={{ fontSize: 9, color: 'var(--wiz-text-hint)' }}></span>}
</div>
</div>
{/* Line 2: conceptual product family name */}
<div style={{ fontSize: 9, color: 'var(--wiz-text-sub)', letterSpacing: '0.02em' }}>{group.subtitle}</div>
</div>
)
}
/* ── Helpers ─────────────────────────────────────────────────────── */
function maskToken(token: string): string {
if (!token) return '— not configured —'
return `•••••••••••• (${token.length} chars)`
}
function shortFingerprint(fp: string): string {
if (!fp) return ''
// Server-generated SSH fingerprints are SHA256:<base64>, ~50 chars.
// Truncate to prefix + last 8 for the review row.
if (fp.length <= 24) return fp
return `${fp.slice(0, 12)}${fp.slice(-8)}`
}
function dimIfMissing(value: string | null | undefined, fallback = '— not configured —'): React.ReactNode {
if (value === null || value === undefined || value === '') {
return <span style={{ color: 'var(--wiz-text-hint)' }}>{fallback}</span>
}
return value
}
/* ── StepReview ──────────────────────────────────────────────────── */
export function StepReview() {
const store = useWizardStore()
const { back } = useStepNav()
const bp = useBreakpoint()
const [loading, setLoading] = useState(false)
const isMobile = bp === 'mobile'
const topology = store.topology
const regionLabels = topology ? (TOPOLOGY_REGION_LABELS[topology] ?? []) : []
const regionProviders = store.regionProviders
/* ── Derived values for display + POST body ──────────────────── */
const sovereignFQDN = resolveSovereignDomain(store)
// Pool domain row label (pool mode only): the wizard stores the pool
// *id* ('omani-works'); the row needs the human-readable domain.
const poolDomainLabel =
SOVEREIGN_POOL_DOMAINS.find(p => p.id === store.sovereignPoolDomain)?.domain ?? store.sovereignPoolDomain
// Provider region for the first slot — solo topology is a single region;
// multi-region topologies treat slot 0 as primary. Mirrors the
// POST-body's `region` field in `provision()`.
const firstRegion = store.regionCloudRegions[0] ?? ''
const firstProvider = (store.regionProviders[0] as CloudProvider | undefined) ?? null
const firstRegionDef =
firstProvider && firstRegion
? PROVIDER_REGIONS[firstProvider].find(r => r.id === firstRegion)
: undefined
// Component totals for the section header.
const totalComponents = Object.values(store.componentGroups).reduce((s, ids) => s + ids.length, 0)
/* Include air-gap region if enabled */
const allRegionLabels = store.airgap
? [...regionLabels, 'AIR-GAP Region']
: regionLabels
// Worker SKU/count are nullable in some store states (pre-Item-2 selector
// wiring); render a placeholder rather than letting "undefined" reach
// the screen.
const workerSizeDisplay = store.workerSize ?? null
const workerCountDisplay =
typeof store.workerCount === 'number' && Number.isFinite(store.workerCount) ? store.workerCount : null
/* ── Submission ─────────────────────────────────────────────── */
async function provision() {
setLoading(true)
// Resolve the wizard's pool/byo state into a single FQDN that the
// catalyst-api ProvisionRequest understands. Per provisioner.go.Validate,
// this must be a non-empty hostname or the request is rejected.
const sovereignFQDN = resolveSovereignDomain(store)
// Pick the region for the first region slot (solo topology = single region).
// For multi-region topologies this is the "primary" region; the
// provisioner extends to the rest after the control plane is up.
const firstRegion = store.regionCloudRegions[0] ?? 'fsn1'
try {
const res = await fetch(`${API_BASE}/v1/deployments`, {
method: 'POST',
@ -142,33 +279,23 @@ export function StepReview() {
// Sovereign domain — pool subdomain or BYO
sovereignFQDN,
sovereignDomainMode: store.sovereignDomainMode,
sovereignPoolDomain:
// Map the wizard's pool ID ('omani-works') to the actual domain
// ('omani.works') by looking it up in SOVEREIGN_POOL_DOMAINS.
// Provisioner needs the literal domain string for Dynadot calls.
SOVEREIGN_POOL_DOMAINS.find(p => p.id === store.sovereignPoolDomain)?.domain ?? '',
sovereignPoolDomain: poolDomainLabel,
sovereignSubdomain: store.sovereignSubdomain,
// Hetzner credentials + region (runtime parameter)
hetznerToken: store.hetznerToken,
hetznerProjectID: store.hetznerProjectId,
region: firstRegion,
region: firstRegion || 'fsn1',
// Topology + sizing
controlPlaneSize: store.controlPlaneSize,
workerSize: store.workerSize,
workerCount: store.workerCount,
haEnabled: store.haEnabled,
// SSH key — captured in StepCredentials (#160). Either auto-generated
// by /api/v1/sshkey/generate (Mode A, private half downloaded once
// by the browser) or pasted by the operator (Mode B). The wizard's
// Continue button on the credentials step is gated on this string
// being non-empty + matching the OpenSSH algorithm allow-list.
// SSH key
sshPublicKey: store.sshPublicKey,
}),
})
const data = await res.json()
if (!res.ok) {
// Surface the validation error from provisioner.Validate to the user
// rather than silently swallowing it.
alert(`Provisioning rejected: ${data.error || 'unknown error'}`)
setLoading(false)
return
@ -182,182 +309,325 @@ export function StepReview() {
window.location.href = path('provision.html')
}
const isMobile = bp === 'mobile'
return (
<StepShell
title="Ready to launch"
description="Your full OpenOva ecosystem — infrastructure, platform stack, security, and observability — will be provisioned exactly as configured below."
description="Every value below is exactly what we'll send to the provisioning API. Use Back to amend any section — none of the steps lose state when you navigate."
onNext={provision}
onBack={back}
nextLabel={<><Zap size={13} style={{ marginRight: 5 }} />Launch OpenOva</>}
nextLabel={
<>
<Zap size={13} style={{ marginRight: 5 }} />
Launch OpenOva
</>
}
nextLoading={loading}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{/* ── Row 1: Organisation (1fr) + Components (2fr) ── */}
<div style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : '1fr 2fr',
gap: 14,
alignItems: 'stretch',
}}>
{/* Organisation */}
{/* ── 1. Organisation ──────────────────────────────────── */}
<Section title="Organisation">
<Row label="Name" value={store.orgName} />
<Row label="Domain" value={store.orgDomain} />
<Row label="Email" value={store.orgEmail} />
<Row label="Industry" value={store.orgIndustry} />
<Row label="Size" value={store.orgSize} />
<Row label="HQ" value={store.orgHeadquarters} />
<Row label="Compliance" value={
store.orgCompliance.length > 0
? <div style={{ display: 'flex', flexWrap: 'wrap', gap: 3 }}>
<Row label="Name" value={dimIfMissing(store.orgName)} />
<Row label="Industry" value={dimIfMissing(store.orgIndustry)} />
<Row label="Size" value={dimIfMissing(store.orgSize)} />
<Row label="HQ" value={dimIfMissing(store.orgHeadquarters)} />
<Row
label="Compliance"
value={
store.orgCompliance.length > 0 ? (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 3 }}>
{store.orgCompliance.map(t => (
<span key={t} style={{ fontSize: 9, padding: '1px 6px', borderRadius: 3, background: 'rgba(56,189,248,0.1)', border: '1px solid rgba(56,189,248,0.2)', color: '#38BDF8' }}>{t}</span>
<span
key={t}
style={{
fontSize: 9,
padding: '1px 6px',
borderRadius: 3,
background: 'rgba(56,189,248,0.1)',
border: '1px solid rgba(56,189,248,0.2)',
color: '#38BDF8',
}}
>
{t}
</span>
))}
</div>
: <span style={{ color: 'var(--wiz-text-hint)' }}>None</span>
} />
) : (
<span style={{ color: 'var(--wiz-text-hint)' }}>None selected</span>
)
}
/>
</Section>
{/* Components — 3×3 grid of group mini-cards */}
<Section title={`Components · ${totalComponents} selected`}>
<div style={{ padding: '10px 14px', display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8 }}>
{GROUPS.map(g => <GroupMiniCard key={g.id} gid={g.id} />)}
</div>
{/* ── 2. Provider ──────────────────────────────────────── */}
<Section title="Cloud Provider">
<Row
label="Provider"
value={
firstProvider ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
{PROVIDER_LOGOS[firstProvider]}
{PROVIDER_NAMES[firstProvider]}
</span>
) : (
<span style={{ color: 'var(--wiz-text-hint)' }}> not selected </span>
)
}
/>
<Row
label="Region"
value={
firstRegionDef ? (
<span>
<code style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 11 }}>{firstRegionDef.id}</code>
<span style={{ color: 'var(--wiz-text-sub)' }}> · {firstRegionDef.location}</span>
</span>
) : (
dimIfMissing(firstRegion)
)
}
/>
<Row label="Project ID" value={dimIfMissing(store.hetznerProjectId)} />
<Row
label="API token"
value={
<code style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 11, color: 'var(--wiz-text-md)' }}>
{maskToken(store.hetznerToken)}
</code>
}
/>
<Row
label="Token validated"
value={
store.credentialValidated ? (
<span style={{ color: '#4ADE80' }}>Yes</span>
) : (
<span style={{ color: 'var(--wiz-text-hint)' }}>No provisioner will re-check at apply time</span>
)
}
/>
</Section>
</div>
{/* ── Row 2: Infrastructure — full width ── */}
<Section title={
<span style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<span>Infrastructure</span>
{topology && (
<span style={{ fontSize: 10, fontWeight: 500, color: 'var(--wiz-text-md)', letterSpacing: 0 }}>
{TOPOLOGY_NAMES[topology]}
{/* ── 3. SSH ───────────────────────────────────────────── */}
<Section title="SSH Access">
<Row
label="Source"
value={
store.sshKeyGeneratedThisSession
? (
<span>
Auto-generated this session{' '}
<span style={{ color: 'var(--wiz-text-hint)' }}>(private key downloaded once)</span>
</span>
)
: store.sshPublicKey
? 'Pasted by operator'
: <span style={{ color: 'var(--wiz-text-hint)' }}> no key configured </span>
}
/>
<Row
label="Fingerprint"
value={
store.sshFingerprint ? (
<code style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 11 }}>
{shortFingerprint(store.sshFingerprint)}
</code>
) : store.sshPublicKey ? (
<span style={{ color: 'var(--wiz-text-hint)' }}>
not pre-computed server will derive at apply time
</span>
) : (
<span style={{ color: 'var(--wiz-text-hint)' }}></span>
)
}
/>
</Section>
{/* ── 4. Topology ──────────────────────────────────────── */}
<Section
title={
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<span>Topology</span>
{store.haEnabled && (
<span
style={{
fontSize: 9,
fontWeight: 700,
letterSpacing: '0.06em',
color: '#4ADE80',
background: 'rgba(74,222,128,0.12)',
border: '1px solid rgba(74,222,128,0.25)',
borderRadius: 3,
padding: '1px 6px',
}}
>
HA
</span>
)}
{store.airgap && (
<span style={{ fontSize: 9, fontWeight: 700, color: '#F59E0B', background: 'rgba(245,158,11,0.12)', border: '1px solid rgba(245,158,11,0.25)', borderRadius: 3, padding: '1px 6px', letterSpacing: '0.04em' }}>AIR-GAP</span>
<span
style={{
fontSize: 9,
fontWeight: 700,
color: '#F59E0B',
background: 'rgba(245,158,11,0.12)',
border: '1px solid rgba(245,158,11,0.25)',
borderRadius: 3,
padding: '1px 6px',
letterSpacing: '0.04em',
}}
>
AIR-GAP
</span>
)}
</span>
}>
{/* Node sizing — control plane + worker SKU + worker count */}
{(() => {
const cp = HETZNER_NODE_SIZES.find(s => s.id === store.controlPlaneSize)
const wk = HETZNER_NODE_SIZES.find(s => s.id === store.workerSize)
const regionCount = allRegionLabels.length || 1
const cpHourly = cp?.priceHour ?? 0
const wkHourly = (wk?.priceHour ?? 0) * store.workerCount
const totalHour = (cpHourly + wkHourly) * regionCount
return (
<div style={{
}
>
<Row label="Template" value={dimIfMissing(store.topology)} />
<Row label="Control plane SKU" value={dimIfMissing(store.controlPlaneSize)} />
<Row
label="Worker SKU"
value={dimIfMissing(workerSizeDisplay, '— not yet selected —')}
/>
<Row
label="Worker count"
value={
workerCountDisplay === null
? <span style={{ color: 'var(--wiz-text-hint)' }}> not yet selected </span>
: workerCountDisplay
}
/>
<Row label="HA" value={store.haEnabled ? 'Enabled' : 'Disabled'} />
</Section>
{/* ── 5. Components ────────────────────────────────────── */}
<Section title={`Components · ${totalComponents} selected`}>
<div
style={{
padding: '10px 14px',
borderBottom: '1px solid var(--wiz-border-sub)',
display: 'flex', flexWrap: 'wrap', gap: 12, alignItems: 'center',
}}>
{/* Control plane chip */}
<div style={{
display: 'flex', flexDirection: 'column', gap: 2,
padding: '6px 10px', borderRadius: 7,
border: '1px solid rgba(56,189,248,0.25)',
background: 'rgba(56,189,248,0.05)',
}}>
<span style={{ fontSize: 8, fontWeight: 700, letterSpacing: '0.1em', textTransform: 'uppercase', color: 'var(--wiz-accent)' }}>Control plane</span>
<span style={{ fontSize: 11, fontWeight: 700, color: 'var(--wiz-text-hi)' }}>
{cp ? `${cp.label} · ${cp.vcpu} vCPU · ${cp.ram} GB` : store.controlPlaneSize}
display: 'grid',
gridTemplateColumns: isMobile ? '1fr 1fr' : 'repeat(3, 1fr)',
gap: 8,
}}
>
{GROUPS.map(g => <GroupMiniCard key={g.id} gid={g.id} />)}
</div>
<div
style={{
padding: '8px 14px',
borderTop: '1px solid var(--wiz-border-sub)',
display: 'flex',
gap: 12,
flexWrap: 'wrap',
fontSize: 10,
color: 'var(--wiz-text-sub)',
}}
>
<span>
<span style={{ color: '#4ADE80', fontWeight: 700 }}>M</span> mandatory (incl. transitive)
</span>
<span style={{ fontSize: 9, color: 'var(--wiz-text-sub)', fontFamily: 'JetBrains Mono, monospace' }}>
{store.haEnabled ? '3 nodes (HA)' : '1 node'} per region
<span>
<span style={{ color: '#38BDF8', fontWeight: 700 }}>R</span> recommended
</span>
</div>
{/* Worker chip */}
<div style={{
display: 'flex', flexDirection: 'column', gap: 2,
padding: '6px 10px', borderRadius: 7,
border: store.workerCount > 0 ? '1px solid rgba(167,139,250,0.3)' : '1px dashed var(--wiz-border-sub)',
background: store.workerCount > 0 ? 'rgba(167,139,250,0.05)' : 'transparent',
}}>
<span style={{ fontSize: 8, fontWeight: 700, letterSpacing: '0.1em', textTransform: 'uppercase', color: store.workerCount > 0 ? '#A78BFA' : 'var(--wiz-text-hint)' }}>Workers</span>
{store.workerCount > 0 ? (
<>
<span style={{ fontSize: 11, fontWeight: 700, color: 'var(--wiz-text-hi)' }}>
{wk ? `${wk.label} · ${wk.vcpu} vCPU · ${wk.ram} GB` : store.workerSize}
<span>
<span style={{ color: '#A78BFA', fontWeight: 700 }}>O</span> user-selected optional
</span>
<span style={{ fontSize: 9, color: 'var(--wiz-text-sub)', fontFamily: 'JetBrains Mono, monospace' }}>
{store.workerCount} per region
</span>
</>
) : (
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--wiz-text-sub)' }}>
None control plane carries workloads
</span>
)}
</div>
{/* Cost rollup */}
<div style={{
marginLeft: 'auto',
display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 2,
}}>
<span style={{ fontSize: 8, fontWeight: 700, letterSpacing: '0.1em', textTransform: 'uppercase', color: 'var(--wiz-text-hint)' }}>Compute total</span>
<span style={{ fontSize: 12, fontWeight: 700, color: 'var(--wiz-text-hi)', fontFamily: 'JetBrains Mono, monospace' }}>
{totalHour.toFixed(3)}/hr
</span>
<span style={{ fontSize: 9, color: 'var(--wiz-text-sub)' }}>
across {regionCount} region{regionCount !== 1 ? 's' : ''}
</span>
</div>
</div>
)
})()}
{/* Region cards — flex row, all equal height, 15 cards */}
<div style={{ padding: '10px 14px', display: 'flex', gap: 8, alignItems: 'stretch', flexWrap: 'wrap' }}>
{allRegionLabels.map((rl, i) => {
const isAirgap = store.airgap && i === regionLabels.length
const p = regionProviders[i] as CloudProvider | undefined
const cloudRegionId = store.regionCloudRegions[i]
const cloudRegionDef = p && cloudRegionId ? PROVIDER_REGIONS[p].find(r => r.id === cloudRegionId) : undefined
return (
<div key={i} style={{
flex: '1 1 0',
minWidth: isMobile ? '100%' : 0,
borderRadius: 8, padding: '8px 10px',
border: `1px solid ${isAirgap ? 'rgba(245,158,11,0.3)' : 'var(--wiz-border-sub)'}`,
background: isAirgap ? 'rgba(245,158,11,0.03)' : 'var(--wiz-bg-xs)',
display: 'flex', flexDirection: 'column', gap: 4,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
<span style={{ fontSize: 9, fontWeight: 700, color: isAirgap ? '#F59E0B' : 'var(--wiz-accent)', width: 12, flexShrink: 0 }}>{i + 1}</span>
{isAirgap && <span style={{ fontSize: 8, fontWeight: 700, color: '#F59E0B', background: 'rgba(245,158,11,0.12)', borderRadius: 3, padding: '0 4px' }}>AIR-GAP</span>}
</div>
<div style={{ fontSize: 10, color: 'var(--wiz-text-lo)', lineHeight: 1.3 }}>{rl}</div>
{p && (
<div style={{ marginTop: 4, display: 'flex', flexDirection: 'column', gap: 3 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
{PROVIDER_LOGOS[p]}
<span style={{ fontSize: 10, color: 'var(--wiz-text-sub)' }}>{PROVIDER_NAMES[p]}</span>
</div>
{cloudRegionDef && (
<div style={{ fontSize: 9, color: 'var(--wiz-text-hint)' }}>{cloudRegionDef.label} {cloudRegionDef.location}</div>
)}
</div>
)}
{!p && <div style={{ fontSize: 10, color: 'var(--wiz-text-hint)', marginTop: 4 }}>Not configured</div>}
</div>
)
})}
</div>
</Section>
{/* Privacy note */}
<div style={{ borderRadius: 8, padding: '9px 12px', background: 'rgba(56,189,248,0.04)', border: '1px solid rgba(56,189,248,0.1)' }}>
{/* ── 6. Domain ────────────────────────────────────────── */}
<Section title="Domain">
<Row label="Mode" value={DOMAIN_MODE_LABELS[store.sovereignDomainMode]} />
{store.sovereignDomainMode === 'pool' ? (
<>
<Row
label="Subdomain"
value={
store.sovereignSubdomain ? (
<code style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 11 }}>
{store.sovereignSubdomain}
</code>
) : (
<span style={{ color: 'var(--wiz-text-hint)' }}> not chosen </span>
)
}
/>
<Row
label="Pool domain"
value={
<code style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 11 }}>
{poolDomainLabel}
</code>
}
/>
</>
) : (
<>
<Row
label="BYO domain"
value={
store.sovereignByoDomain ? (
<code style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 11 }}>
{store.sovereignByoDomain}
</code>
) : (
<span style={{ color: 'var(--wiz-text-hint)' }}> not entered </span>
)
}
/>
{store.sovereignDomainMode === 'byo-api' && (
<>
<Row label="Registrar" value={dimIfMissing(store.registrarType)} />
<Row
label="Registrar token"
value={
<code style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 11, color: 'var(--wiz-text-md)' }}>
{maskToken(store.registrarToken)}
</code>
}
/>
<Row
label="Token validated"
value={
store.registrarTokenValidated ? (
<span style={{ color: '#4ADE80' }}>Yes</span>
) : (
<span style={{ color: '#F87171' }}>
No return to the Domain step to validate before launch
</span>
)
}
/>
</>
)}
</>
)}
<Row
label="Resolved FQDN"
value={
sovereignFQDN ? (
<code style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 11, color: '#38BDF8' }}>
console.{sovereignFQDN}
</code>
) : (
<span style={{ color: 'var(--wiz-text-hint)' }}> not yet resolvable </span>
)
}
/>
<Row label="Admin email" value={dimIfMissing(store.orgEmail)} />
</Section>
{/* ── Privacy note ─────────────────────────────────────── */}
<div
style={{
borderRadius: 8,
padding: '9px 12px',
background: 'rgba(56,189,248,0.04)',
border: '1px solid rgba(56,189,248,0.1)',
}}
>
<p style={{ fontSize: 11, color: 'var(--wiz-text-sub)', margin: 0, lineHeight: 1.6 }}>
Provisioning runs entirely within your cloud account. OpenOva never stores your credentials or accesses your infrastructure after this session.
Provisioning runs entirely within your cloud account. OpenOva never stores your credentials or accesses
your infrastructure after this session.
</p>
</div>
</div>