Files
ClusterCanvas/webui/src/SetupWizard.tsx
T
Squid 5d6f65f3cd Style Access mode selects like other dropdowns.
Use theme-select so Access mode shows a chevron and matches Theme/Scope kind.
2026-07-18 15:24:37 +02:00

568 lines
18 KiB
TypeScript

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<WizardStep>(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<NetworkSettings>(DEFAULT_NETWORK_SETTINGS)
const [rulesText, setRulesText] = useState('')
const [lockoutWarning, setLockoutWarning] = useState<string | null>(null)
const [security, setSecurity] = useState<SecuritySettings>(
DEFAULT_SECURITY_SETTINGS,
)
const [errorMessage, setErrorMessage] = useState<string | null>(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 (
<div className="wizard-shell">
<div className="wizard-card">
<p className="wizard-brand">Cluster Canvas</p>
<h1 className="wizard-title">Setup wizard</h1>
<p className="wizard-step-indicator">Step {step + 1} of 4</p>
{errorMessage ? (
<p className="wizard-error" role="alert">
{errorMessage}
</p>
) : null}
{step === 0 ? (
<div className="wizard-body">
<h2>Welcome</h2>
<p>
Before you can use Cluster Canvas, a few settings need to be
filled in: an administrator account, network access rules, and
server security policy.
</p>
<p>
This wizard walks through those steps once. After it finishes you
will sign in with the admin account you create.
</p>
<div className="wizard-actions">
<button
type="button"
className="groups-save"
onClick={() => setStep(1)}
>
Continue
</button>
</div>
</div>
) : null}
{step === 1 ? (
<div className="wizard-body">
<h2>Administrator account</h2>
<div className="config-field">
<label htmlFor="setup-username">Login name</label>
<input
id="setup-username"
className="config-input"
value={username}
onChange={(event) => setUsername(event.target.value)}
/>
</div>
<div className="config-field">
<label htmlFor="setup-password">Password</label>
<input
id="setup-password"
className="config-input"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
<p className="config-hint">
At least 14 characters, or a passphrase of at least 5 words.
Maximum 256 characters.
</p>
</div>
<div className="config-field">
<label htmlFor="setup-password-confirm">Confirm password</label>
<input
id="setup-password-confirm"
className="config-input"
type="password"
value={passwordConfirm}
onChange={(event) => setPasswordConfirm(event.target.value)}
/>
</div>
<div className="config-field">
<label className="permission-option" htmlFor="setup-totp">
<input
id="setup-totp"
type="checkbox"
checked={setupTotp}
onChange={(event) => {
setSetupTotp(event.target.checked)
setTotpConfirmed(false)
setTotpSecret('')
setTotpUrl('')
setTotpCode('')
}}
/>
Set up TOTP now (recommended)
</label>
</div>
{setupTotp && totpSecret ? (
<div className="totp-setup">
{qrDataUrl ? (
<img
className="totp-qr"
src={qrDataUrl}
alt="TOTP QR code"
width={220}
height={220}
/>
) : (
<p className="config-hint">Generating QR code</p>
)}
<p className="config-hint">
Or copy this secret into your authenticator:
</p>
<code className="totp-secret">{totpSecret}</code>
<button
type="button"
className="config-action-revert"
onClick={() => void navigator.clipboard.writeText(totpUrl || totpSecret)}
>
Copy setup URI
</button>
<div className="config-field">
<label htmlFor="setup-totp-code">Verification code</label>
<input
id="setup-totp-code"
className="config-input"
value={totpCode}
onChange={(event) => setTotpCode(event.target.value)}
inputMode="numeric"
autoComplete="one-time-code"
/>
</div>
</div>
) : null}
<div className="wizard-actions">
<button
type="button"
className="config-action-revert"
onClick={() => setStep(0)}
>
Back
</button>
<button
type="button"
className="groups-save"
disabled={isSubmitting}
onClick={() => void handleAdminNext()}
>
{setupTotp && !totpSecret
? 'Continue to TOTP'
: setupTotp && !totpConfirmed
? 'Verify TOTP'
: 'Next'}
</button>
</div>
</div>
) : null}
{step === 2 ? (
<div className="wizard-body">
<h2>Network setup</h2>
<div className="config-field">
<label htmlFor="setup-listen">Listen address</label>
<input
id="setup-listen"
className="config-input"
value={network.listen_address}
onChange={(event) =>
setNetwork((previous) => ({
...previous,
listen_address: event.target.value,
}))
}
/>
<p className="config-hint">
Default 0.0.0.0 (all interfaces). Applied on next process
restart.
</p>
</div>
<div className="config-field">
<label htmlFor="setup-hostname">Public hostname</label>
<input
id="setup-hostname"
className="config-input"
placeholder="admin.mydomain.ext"
value={network.public_hostname}
onChange={(event) =>
setNetwork((previous) => ({
...previous,
public_hostname: event.target.value,
}))
}
/>
<p className="config-hint">
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.
</p>
</div>
<div className="config-field">
<label htmlFor="setup-access-mode">Access mode</label>
<select
id="setup-access-mode"
className="theme-select"
value={network.access_mode}
onChange={(event) =>
setNetwork((previous) => ({
...previous,
access_mode: event.target.value as AccessMode,
}))
}
>
<option value="open">Open (no IP filter)</option>
<option value="whitelist">Whitelist (deny non-matching)</option>
<option value="blacklist">Blacklist (deny matching)</option>
</select>
</div>
{network.access_mode !== 'open' ? (
<div className="config-field">
<label htmlFor="setup-rules">
IP / CIDR rules (one per line)
</label>
<textarea
id="setup-rules"
className="config-input wizard-rules"
rows={5}
value={rulesText}
onChange={(event) => setRulesText(event.target.value)}
/>
<p className="config-hint">Your current IP: {clientIp || 'unknown'}</p>
{lockoutWarning ? (
<p className="wizard-error" role="status">
{lockoutWarning}
</p>
) : null}
</div>
) : null}
<div className="wizard-actions">
<button
type="button"
className="config-action-revert"
onClick={() => setStep(1)}
>
Back
</button>
<button
type="button"
className="groups-save"
onClick={handleNetworkNext}
>
Next
</button>
</div>
</div>
) : null}
{step === 3 ? (
<div className="wizard-body">
<h2>Server settings</h2>
<div className="config-field">
<label htmlFor="setup-idle">Idle timeout (minutes)</label>
<input
id="setup-idle"
className="config-input"
type="number"
min={1}
max={240}
value={security.idle_timeout_minutes}
onChange={(event) =>
setSecurity((previous) => ({
...previous,
idle_timeout_minutes: Number(event.target.value) || 0,
}))
}
/>
</div>
<div className="config-field">
<label htmlFor="setup-lifetime">Session lifetime (hours)</label>
<input
id="setup-lifetime"
className="config-input"
type="number"
min={1}
value={security.session_lifetime_hours}
onChange={(event) =>
setSecurity((previous) => ({
...previous,
session_lifetime_hours: Number(event.target.value) || 0,
}))
}
/>
</div>
<div className="config-field">
<label className="permission-option" htmlFor="setup-totp-server">
<input
id="setup-totp-server"
type="checkbox"
checked={security.totp_enabled}
disabled={totpConfirmed}
onChange={(event) =>
setSecurity((previous) => ({
...previous,
totp_enabled: event.target.checked,
}))
}
/>
Enable TOTP
</label>
{totpConfirmed ? (
<p className="config-hint">
Locked on because the admin account enrolled TOTP.
</p>
) : null}
</div>
<div className="config-field">
<label className="permission-option" htmlFor="setup-reauth">
<input
id="setup-reauth"
type="checkbox"
checked={security.reauth_sensitive_actions}
onChange={(event) =>
setSecurity((previous) => ({
...previous,
reauth_sensitive_actions: event.target.checked,
}))
}
/>
Re-auth for sensitive actions
</label>
</div>
<div className="config-field">
<label htmlFor="setup-reauth-grace">
Re-auth grace period (minutes)
</label>
<input
id="setup-reauth-grace"
className="config-input"
type="number"
min={0}
disabled={!security.reauth_sensitive_actions}
value={security.reauth_grace_minutes}
onChange={(event) =>
setSecurity((previous) => ({
...previous,
reauth_grace_minutes: Number(event.target.value) || 0,
}))
}
/>
</div>
<div className="wizard-actions">
<button
type="button"
className="config-action-revert"
onClick={() => setStep(2)}
>
Back
</button>
<button
type="button"
className="groups-save"
disabled={isSubmitting}
onClick={() => void handleFinish()}
>
Finish setup
</button>
</div>
</div>
) : null}
</div>
</div>
)
}