Strengthen setup password validation
- Add backend Pydantic password complexity validation for setup - Update frontend setup page with password rule feedback and strength indicator - Add/adjust setup API tests for password validation - Document setup password requirements - Fix frontend test type annotation issue
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
* All fields use Fluent UI v9 components and inline validation.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, type ChangeEvent, type FormEvent } from "react";
|
||||
import {
|
||||
Button,
|
||||
Field,
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
tokens,
|
||||
} from "@fluentui/react-components";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type { ChangeEvent, FormEvent } from "react";
|
||||
import { useSetup } from "../hooks/useSetup";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -63,6 +62,40 @@ const useStyles = makeStyles({
|
||||
error: {
|
||||
marginBottom: tokens.spacingVerticalM,
|
||||
},
|
||||
passwordStrength: {
|
||||
marginTop: tokens.spacingVerticalS,
|
||||
display: "grid",
|
||||
gap: tokens.spacingVerticalS,
|
||||
},
|
||||
passwordStrengthBar: {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(4, 1fr)",
|
||||
gap: tokens.spacingHorizontalS,
|
||||
width: "100%",
|
||||
},
|
||||
passwordStrengthSegment: {
|
||||
height: "8px",
|
||||
borderRadius: tokens.borderRadiusSmall,
|
||||
backgroundColor: tokens.colorNeutralStroke2,
|
||||
},
|
||||
passwordStrengthSegmentActive: {
|
||||
backgroundColor: tokens.colorPaletteGreenBorder1,
|
||||
},
|
||||
passwordRuleList: {
|
||||
margin: 0,
|
||||
paddingLeft: tokens.spacingHorizontalL,
|
||||
color: tokens.colorNeutralForeground2,
|
||||
fontSize: "0.875rem",
|
||||
},
|
||||
passwordRuleItem: {
|
||||
marginBottom: tokens.spacingVerticalXS,
|
||||
},
|
||||
passwordRuleItemPassed: {
|
||||
color: tokens.colorPaletteGreenForeground1,
|
||||
},
|
||||
passwordRuleItemFailed: {
|
||||
color: tokens.colorPaletteRedForeground1,
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -87,6 +120,46 @@ const DEFAULT_VALUES: FormValues = {
|
||||
sessionDurationMinutes: "60",
|
||||
};
|
||||
|
||||
type ValidationErrors = Partial<Record<keyof FormValues, string>>;
|
||||
|
||||
type PasswordRuleId = "length" | "uppercase" | "number" | "special";
|
||||
|
||||
interface PasswordRule {
|
||||
id: PasswordRuleId;
|
||||
label: string;
|
||||
test: (password: string) => boolean;
|
||||
}
|
||||
|
||||
const PASSWORD_RULES: PasswordRule[] = [
|
||||
{
|
||||
id: "length",
|
||||
label: "At least 8 characters",
|
||||
test: (password: string) => password.length >= 8,
|
||||
},
|
||||
{
|
||||
id: "uppercase",
|
||||
label: "At least one uppercase letter",
|
||||
test: (password: string) => /[A-Z]/.test(password),
|
||||
},
|
||||
{
|
||||
id: "number",
|
||||
label: "At least one number",
|
||||
test: (password: string) => /\d/.test(password),
|
||||
},
|
||||
{
|
||||
id: "special",
|
||||
label: "At least one special character (!@#$%^&*())",
|
||||
test: (password: string) => /[!@#$%^&*()]/.test(password),
|
||||
},
|
||||
];
|
||||
|
||||
function getPasswordRuleStatus(password: string) {
|
||||
return PASSWORD_RULES.map((rule) => ({
|
||||
...rule,
|
||||
satisfied: rule.test(password),
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -101,7 +174,9 @@ export function SetupPage(): React.JSX.Element {
|
||||
|
||||
const { status, loading, error, submit, submitting, submitError } = useSetup();
|
||||
const [values, setValues] = useState<FormValues>(DEFAULT_VALUES);
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof FormValues, string>>>({});
|
||||
const [errors, setErrors] = useState<ValidationErrors>({});
|
||||
const passwordRules = getPasswordRuleStatus(values.masterPassword);
|
||||
const passwordStrength = passwordRules.filter((rule) => rule.satisfied).length;
|
||||
const apiError = error ?? submitError;
|
||||
|
||||
// Redirect to /login if setup has already been completed.
|
||||
@@ -125,11 +200,15 @@ export function SetupPage(): React.JSX.Element {
|
||||
}
|
||||
|
||||
function validate(): boolean {
|
||||
const next: Partial<Record<keyof FormValues, string>> = {};
|
||||
const next: ValidationErrors = {};
|
||||
const unmetPasswordRules = passwordRules.filter((rule) => !rule.satisfied);
|
||||
|
||||
if (values.masterPassword.length < 8) {
|
||||
next.masterPassword = "Password must be at least 8 characters.";
|
||||
if (!values.masterPassword.trim()) {
|
||||
next.masterPassword = "Password is required.";
|
||||
} else if (unmetPasswordRules.length > 0) {
|
||||
next.masterPassword = "Password must meet all complexity requirements.";
|
||||
}
|
||||
|
||||
if (values.masterPassword !== values.confirmPassword) {
|
||||
next.confirmPassword = "Passwords do not match.";
|
||||
}
|
||||
@@ -219,8 +298,34 @@ export function SetupPage(): React.JSX.Element {
|
||||
<Field
|
||||
label="Master Password"
|
||||
required
|
||||
validationMessage={errors.masterPassword}
|
||||
validationState={errors.masterPassword ? "error" : "none"}
|
||||
validationMessage={
|
||||
errors.masterPassword ??
|
||||
(passwordRules.some((rule) => !rule.satisfied)
|
||||
? {
|
||||
children: (
|
||||
<ul className={styles.passwordRuleList}>
|
||||
{passwordRules.map((rule) => (
|
||||
<li
|
||||
key={rule.id}
|
||||
className={
|
||||
rule.satisfied
|
||||
? styles.passwordRuleItemPassed
|
||||
: styles.passwordRuleItemFailed
|
||||
}
|
||||
>
|
||||
{rule.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
),
|
||||
}
|
||||
: undefined)
|
||||
}
|
||||
validationState={
|
||||
errors.masterPassword || passwordRules.some((rule) => !rule.satisfied)
|
||||
? "error"
|
||||
: "none"
|
||||
}
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
@@ -228,6 +333,23 @@ export function SetupPage(): React.JSX.Element {
|
||||
onChange={handleChange("masterPassword")}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<div className={styles.passwordStrength} aria-live="polite">
|
||||
<div className={styles.passwordStrengthBar}>
|
||||
{passwordRules.map((rule) => (
|
||||
<span
|
||||
key={rule.id}
|
||||
className={
|
||||
rule.satisfied
|
||||
? `${styles.passwordStrengthSegment} ${styles.passwordStrengthSegmentActive}`
|
||||
: styles.passwordStrengthSegment
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Text size={200}>
|
||||
{passwordStrength} of {passwordRules.length} rules satisfied
|
||||
</Text>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter, Routes, Route } from "react-router-dom";
|
||||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||
import { SetupPage } from "../SetupPage";
|
||||
@@ -10,9 +11,10 @@ vi.mock("../../api/setup", () => ({
|
||||
submitSetup: vi.fn(),
|
||||
}));
|
||||
|
||||
import { getSetupStatus } from "../../api/setup";
|
||||
import { getSetupStatus, submitSetup } from "../../api/setup";
|
||||
|
||||
const mockedGetSetupStatus = vi.mocked(getSetupStatus);
|
||||
const mockedSubmitSetup = vi.mocked(submitSetup);
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
@@ -59,6 +61,45 @@ describe("SetupPage", () => {
|
||||
expect(screen.queryByRole("progressbar")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays password complexity feedback while the user types", async () => {
|
||||
mockedGetSetupStatus.mockResolvedValue({ completed: false });
|
||||
renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("heading", { name: /bangui setup/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
const passwordInput = screen.getByLabelText(/master password/i);
|
||||
await user.type(passwordInput, "Short1");
|
||||
|
||||
expect(screen.getByText(/2 of 4 rules satisfied/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/at least 8 characters/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/at least one uppercase letter/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/at least one special character/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not submit the form when the password is too weak", async () => {
|
||||
mockedGetSetupStatus.mockResolvedValue({ completed: false });
|
||||
mockedSubmitSetup.mockResolvedValue({ message: "Setup completed successfully. Please log in." });
|
||||
renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("heading", { name: /bangui setup/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.type(screen.getByLabelText(/master password/i), "Short1");
|
||||
await user.type(screen.getByLabelText(/confirm password/i), "Short1");
|
||||
await user.click(screen.getByRole("button", { name: /complete setup/i }));
|
||||
|
||||
expect(mockedSubmitSetup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("redirects to /login when setup is already complete", async () => {
|
||||
mockedGetSetupStatus.mockResolvedValue({ completed: true });
|
||||
renderPage();
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { areHistoryQueriesEqual } from "../queryUtils";
|
||||
import type { HistoryQuery } from "../../types/history";
|
||||
|
||||
describe("areHistoryQueriesEqual", () => {
|
||||
it("returns true for identical history queries", () => {
|
||||
const a = {
|
||||
const a: HistoryQuery = {
|
||||
range: "7d",
|
||||
origin: "blocklist",
|
||||
jail: "ssh",
|
||||
@@ -19,7 +20,7 @@ describe("areHistoryQueriesEqual", () => {
|
||||
});
|
||||
|
||||
it("returns false when a single query field differs", () => {
|
||||
const base = {
|
||||
const base: HistoryQuery = {
|
||||
range: "7d",
|
||||
origin: "all",
|
||||
jail: "ssh",
|
||||
|
||||
Reference in New Issue
Block a user