Merge pull request #271 from Sea-Haven-Industries/feat/ab/sh-386-password-policy
Some checks are pending
Frontend checks / static (push) Waiting to run
Frontend checks / build (push) Waiting to run
Frontend checks / unit (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Frontend checks / browser-smoke (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / ci-complete (push) Blocked by required conditions
Deploy Web / Resolve target (push) Waiting to run
Deploy Web / Deploy SPA to (push) Blocked by required conditions

feat(account): My Account page with a live password checklist (SH-386)
This commit is contained in:
Alexandre Brandizzi 2026-09-25 16:35:20 +00:00 • committed by GitHub
commit 403bc66383
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 644 additions and 4 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

After

Width:  |  Height:  |  Size: 198 KiB

View file

@ -0,0 +1,33 @@
import { Text } from "@/components/ui/text";
import type { AuthUser } from "@/domain/auth/types/auth-user";
function ReadOnlyValue({ label, value }: { label: string; value: string | undefined }) {
return (
<div className="grid gap-1.5">
<Text variant="label" tone="muted" className="text-[12px]!">
{label}
</Text>
<Text variant="body" className="m-0 text-[14px]!">
{value || "—"}
</Text>
</div>
);
}
export function AccountSummaryCard({ user }: { user: AuthUser }) {
return (
<section
aria-label="Account details"
className="grid gap-4 rounded-lg border border-(--color-border) bg-(--color-bg-surface) p-5"
>
<div className="grid grid-cols-2 gap-3">
<ReadOnlyValue label="Name" value={user.fullname} />
<ReadOnlyValue label="Role" value={user.userRoles} />
</div>
<ReadOnlyValue label="Email" value={user.email} />
<Text variant="caption" tone="muted" className="text-[11.5px]!">
Contact your Admin to change your name, role, or email.
</Text>
</section>
);
}

View file

@ -0,0 +1,132 @@
import { useForm, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Box } from "@mui/material";
import { KeyRound } from "lucide-react";
import { PasswordChecklist } from "@/components/auth/password-checklist";
import { Button } from "@/components/ui/button";
import { FormField } from "@/components/ui/form-field";
import { PasswordField } from "@/components/ui/password-field";
import { Text } from "@/components/ui/text";
import {
CURRENT_PASSWORD_INCORRECT_MESSAGE,
changePasswordSchema,
type ChangePasswordFormValues,
} from "@/domain/auth/schemas/change-password-schema";
import { useChangePassword } from "@/domain/auth/use-cases/use-change-password";
const EMPTY_VALUES: ChangePasswordFormValues = {
currentPassword: "",
newPassword: "",
confirmPassword: "",
};
export function ChangePasswordSection() {
const changePassword = useChangePassword();
const {
register,
handleSubmit,
control,
reset,
setError,
formState: { errors },
} = useForm<ChangePasswordFormValues>({
resolver: zodResolver(changePasswordSchema),
defaultValues: EMPTY_VALUES,
mode: "onTouched",
});
const values = useWatch({ control });
const newPassword = values.newPassword ?? "";
const canSubmit = changePasswordSchema.safeParse(values).success;
const serverError = changePassword.error?.message;
const showServerError =
Boolean(serverError) && serverError !== CURRENT_PASSWORD_INCORRECT_MESSAGE;
const onSubmit = (form: ChangePasswordFormValues) => {
changePassword.mutate(
{
Currentpassword: form.currentPassword,
Newpassword: form.newPassword,
Confirmpassword: form.confirmPassword,
},
{
onSuccess: () => reset(EMPTY_VALUES),
onError: (error) => {
if (error.message === CURRENT_PASSWORD_INCORRECT_MESSAGE) {
setError("currentPassword", { message: CURRENT_PASSWORD_INCORRECT_MESSAGE });
}
},
},
);
};
return (
<Box
component="form"
aria-labelledby="change-password-heading"
onSubmit={handleSubmit(onSubmit)}
className="grid gap-4 rounded-lg border border-(--color-border) bg-(--color-bg-surface) p-5"
>
<div className="flex items-center gap-2">
<KeyRound size={15} aria-hidden="true" className="text-primary" />
<Text id="change-password-heading" variant="heading" className="m-0 text-[15px]!">
Change Password
</Text>
</div>
<FormField
label="Current password"
htmlFor="account-current-password"
required
error={Boolean(errors.currentPassword)}
helperText={errors.currentPassword?.message}
>
<PasswordField
{...register("currentPassword")}
id="account-current-password"
autoComplete="current-password"
error={Boolean(errors.currentPassword)}
/>
</FormField>
<FormField label="New password" htmlFor="account-new-password" required>
<PasswordField
{...register("newPassword")}
id="account-new-password"
autoComplete="new-password"
slotProps={{ htmlInput: { "aria-describedby": "account-password-rules" } }}
/>
<PasswordChecklist id="account-password-rules" password={newPassword} />
</FormField>
<FormField
label="Confirm new password"
htmlFor="account-confirm-password"
required
error={Boolean(errors.confirmPassword)}
helperText={errors.confirmPassword?.message}
>
<PasswordField
{...register("confirmPassword")}
id="account-confirm-password"
autoComplete="new-password"
error={Boolean(errors.confirmPassword)}
/>
</FormField>
{showServerError && <Text variant="error">{serverError}</Text>}
<div className="flex justify-end">
<Button
type="submit"
disabled={!canSubmit}
loading={changePassword.isPending}
loadingText="Updating…"
>
Update password
</Button>
</div>
</Box>
);
}

View file

@ -0,0 +1,24 @@
import { Box } from "@mui/material";
import { AccountSummaryCard } from "@/app/(protected)/account/_components/account-summary-card";
import { ChangePasswordSection } from "@/app/(protected)/account/_components/change-password-section";
import { PageHeader } from "@/components/ui/page-header";
import { useAuthContext } from "@/providers/auth-context";
export default function MyAccountPage() {
const { user } = useAuthContext();
return (
<Box className="flex flex-col gap-2 p-2.5">
<PageHeader
surface
title="My Account"
subtitle="Review your account and manage your password"
/>
<Box className="grid max-w-md gap-5 px-5 pb-5">
{user != null && <AccountSummaryCard user={user} />}
<ChangePasswordSection />
</Box>
</Box>
);
}

View file

@ -0,0 +1,63 @@
import { useState } from "react";
import { Circle, CircleCheck } from "lucide-react";
import { Text } from "@/components/ui/text";
import { evaluatePasswordRules, type PasswordRuleResult } from "@/domain/auth/password-policy";
import { cn } from "@/lib/utils";
export type PasswordChecklistProps = {
password: string;
id?: string;
className?: string;
};
function describeChanges(previous: PasswordRuleResult[], next: PasswordRuleResult[]): string {
return next
.filter((rule, index) => rule.met !== previous[index]?.met)
.map((rule) => `${rule.label}: ${rule.met ? "requirement met" : "requirement not met"}`)
.join(". ");
}
/**
* Live, per-rule feedback for the shared password policy. A polite status region
* announces only the rules whose state changed, so screen-reader users hear
* progress while typing without the whole list being re-read on each keystroke.
*/
export function PasswordChecklist({ password, id, className }: PasswordChecklistProps) {
const results = evaluatePasswordRules(password);
const signature = results.map((rule) => (rule.met ? "1" : "0")).join("");
const [previous, setPrevious] = useState({ signature, results });
const [announcement, setAnnouncement] = useState("");
if (previous.signature !== signature) {
setPrevious({ signature, results });
setAnnouncement(describeChanges(previous.results, results));
}
return (
<>
<ul
id={id}
aria-label="Password requirements"
className={cn("m-0 mt-2 grid list-none gap-1 p-0", className)}
>
{results.map(({ id: ruleId, label, met }) => (
<li key={ruleId} data-rule={ruleId} data-met={met} className="flex items-center gap-1.5">
{met ? (
<CircleCheck size={13} aria-hidden="true" className="text-(--color-success)" />
) : (
<Circle size={13} aria-hidden="true" className="text-(--color-text-muted)" />
)}
<Text variant="caption" tone={met ? "success" : "muted"}>
{label}
</Text>
<span className="sr-only">{met ? "requirement met" : "requirement not met"}</span>
</li>
))}
</ul>
<Text variant="feedback" className="sr-only">
{announcement}
</Text>
</>
);
}

View file

@ -1,11 +1,14 @@
import { useState } from "react";
import { Link, useLocation } from "react-router";
import { Box, Drawer } from "@mui/material";
import { CircleUserRound } from "lucide-react";
import { BrandLockup, BrandMark } from "@/components/common/brand-lockup";
import { useNavGroups } from "@/components/layout/use-nav-groups";
import { getActiveNavPath, getSidebarWidth, type NavItemDef } from "@/config/menu";
const ACCOUNT_PATH = "/account";
function getNavItemAppearance(isActive: boolean, isHover: boolean) {
if (isActive) {
return {
@ -222,6 +225,21 @@ export function AppSidebar({ collapsed, mobile = false, open = true, onClose }:
</Box>
))}
</Box>
<Box
component="nav"
aria-label="Account navigation"
sx={{ borderTop: "1px solid var(--color-sidebar-border)", p: "8px" }}
>
<NavItem
label="My Account"
icon={CircleUserRound}
path={ACCOUNT_PATH}
collapsed={collapsed}
active={pathname === ACCOUNT_PATH}
onNavigate={mobile ? onClose : undefined}
/>
</Box>
</Box>
</Drawer>
);

View file

@ -0,0 +1,53 @@
import { z } from "zod";
/**
* The single password rule for every surface that sets a password. It mirrors the
* server's Identity options: the character classes are ASCII, matching how the
* server classifies uppercase letters, digits and non-alphanumeric characters.
*/
export const PASSWORD_MIN_LENGTH = 6;
export type PasswordRuleId = "length" | "uppercase" | "number" | "special";
export type PasswordRule = {
id: PasswordRuleId;
label: string;
test: (password: string) => boolean;
};
export type PasswordRuleResult = {
id: PasswordRuleId;
label: string;
met: boolean;
};
export const PASSWORD_RULES: readonly PasswordRule[] = [
{
id: "length",
label: `At least ${PASSWORD_MIN_LENGTH} characters`,
test: (password) => password.length >= PASSWORD_MIN_LENGTH,
},
{ id: "uppercase", label: "One uppercase letter", test: (password) => /[A-Z]/.test(password) },
{ id: "number", label: "One number", test: (password) => /[0-9]/.test(password) },
{
id: "special",
label: "One special character",
test: (password) => /[^A-Za-z0-9]/.test(password),
},
];
export const PASSWORD_REQUIREMENTS_MESSAGE = "Password must meet every requirement below";
export const PASSWORDS_DO_NOT_MATCH_MESSAGE = "Passwords don't match";
export function evaluatePasswordRules(password: string): PasswordRuleResult[] {
return PASSWORD_RULES.map(({ id, label, test }) => ({ id, label, met: test(password) }));
}
export function meetsPasswordPolicy(password: string): boolean {
return PASSWORD_RULES.every((rule) => rule.test(password));
}
export const passwordSchema = z.string().refine(meetsPasswordPolicy, {
message: PASSWORD_REQUIREMENTS_MESSAGE,
});

View file

@ -0,0 +1,18 @@
import { z } from "zod";
import { PASSWORDS_DO_NOT_MATCH_MESSAGE, passwordSchema } from "@/domain/auth/password-policy";
export const CURRENT_PASSWORD_INCORRECT_MESSAGE = "Current password is incorrect";
export const changePasswordSchema = z
.object({
currentPassword: z.string().min(1, "Current password is required"),
newPassword: passwordSchema,
confirmPassword: z.string(),
})
.refine((values) => values.newPassword === values.confirmPassword, {
path: ["confirmPassword"],
message: PASSWORDS_DO_NOT_MATCH_MESSAGE,
});
export type ChangePasswordFormValues = z.infer<typeof changePasswordSchema>;

View file

@ -3,14 +3,13 @@ import { toast } from "react-toastify";
import * as authApi from "@/domain/auth/api/auth-api";
import type { ChangePasswordPayload } from "@/domain/auth/types/auth-user";
/** Errors are rendered inline by the form that initiated the change. */
export function useChangePassword(): UseMutationResult<unknown, Error, ChangePasswordPayload> {
return useMutation({
mutationFn: (data: ChangePasswordPayload) => authApi.changePassword(data),
meta: { suppressErrorToast: true },
onSuccess: () => {
toast.success("Password changed successfully!");
},
onError: (error: Error) => {
toast.error(error.message || "Failed to change password");
toast.success("Password updated");
},
});
}

View file

@ -0,0 +1,144 @@
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { toast } from "react-toastify";
import { beforeEach, describe, expect, it, vi } from "vitest";
import MyAccountPage from "@/app/(protected)/account";
import * as authApi from "@/domain/auth/api/auth-api";
import type { AuthUser } from "@/domain/auth/types/auth-user";
import { createAppQueryClient } from "@/lib/query/query-client";
import { renderWithProviders } from "@/test/test-utils";
const { toastSuccessMock } = vi.hoisted(() => ({ toastSuccessMock: vi.fn() }));
const SIGNED_IN_USER: AuthUser = {
token: "test-jwt-token",
expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
email: "dana@seahavenind.com",
userRoles: "Dispatcher",
phoneNumber: "5551234567",
fullname: "Dana Reyes",
id: "user-1",
};
vi.mock("@/domain/auth/api/auth-api", () => ({
getSession: vi.fn(),
login: vi.fn(),
logout: vi.fn(),
changePassword: vi.fn(),
}));
vi.mock("react-toastify", () => ({
toast: { success: toastSuccessMock, error: vi.fn(), info: vi.fn() },
}));
/** Uses the app's query client, so the global mutation error toast is live. */
function renderPage() {
return renderWithProviders(<MyAccountPage />, {
route: "/account",
queryClient: createAppQueryClient(),
});
}
function field(label: RegExp) {
return screen.getByLabelText(label, { selector: "input" });
}
async function fillPasswords(
user: ReturnType<typeof userEvent.setup>,
{ current, next, confirm }: { current: string; next: string; confirm: string },
) {
await user.type(field(/^current password/i), current);
await user.type(field(/^new password/i), next);
await user.type(field(/^confirm new password/i), confirm);
}
describe("My Account page", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(authApi.getSession).mockResolvedValue(SIGNED_IN_USER);
});
it("shows the signed-in user's name, role and email as read-only details", async () => {
renderPage();
const details = await screen.findByRole("region", { name: "Account details" });
expect(within(details).getByText("Dana Reyes")).toBeInTheDocument();
expect(within(details).getByText("Dispatcher")).toBeInTheDocument();
expect(within(details).getByText("dana@seahavenind.com")).toBeInTheDocument();
expect(within(details).queryByRole("textbox")).not.toBeInTheDocument();
});
it("shows the live checklist for the new password", async () => {
const user = userEvent.setup();
renderPage();
const checklist = screen.getByRole("list", { name: "Password requirements" });
expect(within(checklist).getAllByText("requirement not met")).toHaveLength(4);
await user.type(field(/^new password/i), "Abc1!x");
expect(within(checklist).getAllByText("requirement met")).toHaveLength(4);
});
it("keeps Update password disabled until every rule is met and the confirmation matches", async () => {
const user = userEvent.setup();
renderPage();
const submit = screen.getByRole("button", { name: "Update password" });
await fillPasswords(user, { current: "Current1!", next: "abcdef", confirm: "abcdef" });
expect(submit).toBeDisabled();
await user.clear(field(/^new password/i));
await user.type(field(/^new password/i), "Abc1!x");
expect(submit).toBeDisabled();
await user.clear(field(/^confirm new password/i));
await user.type(field(/^confirm new password/i), "Abc1!x");
expect(submit).toBeEnabled();
expect(authApi.changePassword).not.toHaveBeenCalled();
});
it("tells the user when the confirmation does not match", async () => {
const user = userEvent.setup();
renderPage();
await fillPasswords(user, { current: "Current1!", next: "Abc1!x", confirm: "Abc1!y" });
await user.tab();
expect(await screen.findByText("Passwords don't match")).toBeInTheDocument();
});
it("sends the current and new password and clears the form after a successful change", async () => {
vi.mocked(authApi.changePassword).mockResolvedValue({ status: "Success " });
const user = userEvent.setup();
renderPage();
await fillPasswords(user, { current: "Current1!", next: "Abc1!x", confirm: "Abc1!x" });
await user.click(screen.getByRole("button", { name: "Update password" }));
await waitFor(() =>
expect(authApi.changePassword).toHaveBeenCalledWith({
Currentpassword: "Current1!",
Newpassword: "Abc1!x",
Confirmpassword: "Abc1!x",
}),
);
await waitFor(() => expect(field(/^current password/i)).toHaveValue(""));
expect(toastSuccessMock).toHaveBeenCalledWith("Password updated");
});
it("shows the server's current-password rejection on the current password field", async () => {
vi.mocked(authApi.changePassword).mockRejectedValue(new Error("Current password is incorrect"));
const user = userEvent.setup();
renderPage();
await fillPasswords(user, { current: "Wrong1!", next: "Abc1!x", confirm: "Abc1!x" });
await user.click(screen.getByRole("button", { name: "Update password" }));
expect(await screen.findByText("Current password is incorrect")).toBeInTheDocument();
expect(field(/^current password/i)).toHaveAttribute("aria-invalid", "true");
expect(toastSuccessMock).not.toHaveBeenCalled();
expect(vi.mocked(toast.error)).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,99 @@
import { useState } from "react";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { PasswordChecklist } from "@/components/auth/password-checklist";
function ChecklistHarness() {
const [password, setPassword] = useState("");
return (
<>
<label htmlFor="pw">Password</label>
<input id="pw" value={password} onChange={(event) => setPassword(event.target.value)} />
<PasswordChecklist password={password} />
</>
);
}
function ruleStates(): Record<string, string | null> {
const list = screen.getByRole("list", { name: "Password requirements" });
return Object.fromEntries(
within(list)
.getAllByRole("listitem")
.map((item) => [item.getAttribute("data-rule") ?? "", item.getAttribute("data-met")]),
);
}
describe("PasswordChecklist", () => {
it("starts with every rule unmet", () => {
render(<ChecklistHarness />);
expect(ruleStates()).toEqual({
length: "false",
uppercase: "false",
number: "false",
special: "false",
});
expect(screen.getAllByText("requirement not met")).toHaveLength(4);
});
it("marks each rule met live as the user types", async () => {
const user = userEvent.setup();
render(<ChecklistHarness />);
const input = screen.getByLabelText("Password");
await user.type(input, "A");
expect(ruleStates()).toMatchObject({ uppercase: "true", number: "false", special: "false" });
await user.type(input, "1");
expect(ruleStates()).toMatchObject({ uppercase: "true", number: "true", special: "false" });
await user.type(input, "!");
expect(ruleStates()).toMatchObject({ special: "true", length: "false" });
await user.type(input, "xyz");
expect(ruleStates()).toEqual({
length: "true",
uppercase: "true",
number: "true",
special: "true",
});
expect(screen.getAllByText("requirement met")).toHaveLength(4);
});
it("announces only the rules whose state changed through a polite status region", async () => {
const user = userEvent.setup();
render(<ChecklistHarness />);
const input = screen.getByLabelText("Password");
const status = screen.getByRole("status");
expect(status).toHaveAttribute("aria-live", "polite");
expect(status).toHaveTextContent(/^$/);
await user.type(input, "A");
expect(status).toHaveTextContent(/^One uppercase letter: requirement met$/);
await user.type(input, "b");
expect(status).toHaveTextContent(/^One uppercase letter: requirement met$/);
await user.type(input, "1!xy");
expect(status).toHaveTextContent(/^At least 6 characters: requirement met$/);
await user.clear(input);
expect(status).toHaveTextContent(
"At least 6 characters: requirement not met. One uppercase letter: requirement not met. One number: requirement not met. One special character: requirement not met",
);
});
it("marks a rule unmet again when the user deletes the character that satisfied it", async () => {
const user = userEvent.setup();
render(<ChecklistHarness />);
const input = screen.getByLabelText("Password");
await user.type(input, "Abcde1!");
await user.type(input, "{Backspace}");
expect(ruleStates()).toMatchObject({ special: "false", length: "true", number: "true" });
});
});

View file

@ -26,6 +26,17 @@ function sidebarLinks(): Record<string, string> {
}
describe("AppSidebar navigation entries", () => {
it("links every signed-in user to My Account and marks it current on that page", () => {
canApproveMock.mockReturnValue({ data: false });
renderSidebar("/account");
const accountNav = screen.getByRole("navigation", { name: "Account navigation" });
const link = within(accountNav).getByRole("link", { name: "My Account" });
expect(link).toHaveAttribute("href", "/account");
expect(link).toHaveAttribute("aria-current", "page");
});
it("shows Uplift Approvals for a user the backend says can approve uplifts", () => {
canApproveMock.mockReturnValue({ data: true });

View file

@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import {
PASSWORD_RULES,
evaluatePasswordRules,
meetsPasswordPolicy,
passwordSchema,
} from "@/domain/auth/password-policy";
function unmetRules(password: string): string[] {
return evaluatePasswordRules(password)
.filter((rule) => !rule.met)
.map((rule) => rule.id);
}
describe("password policy", () => {
it("lists the four rules in checklist order with the shared copy", () => {
expect(PASSWORD_RULES.map((rule) => rule.label)).toEqual([
"At least 6 characters",
"One uppercase letter",
"One number",
"One special character",
]);
});
it.each([
["Ab1!x", "length"],
["abc12!", "uppercase"],
["Abcde!", "number"],
["Abcde1", "special"],
])("rejects %s because only the %s rule is missing", (password, missingRule) => {
expect(unmetRules(password)).toEqual([missingRule]);
expect(meetsPasswordPolicy(password)).toBe(false);
expect(passwordSchema.safeParse(password).success).toBe(false);
});
it.each(["Abc1!x", "ABC12!"])("accepts the six-character compliant password %s", (password) => {
expect(unmetRules(password)).toEqual([]);
expect(meetsPasswordPolicy(password)).toBe(true);
expect(passwordSchema.safeParse(password).success).toBe(true);
});
it("treats a non-ASCII letter as a special character, as the server does", () => {
expect(unmetRules("Abcdé1")).toEqual([]);
});
});