feat: Stage 2 — authentication and setup flow

Backend (tasks 2.1–2.6, 2.10):
- settings_repo: get/set/delete/get_all CRUD for the key-value settings table
- session_repo: create/get/delete/delete_expired for session rows
- setup_service: bcrypt password hashing, one-time-only enforcement,
  run_setup() / is_setup_complete() / get_password_hash()
- auth_service: login() with bcrypt verify + token creation,
  validate_session() with expiry check, logout()
- setup router: GET /api/setup (status), POST /api/setup (201 / 409)
- auth router: POST /api/auth/login (token + HttpOnly cookie),
               POST /api/auth/logout (clears cookie, idempotent)
- SetupRedirectMiddleware: 307 → /api/setup for all API paths until setup done
- require_auth dependency: cookie or Bearer token → Session or 401
- conftest.py: manually bootstraps app.state.db for router tests
  (ASGITransport does not trigger ASGI lifespan)
- 85 tests pass; ruff 0 errors; mypy --strict 0 errors

Frontend (tasks 2.7–2.9):
- types/auth.ts, types/setup.ts, api/auth.ts, api/setup.ts
- AuthProvider: sessionStorage-backed context (isAuthenticated, login, logout)
- RequireAuth: guard component → /login?next=<path> when unauthenticated
- SetupPage: Fluent UI form, client-side validation, inline errors
- LoginPage: single password input, ?next= redirect after success
- DashboardPage: placeholder (full impl Stage 5)
- App.tsx: full route tree (/setup, /login, /, *)
This commit is contained in:
2026-02-28 21:33:30 +01:00
parent 7392c930d6
commit 750785680b
26 changed files with 2075 additions and 49 deletions

View File

@@ -0,0 +1,30 @@
/**
* Dashboard placeholder page.
*
* Full implementation is delivered in Stage 5.
*/
import { Text, makeStyles, tokens } from "@fluentui/react-components";
const useStyles = makeStyles({
root: {
padding: tokens.spacingVerticalXXL,
},
});
/**
* Temporary dashboard placeholder rendered until Stage 5 is complete.
*/
export function DashboardPage(): JSX.Element {
const styles = useStyles();
return (
<div className={styles.root}>
<Text as="h1" size={700} weight="semibold">
Dashboard
</Text>
<Text as="p" size={300}>
Ban overview will be implemented in Stage 5.
</Text>
</div>
);
}

View File

@@ -0,0 +1,158 @@
/**
* Login page.
*
* A single password field and submit button. On success the user is
* redirected to the originally requested page (via the `?next=` query
* parameter) or the dashboard.
*/
import { useState } from "react";
import {
Button,
Field,
Input,
makeStyles,
MessageBar,
MessageBarBody,
Spinner,
Text,
tokens,
} from "@fluentui/react-components";
import { useNavigate, useSearchParams } from "react-router-dom";
import type { ChangeEvent, FormEvent } from "react";
import { ApiError } from "../api/client";
import { useAuth } from "../providers/AuthProvider";
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const useStyles = makeStyles({
root: {
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "100vh",
backgroundColor: tokens.colorNeutralBackground2,
padding: tokens.spacingHorizontalM,
},
card: {
width: "100%",
maxWidth: "360px",
backgroundColor: tokens.colorNeutralBackground1,
borderRadius: tokens.borderRadiusXLarge,
padding: tokens.spacingVerticalXXL,
boxShadow: tokens.shadow8,
},
heading: {
marginBottom: tokens.spacingVerticalXS,
display: "block",
},
subtitle: {
marginBottom: tokens.spacingVerticalXXL,
color: tokens.colorNeutralForeground2,
display: "block",
},
field: {
marginBottom: tokens.spacingVerticalM,
},
submitRow: {
marginTop: tokens.spacingVerticalL,
},
error: {
marginBottom: tokens.spacingVerticalM,
},
});
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
/**
* Login page — single password input, no username.
*/
export function LoginPage(): JSX.Element {
const styles = useStyles();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { login } = useAuth();
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const nextPath = searchParams.get("next") ?? "/";
function handlePasswordChange(ev: ChangeEvent<HTMLInputElement>): void {
setPassword(ev.target.value);
setError(null);
}
async function handleSubmit(ev: FormEvent<HTMLFormElement>): Promise<void> {
ev.preventDefault();
if (!password) {
setError("Please enter a password.");
return;
}
setSubmitting(true);
setError(null);
try {
await login(password);
navigate(nextPath, { replace: true });
} catch (err) {
if (err instanceof ApiError && err.status === 401) {
setError("Incorrect password. Please try again.");
} else {
setError("An unexpected error occurred. Please try again.");
}
} finally {
setSubmitting(false);
}
}
return (
<div className={styles.root}>
<div className={styles.card}>
<Text as="h1" size={700} weight="semibold" className={styles.heading}>
BanGUI
</Text>
<Text size={300} className={styles.subtitle}>
Enter your master password to continue.
</Text>
{error !== null && (
<MessageBar intent="error" className={styles.error}>
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
)}
<form onSubmit={(ev) => void handleSubmit(ev)}>
<div className={styles.field}>
<Field label="Password" required>
<Input
type="password"
value={password}
onChange={handlePasswordChange}
autoComplete="current-password"
autoFocus
/>
</Field>
</div>
<div className={styles.submitRow}>
<Button
type="submit"
appearance="primary"
disabled={submitting || !password}
icon={submitting ? <Spinner size="tiny" /> : undefined}
>
{submitting ? "Signing in…" : "Sign in"}
</Button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,285 @@
/**
* 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 { 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 { 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(): JSX.Element {
const styles = useStyles();
const navigate = useNavigate();
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);
// ---------------------------------------------------------------------------
// 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
// ---------------------------------------------------------------------------
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)}>
<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>
);
}