- config_service.py: read/write jail config via asyncio.gather, global settings, in-process regex validation, log preview via _read_tail_lines - server_service.py: read/write server settings, flush logs - config router: 9 endpoints for jail/global config, regex-test, logpath management, log preview - server router: GET/PUT settings, POST flush-logs - models/config.py expanded with JailConfig, GlobalConfigUpdate, LogPreview* models - 285 tests pass (68 new), ruff clean, mypy clean (44 files) - Frontend: types/config.ts, api/config.ts, hooks/useConfig.ts, ConfigPage.tsx full implementation (Jails accordion editor, Global config, Server settings, Regex Tester with preview) - Fixed pre-existing frontend lint: JSX.Element → React.JSX.Element (10 files), void/promise patterns in useServerStatus + useJails, no-misused-spread in client.ts, eslint.config.ts self-excluded
159 lines
4.2 KiB
TypeScript
159 lines
4.2 KiB
TypeScript
/**
|
|
* 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(): React.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>
|
|
);
|
|
}
|