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:
Alexandre Brandizzi 2026-09-25 11:16:59 -03:00
parent 358ec3e890
commit e6250fba88
12 changed files with 587 additions and 4 deletions

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,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>
);
}

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,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");
},
});
}

View 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();
});
});

View 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" });
});
});

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([]);
});
});