/** * Setup wizard page. * * Displayed automatically on first launch when no configuration exists. * Once submitted successfully the user is redirected to the login page. * All fields use Fluent UI v9 components and inline validation. */ import { useEffect, useState } from "react"; import { Button, Field, Input, makeStyles, MessageBar, MessageBarBody, Spinner, Text, tokens, } from "@fluentui/react-components"; import { useNavigate } from "react-router-dom"; import type { ChangeEvent, FormEvent } from "react"; import { ApiError } from "../api/client"; import { getSetupStatus, submitSetup } from "../api/setup"; // --------------------------------------------------------------------------- // Styles // --------------------------------------------------------------------------- const useStyles = makeStyles({ root: { display: "flex", justifyContent: "center", alignItems: "flex-start", minHeight: "100vh", padding: `${tokens.spacingVerticalXXL} ${tokens.spacingHorizontalM}`, backgroundColor: tokens.colorNeutralBackground2, }, card: { width: "100%", maxWidth: "480px", backgroundColor: tokens.colorNeutralBackground1, borderRadius: tokens.borderRadiusXLarge, padding: tokens.spacingVerticalXXL, boxShadow: tokens.shadow8, }, heading: { marginBottom: tokens.spacingVerticalL, display: "block", }, description: { marginBottom: tokens.spacingVerticalXXL, color: tokens.colorNeutralForeground2, display: "block", }, fields: { display: "flex", flexDirection: "column", gap: tokens.spacingVerticalM, }, submitRow: { marginTop: tokens.spacingVerticalL, }, error: { marginBottom: tokens.spacingVerticalM, }, }); // --------------------------------------------------------------------------- // Form state // --------------------------------------------------------------------------- interface FormValues { masterPassword: string; confirmPassword: string; databasePath: string; fail2banSocket: string; timezone: string; sessionDurationMinutes: string; } const DEFAULT_VALUES: FormValues = { masterPassword: "", confirmPassword: "", databasePath: "bangui.db", fail2banSocket: "/var/run/fail2ban/fail2ban.sock", timezone: "UTC", sessionDurationMinutes: "60", }; // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- /** * First-run setup wizard page. * Collects master password and server preferences. */ export function SetupPage(): React.JSX.Element { const styles = useStyles(); const navigate = useNavigate(); const [checking, setChecking] = useState(true); const [values, setValues] = useState(DEFAULT_VALUES); const [errors, setErrors] = useState>>({}); const [apiError, setApiError] = useState(null); const [submitting, setSubmitting] = useState(false); // Redirect to /login if setup has already been completed. // Show a full-screen spinner while the check is in flight to prevent // the form from flashing before the redirect fires. useEffect(() => { let cancelled = false; getSetupStatus() .then((res) => { if (!cancelled) { if (res.completed) { navigate("/login", { replace: true }); } else { setChecking(false); } } }) .catch(() => { // Failed check: the backend may still be starting up. Stay on this // page so the user can attempt setup once the backend is ready. console.warn("SetupPage: setup status check failed — rendering setup form"); if (!cancelled) setChecking(false); }); return (): void => { cancelled = true; }; }, [navigate]); // --------------------------------------------------------------------------- // Handlers // --------------------------------------------------------------------------- function handleChange(field: keyof FormValues) { return (ev: ChangeEvent): void => { setValues((prev) => ({ ...prev, [field]: ev.target.value })); // Clear field-level error on change. setErrors((prev) => ({ ...prev, [field]: undefined })); }; } function validate(): boolean { const next: Partial> = {}; if (values.masterPassword.length < 8) { next.masterPassword = "Password must be at least 8 characters."; } if (values.masterPassword !== values.confirmPassword) { next.confirmPassword = "Passwords do not match."; } if (!values.databasePath.trim()) { next.databasePath = "Database path is required."; } if (!values.fail2banSocket.trim()) { next.fail2banSocket = "Socket path is required."; } const duration = parseInt(values.sessionDurationMinutes, 10); if (isNaN(duration) || duration < 1) { next.sessionDurationMinutes = "Session duration must be at least 1 minute."; } setErrors(next); return Object.keys(next).length === 0; } async function handleSubmit(ev: FormEvent): Promise { ev.preventDefault(); setApiError(null); if (!validate()) return; setSubmitting(true); try { await submitSetup({ master_password: values.masterPassword, database_path: values.databasePath, fail2ban_socket: values.fail2banSocket, timezone: values.timezone, session_duration_minutes: parseInt(values.sessionDurationMinutes, 10), }); navigate("/login", { replace: true }); } catch (err) { if (err instanceof ApiError) { setApiError(err.message || `Error ${String(err.status)}`); } else { setApiError("An unexpected error occurred. Please try again."); } } finally { setSubmitting(false); } } // --------------------------------------------------------------------------- // Render // --------------------------------------------------------------------------- if (checking) { return (
); } return (
BanGUI Setup Configure BanGUI for first use. This page will not be shown again once setup is complete. {apiError !== null && ( {apiError} )}
void handleSubmit(ev)}> {/* Hidden username field — required by accessibility guidelines for password managers to correctly identify the credential form. */}
); }