mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 15:09:02 +00:00
Merge cadfe1ee8d into 358ec3e890
This commit is contained in:
commit
f4a3133416
12 changed files with 636 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
src/components/auth/password-checklist.tsx
Normal file
63
src/components/auth/password-checklist.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link, useLocation } from "react-router";
|
import { Link, useLocation } from "react-router";
|
||||||
import { Box, Drawer } from "@mui/material";
|
import { Box, Drawer } from "@mui/material";
|
||||||
|
import { CircleUserRound } from "lucide-react";
|
||||||
|
|
||||||
import { BrandLockup, BrandMark } from "@/components/common/brand-lockup";
|
import { BrandLockup, BrandMark } from "@/components/common/brand-lockup";
|
||||||
import { useNavGroups } from "@/components/layout/use-nav-groups";
|
import { useNavGroups } from "@/components/layout/use-nav-groups";
|
||||||
import { getActiveNavPath, getSidebarWidth, type NavItemDef } from "@/config/menu";
|
import { getActiveNavPath, getSidebarWidth, type NavItemDef } from "@/config/menu";
|
||||||
|
|
||||||
|
const ACCOUNT_PATH = "/account";
|
||||||
|
|
||||||
function getNavItemAppearance(isActive: boolean, isHover: boolean) {
|
function getNavItemAppearance(isActive: boolean, isHover: boolean) {
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -222,6 +225,21 @@ export function AppSidebar({ collapsed, mobile = false, open = true, onClose }:
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</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>
|
</Box>
|
||||||
</Drawer>
|
</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 * as authApi from "@/domain/auth/api/auth-api";
|
||||||
import type { ChangePasswordPayload } from "@/domain/auth/types/auth-user";
|
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> {
|
export function useChangePassword(): UseMutationResult<unknown, Error, ChangePasswordPayload> {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (data: ChangePasswordPayload) => authApi.changePassword(data),
|
mutationFn: (data: ChangePasswordPayload) => authApi.changePassword(data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Password changed successfully!");
|
toast.success("Password updated");
|
||||||
},
|
|
||||||
onError: (error: Error) => {
|
|
||||||
toast.error(error.message || "Failed to change password");
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
99
src/test/components/auth/password-checklist.test.tsx
Normal file
99
src/test/components/auth/password-checklist.test.tsx
Normal 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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -26,6 +26,17 @@ function sidebarLinks(): Record<string, string> {
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("AppSidebar navigation entries", () => {
|
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", () => {
|
it("shows Uplift Approvals for a user the backend says can approve uplifts", () => {
|
||||||
canApproveMock.mockReturnValue({ data: true });
|
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