The sha256Hex helper used window.crypto.subtle.digest(), which is only available in a secure context (HTTPS / localhost). In the HTTP Docker environment crypto.subtle is undefined, causing a TypeError before any request is sent — the setup and login forms both silently failed with 'An unexpected error occurred'. Fix: pass raw passwords directly to the API. The backend already applies bcrypt, which is sufficient. No stored hashes need migration because setup never completed successfully in the HTTP environment. * frontend/src/pages/SetupPage.tsx — remove sha256Hex call * frontend/src/api/auth.ts — remove sha256Hex call * frontend/src/pages/__tests__/SetupPage.test.tsx — drop crypto mock * frontend/src/utils/crypto.ts — deleted (no remaining callers)
339 lines
10 KiB
TypeScript
339 lines
10 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 } 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<FormValues>(DEFAULT_VALUES);
|
|
const [errors, setErrors] = useState<Partial<Record<keyof FormValues, string>>>({});
|
|
const [apiError, setApiError] = useState<string | null>(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<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: Partial<Record<keyof FormValues, string>> = {};
|
|
|
|
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<HTMLFormElement>): Promise<void> {
|
|
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 (
|
|
<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 !== null && (
|
|
<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}
|
|
validationState={errors.masterPassword ? "error" : "none"}
|
|
>
|
|
<Input
|
|
type="password"
|
|
value={values.masterPassword}
|
|
onChange={handleChange("masterPassword")}
|
|
autoComplete="new-password"
|
|
/>
|
|
</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>
|
|
);
|
|
}
|