Files
BanGUI/frontend/src/pages/SetupPage.tsx
Lukas e593498de5 Strengthen setup password validation
- Add backend Pydantic password complexity validation for setup
- Update frontend setup page with password rule feedback and strength indicator
- Add/adjust setup API tests for password validation
- Document setup password requirements
- Fix frontend test type annotation issue
2026-04-20 19:23:12 +02:00

433 lines
13 KiB
TypeScript

/**
* 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<Record<keyof FormValues, string>>;
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<FormValues>(DEFAULT_VALUES);
const [errors, setErrors] = useState<ValidationErrors>({});
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<HTMLInputElement>): 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<HTMLFormElement>): Promise<void> {
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 (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "100vh",
}}
>
<Spinner size="large" label="Loading…" />
</div>
);
}
return (
<div className={styles.root}>
<div className={styles.card}>
<Text as="h1" size={700} weight="semibold" className={styles.heading}>
BanGUI Setup
</Text>
<Text size={300} className={styles.description}>
Configure BanGUI for first use. This page will not be shown again once setup
is complete.
</Text>
{apiError && (
<MessageBar intent="error" className={styles.error}>
<MessageBarBody>{apiError}</MessageBarBody>
</MessageBar>
)}
<form onSubmit={(ev) => void handleSubmit(ev)}>
{/* Hidden username field — required by accessibility guidelines for
password managers to correctly identify the credential form. */}
<input
type="text"
autoComplete="username"
aria-hidden="true"
tabIndex={-1}
value="bangui"
readOnly
style={{ display: "none" }}
/>
<div className={styles.fields}>
<Field
label="Master Password"
required
validationMessage={
errors.masterPassword ??
(passwordRules.some((rule) => !rule.satisfied)
? {
children: (
<ul className={styles.passwordRuleList}>
{passwordRules.map((rule) => (
<li
key={rule.id}
className={
rule.satisfied
? styles.passwordRuleItemPassed
: styles.passwordRuleItemFailed
}
>
{rule.label}
</li>
))}
</ul>
),
}
: undefined)
}
validationState={
errors.masterPassword || passwordRules.some((rule) => !rule.satisfied)
? "error"
: "none"
}
>
<Input
type="password"
value={values.masterPassword}
onChange={handleChange("masterPassword")}
autoComplete="new-password"
/>
<div className={styles.passwordStrength} aria-live="polite">
<div className={styles.passwordStrengthBar}>
{passwordRules.map((rule) => (
<span
key={rule.id}
className={
rule.satisfied
? `${styles.passwordStrengthSegment} ${styles.passwordStrengthSegmentActive}`
: styles.passwordStrengthSegment
}
/>
))}
</div>
<Text size={200}>
{passwordStrength} of {passwordRules.length} rules satisfied
</Text>
</div>
</Field>
<Field
label="Confirm Password"
required
validationMessage={errors.confirmPassword}
validationState={errors.confirmPassword ? "error" : "none"}
>
<Input
type="password"
value={values.confirmPassword}
onChange={handleChange("confirmPassword")}
autoComplete="new-password"
/>
</Field>
<Field
label="Database Path"
hint="Path where BanGUI stores its SQLite database."
validationMessage={errors.databasePath}
validationState={errors.databasePath ? "error" : "none"}
>
<Input
value={values.databasePath}
onChange={handleChange("databasePath")}
/>
</Field>
<Field
label="fail2ban Socket Path"
hint="Unix socket used to communicate with the fail2ban daemon."
validationMessage={errors.fail2banSocket}
validationState={errors.fail2banSocket ? "error" : "none"}
>
<Input
value={values.fail2banSocket}
onChange={handleChange("fail2banSocket")}
/>
</Field>
<Field
label="Timezone"
hint="IANA timezone identifier (e.g. UTC, Europe/Berlin)."
>
<Input
value={values.timezone}
onChange={handleChange("timezone")}
/>
</Field>
<Field
label="Session Duration (minutes)"
hint="How long a login session stays active."
validationMessage={errors.sessionDurationMinutes}
validationState={errors.sessionDurationMinutes ? "error" : "none"}
>
<Input
type="number"
value={values.sessionDurationMinutes}
onChange={handleChange("sessionDurationMinutes")}
min={1}
/>
</Field>
</div>
<div className={styles.submitRow}>
<Button
type="submit"
appearance="primary"
disabled={submitting}
icon={submitting ? <Spinner size="tiny" /> : undefined}
>
{submitting ? "Saving…" : "Complete Setup"}
</Button>
</div>
</form>
</div>
</div>
);
}