mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 06:53:12 +00:00
feat(account): My Account page with shared password checklist (SH-386)
Adds a My Account page, reachable from the sidebar, with the signed-in user's read-only details and a Change Password section. The new password is checked live against the shared password rules (6+ characters, an uppercase letter, a number and a special character), which the invite registration flow reuses. The current password is still verified by the server first, and its rejection is shown on the current password field.
This commit is contained in:
parent
358ec3e890
commit
e6250fba88
12 changed files with 587 additions and 4 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
24
src/app/(protected)/account/index.tsx
Normal file
24
src/app/(protected)/account/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
src/components/auth/password-checklist.tsx
Normal file
38
src/components/auth/password-checklist.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { Circle, CircleCheck } from "lucide-react";
|
||||
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { evaluatePasswordRules } from "@/domain/auth/password-policy";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type PasswordChecklistProps = {
|
||||
password: string;
|
||||
id?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/** Live, per-rule feedback for the shared password policy. */
|
||||
export function PasswordChecklist({ password, id, className }: PasswordChecklistProps) {
|
||||
const results = evaluatePasswordRules(password);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
53
src/domain/auth/password-policy.ts
Normal file
53
src/domain/auth/password-policy.ts
Normal 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,
|
||||
});
|
||||
18
src/domain/auth/schemas/change-password-schema.ts
Normal file
18
src/domain/auth/schemas/change-password-schema.ts
Normal 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>;
|
||||
|
|
@ -3,14 +3,12 @@ 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),
|
||||
onSuccess: () => {
|
||||
toast.success("Password changed successfully!");
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to change password");
|
||||
toast.success("Password updated");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
137
src/test/app/(protected)/account/my-account-page.test.tsx
Normal file
137
src/test/app/(protected)/account/my-account-page.test.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
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 { 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() },
|
||||
}));
|
||||
|
||||
function renderPage() {
|
||||
return renderWithProviders(<MyAccountPage />, { route: "/account" });
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
75
src/test/components/auth/password-checklist.test.tsx
Normal file
75
src/test/components/auth/password-checklist.test.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
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("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" });
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
46
src/test/domain/auth/password-policy.test.ts
Normal file
46
src/test/domain/auth/password-policy.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue