diff --git a/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png b/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png index 8ee41bac..61410e8e 100644 Binary files a/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png and b/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png differ diff --git a/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png b/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png index 0bcbb17a..c73fc9e3 100644 Binary files a/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png and b/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png index bcd00f31..0c695940 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png index 08602f38..0b081cba 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png index a22451fa..18302e36 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png index ad205418..a2f46763 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png index ee67ef29..1ba120c7 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png index 0e97260a..869457df 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png index da22ab30..6a2eb6e5 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png index 59dc78f3..53582910 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png index 6fab2b85..c9f0986a 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png index cd50b14e..8382b445 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png index 4e0ca158..5f3e96ae 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png index 2db0cea4..534e8552 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png index 5c41b91f..117f2d30 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png index d57d69c0..b6349d96 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png index 9707bda7..cae7003a 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png index 0e49025d..1d727318 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png differ diff --git a/src/app/(protected)/account/_components/account-summary-card.tsx b/src/app/(protected)/account/_components/account-summary-card.tsx new file mode 100644 index 00000000..bb064449 --- /dev/null +++ b/src/app/(protected)/account/_components/account-summary-card.tsx @@ -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 ( +
+ + {label} + + + {value || "—"} + +
+ ); +} + +export function AccountSummaryCard({ user }: { user: AuthUser }) { + return ( +
+
+ + +
+ + + Contact your Admin to change your name, role, or email. + +
+ ); +} diff --git a/src/app/(protected)/account/_components/change-password-section.tsx b/src/app/(protected)/account/_components/change-password-section.tsx new file mode 100644 index 00000000..c464f6ed --- /dev/null +++ b/src/app/(protected)/account/_components/change-password-section.tsx @@ -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({ + 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 ( + +
+
+ + + + + + + + + + + + + + + {showServerError && {serverError}} + +
+ +
+
+ ); +} diff --git a/src/app/(protected)/account/index.tsx b/src/app/(protected)/account/index.tsx new file mode 100644 index 00000000..1a645a09 --- /dev/null +++ b/src/app/(protected)/account/index.tsx @@ -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 ( + + + + {user != null && } + + + + ); +} diff --git a/src/components/auth/password-checklist.tsx b/src/components/auth/password-checklist.tsx new file mode 100644 index 00000000..41b5e7ac --- /dev/null +++ b/src/components/auth/password-checklist.tsx @@ -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 ( + <> + + + {announcement} + + + ); +} diff --git a/src/components/layout/app-sidebar.tsx b/src/components/layout/app-sidebar.tsx index e897bb89..92ddfcca 100644 --- a/src/components/layout/app-sidebar.tsx +++ b/src/components/layout/app-sidebar.tsx @@ -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 }: ))} + + + + ); diff --git a/src/domain/auth/password-policy.ts b/src/domain/auth/password-policy.ts new file mode 100644 index 00000000..b1ed46f8 --- /dev/null +++ b/src/domain/auth/password-policy.ts @@ -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, +}); diff --git a/src/domain/auth/schemas/change-password-schema.ts b/src/domain/auth/schemas/change-password-schema.ts new file mode 100644 index 00000000..be3118f9 --- /dev/null +++ b/src/domain/auth/schemas/change-password-schema.ts @@ -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; diff --git a/src/domain/auth/use-cases/use-change-password.ts b/src/domain/auth/use-cases/use-change-password.ts index eec15235..c86e7860 100644 --- a/src/domain/auth/use-cases/use-change-password.ts +++ b/src/domain/auth/use-cases/use-change-password.ts @@ -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 { 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"); }, }); } diff --git a/src/test/app/(protected)/account/my-account-page.test.tsx b/src/test/app/(protected)/account/my-account-page.test.tsx new file mode 100644 index 00000000..65c5ad3e --- /dev/null +++ b/src/test/app/(protected)/account/my-account-page.test.tsx @@ -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(, { + route: "/account", + queryClient: createAppQueryClient(), + }); +} + +function field(label: RegExp) { + return screen.getByLabelText(label, { selector: "input" }); +} + +async function fillPasswords( + user: ReturnType, + { 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(); + }); +}); diff --git a/src/test/components/auth/password-checklist.test.tsx b/src/test/components/auth/password-checklist.test.tsx new file mode 100644 index 00000000..2b7bc6dc --- /dev/null +++ b/src/test/components/auth/password-checklist.test.tsx @@ -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 ( + <> + + setPassword(event.target.value)} /> + + + ); +} + +function ruleStates(): Record { + 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(); + + 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(); + 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(); + 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(); + const input = screen.getByLabelText("Password"); + + await user.type(input, "Abcde1!"); + await user.type(input, "{Backspace}"); + + expect(ruleStates()).toMatchObject({ special: "false", length: "true", number: "true" }); + }); +}); diff --git a/src/test/components/layout/app-sidebar-nav.test.tsx b/src/test/components/layout/app-sidebar-nav.test.tsx index 07e58155..7565d3e7 100644 --- a/src/test/components/layout/app-sidebar-nav.test.tsx +++ b/src/test/components/layout/app-sidebar-nav.test.tsx @@ -26,6 +26,17 @@ function sidebarLinks(): Record { } 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 }); diff --git a/src/test/domain/auth/password-policy.test.ts b/src/test/domain/auth/password-policy.test.ts new file mode 100644 index 00000000..486f925a --- /dev/null +++ b/src/test/domain/auth/password-policy.test.ts @@ -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([]); + }); +});