Files
BanGUI/frontend/src/pages/SetupPage.tsx
Lukas c41165c294 Remove client-side SHA-256 pre-hashing from setup and login
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)
2026-03-15 21:29:23 +01:00

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>
);
}