/** * 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, type ChangeEvent, type FormEvent } from "react"; import { Button, Field, Input, makeStyles, MessageBar, MessageBarBody, Spinner, Text, tokens, } from "@fluentui/react-components"; import { useNavigate } from "react-router-dom"; import { useSetup } from "../hooks/useSetup"; // --------------------------------------------------------------------------- // 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, }, passwordStrength: { marginTop: tokens.spacingVerticalS, display: "grid", gap: tokens.spacingVerticalS, }, passwordStrengthBar: { display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: tokens.spacingHorizontalS, width: "100%", }, passwordStrengthSegment: { height: "8px", borderRadius: tokens.borderRadiusSmall, backgroundColor: tokens.colorNeutralStroke2, }, passwordStrengthSegmentActive: { backgroundColor: tokens.colorPaletteGreenBorder1, }, passwordRuleList: { margin: 0, paddingLeft: tokens.spacingHorizontalL, color: tokens.colorNeutralForeground2, fontSize: "0.875rem", }, passwordRuleItem: { marginBottom: tokens.spacingVerticalXS, }, passwordRuleItemPassed: { color: tokens.colorPaletteGreenForeground1, }, passwordRuleItemFailed: { color: tokens.colorPaletteRedForeground1, }, }); // --------------------------------------------------------------------------- // 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", }; type ValidationErrors = Partial>; type PasswordRuleId = "length" | "uppercase" | "number" | "special"; interface PasswordRule { id: PasswordRuleId; label: string; test: (password: string) => boolean; } const PASSWORD_RULES: PasswordRule[] = [ { id: "length", label: "At least 8 characters", test: (password: string) => password.length >= 8, }, { id: "uppercase", label: "At least one uppercase letter", test: (password: string) => /[A-Z]/.test(password), }, { id: "number", label: "At least one number", test: (password: string) => /\d/.test(password), }, { id: "special", label: "At least one special character (!@#$%^&*())", test: (password: string) => /[!@#$%^&*()]/.test(password), }, ]; function getPasswordRuleStatus(password: string) { return PASSWORD_RULES.map((rule) => ({ ...rule, satisfied: rule.test(password), })); } // --------------------------------------------------------------------------- // 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 { status, loading, error, submit, submitting, submitError } = useSetup(); const [values, setValues] = useState(DEFAULT_VALUES); const [errors, setErrors] = useState({}); const passwordRules = getPasswordRuleStatus(values.masterPassword); const passwordStrength = passwordRules.filter((rule) => rule.satisfied).length; const apiError = error ?? submitError; // Redirect to /login if setup has already been completed. // Show a full-screen spinner while the initial status check is in flight. useEffect(() => { if (status?.completed) { navigate("/login", { replace: true }); } }, [navigate, status]); // --------------------------------------------------------------------------- // 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: ValidationErrors = {}; const unmetPasswordRules = passwordRules.filter((rule) => !rule.satisfied); if (!values.masterPassword.trim()) { next.masterPassword = "Password is required."; } else if (unmetPasswordRules.length > 0) { next.masterPassword = "Password must meet all complexity requirements."; } 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(); if (!validate()) return; try { await submit({ 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 { // Errors are surfaced through the hook via `submitError`. } } // --------------------------------------------------------------------------- // Render // --------------------------------------------------------------------------- if (loading) { return (
); } return (
BanGUI Setup Configure BanGUI for first use. This page will not be shown again once setup is complete. {apiError && ( {apiError} )}
void handleSubmit(ev)}> {/* Hidden username field — required by accessibility guidelines for password managers to correctly identify the credential form. */}
!rule.satisfied) ? { children: (
    {passwordRules.map((rule) => (
  • {rule.label}
  • ))}
), } : undefined) } validationState={ errors.masterPassword || passwordRules.some((rule) => !rule.satisfied) ? "error" : "none" } >
{passwordRules.map((rule) => ( ))}
{passwordStrength} of {passwordRules.length} rules satisfied
); }