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