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 (
+
+
+
+
+ Change Password
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {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..7abfa718
--- /dev/null
+++ b/src/components/auth/password-checklist.tsx
@@ -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 (
+
+ {results.map(({ id: ruleId, label, met }) => (
+ -
+ {met ? (
+
+ ) : (
+
+ )}
+
+ {label}
+
+ {met ? "requirement met" : "requirement not met"}
+
+ ))}
+
+ );
+}
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..90d1d0df 100644
--- a/src/domain/auth/use-cases/use-change-password.ts
+++ b/src/domain/auth/use-cases/use-change-password.ts
@@ -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 {
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");
},
});
}
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..135b0db5
--- /dev/null
+++ b/src/test/app/(protected)/account/my-account-page.test.tsx
@@ -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(, { route: "/account" });
+}
+
+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();
+ });
+});
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..391310b4
--- /dev/null
+++ b/src/test/components/auth/password-checklist.test.tsx
@@ -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 (
+ <>
+
+ 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("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([]);
+ });
+});