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

@@ -4,39 +4,52 @@
* Wraps the entire application in:
* 1. `FluentProvider` — supplies the Fluent UI theme and design tokens.
* 2. `BrowserRouter` — enables client-side routing via React Router.
* 3. `AuthProvider` — manages session state and exposes `useAuth()`.
*
* Route definitions are delegated to `AppRoutes` (implemented in Stage 3).
* For now a placeholder component is rendered so the app can start and the
* theme can be verified.
* Routes:
* - `/setup` — first-run setup wizard (always accessible, redirected to by backend middleware)
* - `/login` — master password login
* - `/` — dashboard (protected)
* All other paths fall through to the dashboard guard; the full route tree
* is wired up in Stage 3.
*/
import { FluentProvider } from "@fluentui/react-components";
import { BrowserRouter } from "react-router-dom";
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { lightTheme } from "./theme/customTheme";
import { AuthProvider } from "./providers/AuthProvider";
import { RequireAuth } from "./components/RequireAuth";
import { SetupPage } from "./pages/SetupPage";
import { LoginPage } from "./pages/LoginPage";
import { DashboardPage } from "./pages/DashboardPage";
/**
* Temporary placeholder shown until full routing is wired up in Stage 3.
*/
function AppPlaceholder(): JSX.Element {
return (
<div style={{ padding: 32, fontFamily: "Segoe UI, sans-serif" }}>
<h1 style={{ fontSize: 28, fontWeight: 600 }}>BanGUI</h1>
<p style={{ fontSize: 14, color: "#605e5c" }}>
Frontend scaffolding complete. Full UI implemented in Stage 3.
</p>
</div>
);
}
/**
* Root application component.
* Mounts `FluentProvider` and `BrowserRouter` around all page content.
* Root application component — mounts providers and top-level routes.
*/
function App(): JSX.Element {
return (
<FluentProvider theme={lightTheme}>
<BrowserRouter>
<AppPlaceholder />
<AuthProvider>
<Routes>
{/* Public routes */}
<Route path="/setup" element={<SetupPage />} />
<Route path="/login" element={<LoginPage />} />
{/* Protected routes */}
<Route
path="/"
element={
<RequireAuth>
<DashboardPage />
</RequireAuth>
}
/>
{/* Fallback — redirect unknown paths to dashboard (guard will redirect to login if needed) */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</AuthProvider>
</BrowserRouter>
</FluentProvider>
);

30
frontend/src/api/auth.ts Normal file
View File

@@ -0,0 +1,30 @@
/**
* Authentication API functions.
*
* Wraps calls to POST /api/auth/login and POST /api/auth/logout
* using the central typed fetch client.
*/
import { api } from "./client";
import { ENDPOINTS } from "./endpoints";
import type { LoginRequest, LoginResponse, LogoutResponse } from "../types/auth";
/**
* Authenticate with the master password.
*
* @param password - The master password entered by the user.
* @returns The login response containing the session token.
*/
export async function login(password: string): Promise<LoginResponse> {
const body: LoginRequest = { password };
return api.post<LoginResponse>(ENDPOINTS.authLogin, body);
}
/**
* Log out and invalidate the current session.
*
* @returns The logout confirmation message.
*/
export async function logout(): Promise<LogoutResponse> {
return api.post<LogoutResponse>(ENDPOINTS.authLogout, {});
}

32
frontend/src/api/setup.ts Normal file
View File

@@ -0,0 +1,32 @@
/**
* Setup wizard API functions.
*
* Wraps calls to GET /api/setup and POST /api/setup.
*/
import { api } from "./client";
import { ENDPOINTS } from "./endpoints";
import type {
SetupRequest,
SetupResponse,
SetupStatusResponse,
} from "../types/setup";
/**
* Check whether the initial setup has been completed.
*
* @returns Setup status response with a `completed` boolean.
*/
export async function getSetupStatus(): Promise<SetupStatusResponse> {
return api.get<SetupStatusResponse>(ENDPOINTS.setup);
}
/**
* Submit the initial setup configuration.
*
* @param data - Setup request payload.
* @returns Success message from the API.
*/
export async function submitSetup(data: SetupRequest): Promise<SetupResponse> {
return api.post<SetupResponse>(ENDPOINTS.setup, data);
}

View File

@@ -0,0 +1,37 @@
/**
* Route guard component.
*
* Wraps protected routes. If the user is not authenticated they are
* redirected to `/login` and the intended destination is preserved so the
* user lands on it after a successful login.
*/
import { Navigate, useLocation } from "react-router-dom";
import { useAuth } from "../providers/AuthProvider";
interface RequireAuthProps {
/** The protected page content to render when authenticated. */
children: JSX.Element;
}
/**
* Render `children` only if the user is authenticated.
*
* Redirects to `/login?next=<path>` otherwise so the intended destination is
* preserved and honoured after a successful login.
*/
export function RequireAuth({ children }: RequireAuthProps): JSX.Element {
const { isAuthenticated } = useAuth();
const location = useLocation();
if (!isAuthenticated) {
return (
<Navigate
to={`/login?next=${encodeURIComponent(location.pathname + location.search)}`}
replace
/>
);
}
return children;
}

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

View File

@@ -0,0 +1,118 @@
/**
* Authentication context and provider.
*
* Manages the user's authenticated state and exposes `login`, `logout`, and
* `isAuthenticated` through `useAuth()`. The session token is persisted in
* `sessionStorage` so it survives page refreshes within the browser tab but
* is automatically cleared when the tab is closed.
*/
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
} from "react";
import * as authApi from "../api/auth";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface AuthState {
token: string | null;
expiresAt: string | null;
}
interface AuthContextValue {
/** `true` when a valid session token is held in state. */
isAuthenticated: boolean;
/**
* Authenticate with the master password.
* Throws an `ApiError` on failure.
*/
login: (password: string) => Promise<void>;
/** Revoke the current session and clear local state. */
logout: () => Promise<void>;
}
// ---------------------------------------------------------------------------
// Context
// ---------------------------------------------------------------------------
const AuthContext = createContext<AuthContextValue | null>(null);
const SESSION_KEY = "bangui_token";
const SESSION_EXPIRES_KEY = "bangui_expires_at";
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
/**
* Wraps the application and provides authentication state to all children.
*
* Place this inside `<FluentProvider>` and `<BrowserRouter>` so all
* descendants can call `useAuth()`.
*/
export function AuthProvider({
children,
}: {
children: React.ReactNode;
}): JSX.Element {
const [auth, setAuth] = useState<AuthState>(() => ({
token: sessionStorage.getItem(SESSION_KEY),
expiresAt: sessionStorage.getItem(SESSION_EXPIRES_KEY),
}));
const isAuthenticated = useMemo<boolean>(() => {
if (!auth.token || !auth.expiresAt) return false;
// Treat the session as expired if the expiry time has passed.
return new Date(auth.expiresAt) > new Date();
}, [auth]);
const login = useCallback(async (password: string): Promise<void> => {
const response = await authApi.login(password);
sessionStorage.setItem(SESSION_KEY, response.token);
sessionStorage.setItem(SESSION_EXPIRES_KEY, response.expires_at);
setAuth({ token: response.token, expiresAt: response.expires_at });
}, []);
const logout = useCallback(async (): Promise<void> => {
try {
await authApi.logout();
} finally {
// Always clear local state even if the API call fails (e.g. expired session).
sessionStorage.removeItem(SESSION_KEY);
sessionStorage.removeItem(SESSION_EXPIRES_KEY);
setAuth({ token: null, expiresAt: null });
}
}, []);
const value = useMemo<AuthContextValue>(
() => ({ isAuthenticated, login, logout }),
[isAuthenticated, login, logout],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
/**
* Access authentication state and actions.
*
* Must be called inside a component rendered within `<AuthProvider>`.
*
* @throws {Error} When called outside of `<AuthProvider>`.
*/
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (ctx === null) {
throw new Error("useAuth must be used within <AuthProvider>.");
}
return ctx;
}

View File

@@ -0,0 +1,19 @@
/**
* Types for the authentication domain.
*/
/** Request payload for POST /api/auth/login. */
export interface LoginRequest {
password: string;
}
/** Successful login response from the API. */
export interface LoginResponse {
token: string;
expires_at: string;
}
/** Response body for POST /api/auth/logout. */
export interface LogoutResponse {
message: string;
}

View File

@@ -0,0 +1,22 @@
/**
* Types for the setup wizard domain.
*/
/** Request payload for POST /api/setup. */
export interface SetupRequest {
master_password: string;
database_path?: string;
fail2ban_socket?: string;
timezone?: string;
session_duration_minutes?: number;
}
/** Response from a successful POST /api/setup. */
export interface SetupResponse {
message: string;
}
/** Response from GET /api/setup — indicates setup completion status. */
export interface SetupStatusResponse {
completed: boolean;
}