import { useEffect, useState } from 'react' import { beginSetupTOTP, completeSetup, DEFAULT_NETWORK_SETTINGS, DEFAULT_SECURITY_SETTINGS, previewNetworkAccess, type AccessMode, type NetworkSettings, type SecuritySettings, verifySetupTOTP, } from './api/client' import { validateHostname, validateListenAddress, validatePassword, } from './passwordPolicy' type SetupWizardProps = { clientIp: string onCompleted: () => void } type WizardStep = 0 | 1 | 2 | 3 export function SetupWizard({ clientIp, onCompleted }: SetupWizardProps) { const [step, setStep] = useState(0) const [username, setUsername] = useState('Admin') const [password, setPassword] = useState('') const [passwordConfirm, setPasswordConfirm] = useState('') const [setupTotp, setSetupTotp] = useState(true) const [totpSecret, setTotpSecret] = useState('') const [totpUrl, setTotpUrl] = useState('') const [totpCode, setTotpCode] = useState('') const [totpConfirmed, setTotpConfirmed] = useState(false) const [qrDataUrl, setQrDataUrl] = useState('') const [network, setNetwork] = useState(DEFAULT_NETWORK_SETTINGS) const [rulesText, setRulesText] = useState('') const [lockoutWarning, setLockoutWarning] = useState(null) const [security, setSecurity] = useState( DEFAULT_SECURITY_SETTINGS, ) const [errorMessage, setErrorMessage] = useState(null) const [isSubmitting, setIsSubmitting] = useState(false) useEffect(() => { if (!totpUrl) { setQrDataUrl('') return } let cancelled = false void import('qrcode').then(async (qrcode) => { const dataUrl = await qrcode.toDataURL(totpUrl, { width: 220, margin: 1 }) if (!cancelled) { setQrDataUrl(dataUrl) } }) return () => { cancelled = true } }, [totpUrl]) useEffect(() => { if (step !== 2) { return } const rules = rulesText .split('\n') .map((line) => line.trim()) .filter(Boolean) const draft: NetworkSettings = { ...network, rules } let cancelled = false void previewNetworkAccess(draft) .then((preview) => { if (cancelled) { return } if (preview.would_lock_out) { setLockoutWarning( preview.reason || `These rules may lock out your current IP (${preview.client_ip}).`, ) } else { setLockoutWarning(null) } }) .catch(() => { if (!cancelled) { setLockoutWarning(null) } }) return () => { cancelled = true } }, [step, network, rulesText]) async function handleAdminNext() { setErrorMessage(null) const passwordError = validatePassword(password, username) if (passwordError) { setErrorMessage(passwordError) return } if (password !== passwordConfirm) { setErrorMessage('Passwords do not match') return } if (!setupTotp) { setTotpSecret('') setTotpConfirmed(false) setStep(2) return } if (!totpConfirmed) { try { setIsSubmitting(true) if (!totpSecret) { const began = await beginSetupTOTP(username.trim() || 'Admin') setTotpSecret(began.secret) setTotpUrl(began.otpauth_url) return } if (!totpCode.trim()) { setErrorMessage('Enter the TOTP code from your authenticator') return } await verifySetupTOTP(totpSecret, totpCode.trim()) setTotpConfirmed(true) setSecurity((previous) => ({ ...previous, totp_enabled: true })) setStep(2) } catch (error) { setErrorMessage( error instanceof Error ? error.message : 'TOTP setup failed', ) } finally { setIsSubmitting(false) } return } setStep(2) } function handleNetworkNext() { setErrorMessage(null) const listenError = validateListenAddress(network.listen_address) if (listenError) { setErrorMessage(listenError) return } const hostnameError = validateHostname(network.public_hostname) if (hostnameError) { setErrorMessage(hostnameError) return } if (lockoutWarning) { setErrorMessage( `${lockoutWarning} Adjust the rules before continuing.`, ) return } const rules = rulesText .split('\n') .map((line) => line.trim()) .filter(Boolean) setNetwork((previous) => ({ ...previous, rules })) setStep(3) } async function handleFinish() { setErrorMessage(null) setIsSubmitting(true) try { const rules = rulesText .split('\n') .map((line) => line.trim()) .filter(Boolean) const finalSecurity = totpConfirmed ? { ...security, totp_enabled: true } : security await completeSetup({ username: username.trim() || 'Admin', password, totp_secret: totpConfirmed ? totpSecret : '', totp_confirmed: totpConfirmed, network: { ...network, rules }, security: finalSecurity, }) onCompleted() } catch (error) { setErrorMessage( error instanceof Error ? error.message : 'Setup failed', ) } finally { setIsSubmitting(false) } } return (

Cluster Canvas

Setup wizard

Step {step + 1} of 4

{errorMessage ? (

{errorMessage}

) : null} {step === 0 ? (

Welcome

Before you can use Cluster Canvas, a few settings need to be filled in: an administrator account, network access rules, and server security policy.

This wizard walks through those steps once. After it finishes you will sign in with the admin account you create.

) : null} {step === 1 ? (

Administrator account

setUsername(event.target.value)} />
setPassword(event.target.value)} />

At least 14 characters, or a passphrase of at least 5 words. Maximum 256 characters.

setPasswordConfirm(event.target.value)} />
{setupTotp && totpSecret ? (
{qrDataUrl ? ( TOTP QR code ) : (

Generating QR code…

)}

Or copy this secret into your authenticator:

{totpSecret}
setTotpCode(event.target.value)} inputMode="numeric" autoComplete="one-time-code" />
) : null}
) : null} {step === 2 ? (

Network setup

setNetwork((previous) => ({ ...previous, listen_address: event.target.value, })) } />

Default 0.0.0.0 (all interfaces). Applied on next process restart.

setNetwork((previous) => ({ ...previous, public_hostname: event.target.value, })) } />

Recommended for production behind a reverse proxy (HTTPS). Leave empty for local/remotedev access by IP address — filling this in requires visiting the UI via that exact hostname.

{network.access_mode !== 'open' ? (