Add users and network settings tabs with admin protections.
List and delete accounts with last-admin and Administrators-group guards, and expose editable network settings via the configuration UI.
This commit is contained in:
@@ -0,0 +1,567 @@
|
||||
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="config-input"
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user