568 lines
18 KiB
TypeScript
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>
|
|
)
|
|
}
|