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/api/api-paths.ts b/src/api/api-paths.ts
index b5da6165..b9842f15 100644
--- a/src/api/api-paths.ts
+++ b/src/api/api-paths.ts
@@ -83,6 +83,13 @@ export const API_PATHS = {
getList: "User",
create: "team-members",
detail: "team-members",
+ invite: (id: string | number) => `team-members/${encodeURIComponent(String(id))}/invite`,
+ },
+ teamMemberInvite: {
+ resolve: "team-member-invites/resolve",
+ sendCode: "team-member-invites/send-code",
+ verifyCode: "team-member-invites/verify-code",
+ complete: "team-member-invites/complete",
},
contact: {
getList: "Contact/GetContactList",
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/app/(protected)/team-members/_components/resend-invite-button.tsx b/src/app/(protected)/team-members/_components/resend-invite-button.tsx
new file mode 100644
index 00000000..4bc9bf51
--- /dev/null
+++ b/src/app/(protected)/team-members/_components/resend-invite-button.tsx
@@ -0,0 +1,26 @@
+import { Mail } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import type { TeamMemberDetail } from "@/domain/team-members/types/team-member";
+import { useResendTeamMemberInvite } from "@/domain/team-members/use-cases/use-resend-team-member-invite";
+
+/** Re-sends the registration invite to an active member who has not registered yet. */
+export function ResendInviteButton({ member }: { member: TeamMemberDetail }) {
+ const resendInvite = useResendTeamMemberInvite();
+
+ if (!member.pendingRegistration || member.status !== "Active") {
+ return null;
+ }
+
+ return (
+
+ );
+}
diff --git a/src/app/(protected)/team-members/_components/team-member-detail-drawer.tsx b/src/app/(protected)/team-members/_components/team-member-detail-drawer.tsx
index 7c00b463..dde592a5 100644
--- a/src/app/(protected)/team-members/_components/team-member-detail-drawer.tsx
+++ b/src/app/(protected)/team-members/_components/team-member-detail-drawer.tsx
@@ -37,6 +37,7 @@ import {
import { getFormErrors } from "./team-member-permissions";
import { TeamMemberFormFields, type TeamMemberForm } from "./add-team-member-dialog";
import { canonicalRole, resolveFormColor } from "./team-member-form-options";
+import { ResendInviteButton } from "./resend-invite-button";
import {
useTeamMemberDetail,
useUpdateTeamMember,
@@ -206,6 +207,7 @@ function MemberEditForm({
}
function DrawerFooter({
+ member,
editing,
form,
setForm,
@@ -215,6 +217,7 @@ function DrawerFooter({
onEdit,
onSave,
}: {
+ member: TeamMemberDetail;
editing: boolean;
form: TeamMemberForm;
setForm: Dispatch>;
@@ -257,7 +260,8 @@ function DrawerFooter({
) : (
-
+
+
@@ -475,6 +479,7 @@ export function TeamMemberDetailDrawer({
{currentMember !== undefined && (
void;
+ onResend: () => void;
+ onCodeChange: () => void;
+};
+
+export function InviteCodeStep({
+ email,
+ isVerifying,
+ isResending,
+ codeError,
+ resendError,
+ onVerify,
+ onResend,
+ onCodeChange,
+}: InviteCodeStepProps) {
+ const { register, handleSubmit, control } = useForm({
+ resolver: zodResolver(inviteCodeStepSchema),
+ defaultValues: { code: "" },
+ });
+
+ const values = useWatch({ control });
+ const canContinue = inviteCodeStepSchema.safeParse(values).success;
+ const codeField = register("code", { onChange: onCodeChange });
+
+ return (
+ onVerify(form.code))}
+ className="flex flex-col gap-4"
+ >
+
+
+
+
+
+
+
+
+
+ {resendError}
+
+
+
+
+
+ );
+}
diff --git a/src/app/invite/_components/invite-password-step.tsx b/src/app/invite/_components/invite-password-step.tsx
new file mode 100644
index 00000000..8696af09
--- /dev/null
+++ b/src/app/invite/_components/invite-password-step.tsx
@@ -0,0 +1,101 @@
+import { useForm, useWatch } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { Box } from "@mui/material";
+
+import { PasswordChecklist } from "@/components/auth/password-checklist";
+import { Button } from "@/components/ui/button";
+import { Text } from "@/components/ui/text";
+import { FormField } from "@/components/ui/form-field";
+import { PasswordField } from "@/components/ui/password-field";
+import {
+ invitePasswordStepSchema,
+ type InvitePasswordStepValues,
+} from "@/domain/team-member-invite/schemas/invite-registration-schema";
+
+import { InviteStepHeader } from "./invite-step-header";
+
+export type InvitePasswordStepProps = {
+ name: string;
+ defaultPassword: string;
+ isSubmitting: boolean;
+ error: string | null;
+ onContinue: (password: string) => void;
+};
+
+export function InvitePasswordStep({
+ name,
+ defaultPassword,
+ isSubmitting,
+ error,
+ onContinue,
+}: InvitePasswordStepProps) {
+ const {
+ register,
+ handleSubmit,
+ control,
+ formState: { errors },
+ } = useForm({
+ resolver: zodResolver(invitePasswordStepSchema),
+ defaultValues: { password: defaultPassword, confirmPassword: defaultPassword },
+ mode: "onTouched",
+ });
+
+ const values = useWatch({ control });
+ const canContinue = invitePasswordStepSchema.safeParse(values).success;
+
+ return (
+ onContinue(form.password))}
+ className="flex flex-col gap-4"
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+ {error}
+
+
+
+
+ );
+}
diff --git a/src/app/invite/_components/invite-review-step.tsx b/src/app/invite/_components/invite-review-step.tsx
new file mode 100644
index 00000000..53d7ed96
--- /dev/null
+++ b/src/app/invite/_components/invite-review-step.tsx
@@ -0,0 +1,100 @@
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { Box } from "@mui/material";
+import { CircleCheck } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { FormField } from "@/components/ui/form-field";
+import { Text } from "@/components/ui/text";
+import { TextField } from "@/components/ui/text-field";
+import {
+ inviteReviewStepSchema,
+ type InviteReviewStepValues,
+} from "@/domain/team-member-invite/schemas/invite-registration-schema";
+import type { TeamMemberInviteDetails } from "@/domain/team-member-invite/types/team-member-invite";
+
+import { InviteStepHeader } from "./invite-step-header";
+
+export type InviteReviewStepProps = {
+ details: TeamMemberInviteDetails;
+ isFinishing: boolean;
+ error: string | null;
+ onFinish: (phone: string) => void;
+};
+
+function ReadOnlyValue({ label, value }: { label: string; value: string }) {
+ return (
+
+
+ {label}
+
+
+ {value}
+
+
+ );
+}
+
+export function InviteReviewStep({ details, isFinishing, error, onFinish }: InviteReviewStepProps) {
+ const {
+ register,
+ handleSubmit,
+ formState: { errors },
+ } = useForm({
+ resolver: zodResolver(inviteReviewStepSchema),
+ defaultValues: { phone: details.phone ?? "" },
+ mode: "onTouched",
+ });
+
+ return (
+ onFinish(form.phone))}
+ className="flex flex-col gap-4"
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+ {error}
+
+
+ }
+ >
+ Finish registration
+
+
+ );
+}
diff --git a/src/app/invite/_components/invite-step-header.tsx b/src/app/invite/_components/invite-step-header.tsx
new file mode 100644
index 00000000..b6437d27
--- /dev/null
+++ b/src/app/invite/_components/invite-step-header.tsx
@@ -0,0 +1,46 @@
+import { Text } from "@/components/ui/text";
+import { cn } from "@/lib/utils";
+
+export const INVITE_STEP_COUNT = 3;
+
+export type InviteStep = 1 | 2 | 3;
+
+export type InviteStepHeaderProps = {
+ step: InviteStep;
+ title: string;
+ subtitle: string;
+};
+
+export function InviteStepHeader({ step, title, subtitle }: InviteStepHeaderProps) {
+ return (
+
+
+ {[1, 2, 3].map((bar) => (
+
+ ))}
+
+
+ {`Step ${step} of ${INVITE_STEP_COUNT}`}
+
+
+ {title}
+
+
+ {subtitle}
+
+
+ );
+}
diff --git a/src/app/invite/_components/invite-wizard.tsx b/src/app/invite/_components/invite-wizard.tsx
new file mode 100644
index 00000000..1dba3457
--- /dev/null
+++ b/src/app/invite/_components/invite-wizard.tsx
@@ -0,0 +1,137 @@
+import { useState } from "react";
+import { toast } from "react-toastify";
+
+import { inviteErrorCode } from "@/domain/team-member-invite/api/team-member-invite-api";
+import type { TeamMemberInviteDetails } from "@/domain/team-member-invite/types/team-member-invite";
+import {
+ useCompleteRegistration,
+ useSendInviteCode,
+ useVerifyInviteCode,
+} from "@/domain/team-member-invite/use-cases/use-team-member-invite";
+
+import { InviteCodeStep } from "./invite-code-step";
+import { InvitePasswordStep } from "./invite-password-step";
+import { InviteReviewStep } from "./invite-review-step";
+import type { InviteStep } from "./invite-step-header";
+
+export type InviteWizardProps = {
+ token: string;
+ details: TeamMemberInviteDetails;
+ /** Called when the server reports the invite can no longer be used. */
+ onInviteInvalid: () => void;
+};
+
+export function InviteWizard({ token, details, onInviteInvalid }: InviteWizardProps) {
+ const [step, setStep] = useState(1);
+ const [password, setPassword] = useState("");
+ const [stepError, setStepError] = useState(null);
+ const [codeError, setCodeError] = useState(null);
+ const [resendError, setResendError] = useState(null);
+ const sendCode = useSendInviteCode();
+ const verifyCode = useVerifyInviteCode();
+ const complete = useCompleteRegistration();
+
+ /** Returns true when the failure ended the invite and the wizard should stop. */
+ const endedInvite = (error: Error) => {
+ if (inviteErrorCode(error) !== "invalid_invite") {
+ return false;
+ }
+ onInviteInvalid();
+ return true;
+ };
+
+ const handlePasswordContinue = (nextPassword: string) => {
+ setPassword(nextPassword);
+ setStepError(null);
+ sendCode.mutate(token, {
+ onSuccess: () => setStep(2),
+ onError: (error) => {
+ if (endedInvite(error)) return;
+ // A code sent moments ago is still valid, so the member can go on and enter it.
+ if (inviteErrorCode(error) === "resend_too_soon") {
+ setStep(2);
+ return;
+ }
+ setStepError(error.message);
+ },
+ });
+ };
+
+ const handleResend = () => {
+ setResendError(null);
+ sendCode.mutate(token, {
+ onSuccess: () => {
+ setCodeError(null);
+ toast.success(`Code resent to ${details.email}`);
+ },
+ onError: (error) => {
+ if (!endedInvite(error)) setResendError(error.message);
+ },
+ });
+ };
+
+ const handleVerify = (code: string) => {
+ setCodeError(null);
+ verifyCode.mutate(
+ { token, code },
+ {
+ onSuccess: () => setStep(3),
+ onError: (error) => {
+ if (!endedInvite(error)) setCodeError(error.message);
+ },
+ },
+ );
+ };
+
+ const handleFinish = (phone: string) => {
+ setStepError(null);
+ complete.mutate(
+ { token, password, phone },
+ {
+ onError: (error) => {
+ if (endedInvite(error)) return;
+ if (inviteErrorCode(error) === "password_rejected") {
+ setStep(1);
+ }
+ setStepError(error.message);
+ },
+ },
+ );
+ };
+
+ if (step === 1) {
+ return (
+
+ );
+ }
+
+ if (step === 2) {
+ return (
+ setCodeError(null)}
+ />
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/src/app/invite/index.tsx b/src/app/invite/index.tsx
new file mode 100644
index 00000000..1c56def5
--- /dev/null
+++ b/src/app/invite/index.tsx
@@ -0,0 +1,61 @@
+import { useState } from "react";
+import { Link, useLocation } from "react-router";
+import { Box, CircularProgress } from "@mui/material";
+import { ChevronLeft } from "lucide-react";
+
+import { AuthBrandLogo } from "@/app/(auth)/_components/auth-brand-logo";
+import { AuthCardHeader } from "@/app/(auth)/_components/auth-card-header";
+import { AuthPageShell } from "@/app/(auth)/_components/auth-page-shell";
+import { INVALID_INVITE_MESSAGE } from "@/domain/team-member-invite/types/team-member-invite";
+import { useTeamMemberInviteDetails } from "@/domain/team-member-invite/use-cases/use-team-member-invite";
+
+import { InviteWizard } from "./_components/invite-wizard";
+
+/** The invite email links to `/invite#`; a fragment never reaches a server or a Referer. */
+function readInviteToken(hash: string): string {
+ return hash.replace(/^#/, "").trim();
+}
+
+function InviteBody({ token }: { token: string }) {
+ const details = useTeamMemberInviteDetails(token);
+ const [inviteEnded, setInviteEnded] = useState(false);
+
+ if (token.length > 0 && details.isPending) {
+ return (
+
+
+
+ );
+ }
+
+ if (inviteEnded || details.data === undefined) {
+ return ;
+ }
+
+ return (
+ setInviteEnded(true)}
+ />
+ );
+}
+
+export default function InviteRegistrationPage() {
+ const { hash } = useLocation();
+ const token = readInviteToken(hash);
+
+ return (
+
+
+
+
+ Back to login
+
+
+
+ );
+}
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 (
+ <>
+
+ {results.map(({ id: ruleId, label, met }) => (
+ -
+ {met ? (
+
+ ) : (
+
+ )}
+
+ {label}
+
+ {met ? "requirement met" : "requirement not met"}
+
+ ))}
+
+
+ {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/domain/auth/use-cases/use-login.ts b/src/domain/auth/use-cases/use-login.ts
index 55766b90..dbd40a4b 100644
--- a/src/domain/auth/use-cases/use-login.ts
+++ b/src/domain/auth/use-cases/use-login.ts
@@ -2,6 +2,7 @@ import {
useMutation,
useQuery,
useQueryClient,
+ type QueryClient,
type UseMutationResult,
type UseQueryResult,
} from "@tanstack/react-query";
@@ -21,6 +22,13 @@ export function useSession(): UseQueryResult {
});
}
+/** Makes a freshly stored session the signed-in user, after login or invite registration. */
+export function startSignedInSession(queryClient: QueryClient, user: AuthUser): void {
+ stopQueryBroadcast(queryClient);
+ queryClient.setQueryData(queryKeys.auth.session(), user);
+ startQueryBroadcast(queryClient, user.id);
+}
+
export function useLogin(): UseMutationResult {
const navigate = useNavigate();
const queryClient = useQueryClient();
@@ -29,9 +37,7 @@ export function useLogin(): UseMutationResult
mutationFn: (credentials: LoginCredentials) => authApi.login(credentials),
meta: { suppressErrorToast: true },
onSuccess: (user) => {
- stopQueryBroadcast(queryClient);
- queryClient.setQueryData(queryKeys.auth.session(), user);
- startQueryBroadcast(queryClient, user.id);
+ startSignedInSession(queryClient, user);
toast.success("Login successful!");
navigate("/");
},
diff --git a/src/domain/team-member-invite/api/team-member-invite-api.ts b/src/domain/team-member-invite/api/team-member-invite-api.ts
new file mode 100644
index 00000000..b0545c4d
--- /dev/null
+++ b/src/domain/team-member-invite/api/team-member-invite-api.ts
@@ -0,0 +1,59 @@
+/**
+ * Anonymous invite registration. The invite token travels only in request bodies,
+ * never in a URL path or query, and the server scopes every call to that token.
+ */
+import { isHTTPError } from "ky";
+
+import { apiPost, apiPostNoContent } from "@/api/api";
+import { API_PATHS } from "@/api/api-paths";
+import { handleApiResponse } from "@/api/handle-api-response";
+import type { AuthUser } from "@/domain/auth/types/auth-user";
+import type {
+ CompleteRegistrationInput,
+ TeamMemberInviteDetails,
+ TeamMemberInviteErrorCode,
+} from "@/domain/team-member-invite/types/team-member-invite";
+import { getAuthUser, setAuthUser } from "@/lib/auth/auth-storage";
+
+export const teamMemberInviteApi = {
+ resolve: async (token: string): Promise => {
+ const data = await apiPost(API_PATHS.teamMemberInvite.resolve, { token });
+ return handleApiResponse(data);
+ },
+
+ sendCode: async (token: string): Promise => {
+ await apiPostNoContent(API_PATHS.teamMemberInvite.sendCode, { token });
+ },
+
+ verifyCode: async ({ token, code }: { token: string; code: string }): Promise => {
+ await apiPostNoContent(API_PATHS.teamMemberInvite.verifyCode, { token, code });
+ },
+
+ /** Finishes registration and stores the returned session exactly as login does. */
+ complete: async (input: CompleteRegistrationInput): Promise => {
+ const data = await apiPost(API_PATHS.teamMemberInvite.complete, {
+ token: input.token,
+ password: input.password,
+ // An empty value clears the phone the admin entered.
+ phone: input.phone.trim(),
+ });
+ const user = handleApiResponse(data);
+ setAuthUser(user);
+ return getAuthUser() ?? user;
+ },
+};
+
+/** The server's failure code for an invite call, when the response carried one. */
+export function inviteErrorCode(error: unknown): TeamMemberInviteErrorCode | null {
+ if (!isHTTPError(error)) {
+ return null;
+ }
+
+ const data: unknown = error.data;
+ if (typeof data === "object" && data !== null && "code" in data) {
+ const code = (data as { code: unknown }).code;
+ return typeof code === "string" ? (code as TeamMemberInviteErrorCode) : null;
+ }
+
+ return null;
+}
diff --git a/src/domain/team-member-invite/schemas/invite-registration-schema.ts b/src/domain/team-member-invite/schemas/invite-registration-schema.ts
new file mode 100644
index 00000000..b680ea06
--- /dev/null
+++ b/src/domain/team-member-invite/schemas/invite-registration-schema.ts
@@ -0,0 +1,39 @@
+import { z } from "zod";
+
+import { PASSWORDS_DO_NOT_MATCH_MESSAGE, passwordSchema } from "@/domain/auth/password-policy";
+
+export const CODE_FORMAT_MESSAGE = "Enter the 6-digit code from your email";
+export const PHONE_FORMAT_MESSAGE = "Enter a valid phone number";
+
+/** Mirrors the server's accepted phone characters and length. */
+const PHONE_PATTERN = /^[0-9+().\s-]{7,32}$/;
+
+export const invitePasswordStepSchema = z
+ .object({
+ password: passwordSchema,
+ confirmPassword: z.string(),
+ })
+ .refine((values) => values.password === values.confirmPassword, {
+ message: PASSWORDS_DO_NOT_MATCH_MESSAGE,
+ path: ["confirmPassword"],
+ });
+
+export type InvitePasswordStepValues = z.infer;
+
+export const inviteCodeStepSchema = z.object({
+ code: z
+ .string()
+ .trim()
+ .regex(/^[0-9]{6}$/, CODE_FORMAT_MESSAGE),
+});
+
+export type InviteCodeStepValues = z.infer;
+
+export const inviteReviewStepSchema = z.object({
+ phone: z
+ .string()
+ .trim()
+ .refine((phone) => phone === "" || PHONE_PATTERN.test(phone), PHONE_FORMAT_MESSAGE),
+});
+
+export type InviteReviewStepValues = z.infer;
diff --git a/src/domain/team-member-invite/types/team-member-invite.ts b/src/domain/team-member-invite/types/team-member-invite.ts
new file mode 100644
index 00000000..c89ac3e4
--- /dev/null
+++ b/src/domain/team-member-invite/types/team-member-invite.ts
@@ -0,0 +1,30 @@
+/** The server's generic message for any invite that cannot be used; shown before any call too. */
+export const INVALID_INVITE_MESSAGE =
+ "This invite link is invalid or has expired. Ask your admin to send a new invite.";
+
+/** The read-only identity an admin set when inviting the member. */
+export interface TeamMemberInviteDetails {
+ name: string;
+ role: string;
+ email: string;
+ phone: string | null;
+}
+
+export interface CompleteRegistrationInput {
+ token: string;
+ password: string;
+ phone: string;
+}
+
+/** Stable failure codes returned by the anonymous invite endpoints. */
+export type TeamMemberInviteErrorCode =
+ | "invalid_invite"
+ | "code_incorrect"
+ | "code_expired"
+ | "code_locked"
+ | "resend_too_soon"
+ | "resend_limit_reached"
+ | "code_delivery_failed"
+ | "email_not_confirmed"
+ | "password_rejected"
+ | "invalid_phone";
diff --git a/src/domain/team-member-invite/use-cases/use-team-member-invite.ts b/src/domain/team-member-invite/use-cases/use-team-member-invite.ts
new file mode 100644
index 00000000..4fda3a49
--- /dev/null
+++ b/src/domain/team-member-invite/use-cases/use-team-member-invite.ts
@@ -0,0 +1,74 @@
+import {
+ useMutation,
+ useQuery,
+ useQueryClient,
+ type UseMutationResult,
+ type UseQueryResult,
+} from "@tanstack/react-query";
+import { useNavigate } from "react-router";
+import { toast } from "react-toastify";
+
+import type { AuthUser } from "@/domain/auth/types/auth-user";
+import { startSignedInSession } from "@/domain/auth/use-cases/use-login";
+import { queryKeys } from "@/infra/query-key/query-key";
+import { teamMemberInviteApi } from "@/domain/team-member-invite/api/team-member-invite-api";
+import type {
+ CompleteRegistrationInput,
+ TeamMemberInviteDetails,
+} from "@/domain/team-member-invite/types/team-member-invite";
+
+export const REGISTRATION_COMPLETE_MESSAGE = "Registration complete — welcome to Seahaven";
+
+/** Invite errors are shown inline on the wizard step, never as a global toast. */
+const INLINE_ERRORS = { suppressErrorToast: true } as const;
+
+export function useTeamMemberInviteDetails(
+ token: string,
+): UseQueryResult {
+ return useQuery({
+ queryKey: queryKeys.teamMemberInvite.details(token),
+ queryFn: () => teamMemberInviteApi.resolve(token),
+ enabled: token.length > 0,
+ retry: false,
+ staleTime: Infinity,
+ gcTime: 0,
+ meta: INLINE_ERRORS,
+ });
+}
+
+export function useSendInviteCode(): UseMutationResult {
+ return useMutation({
+ mutationFn: teamMemberInviteApi.sendCode,
+ meta: INLINE_ERRORS,
+ });
+}
+
+export function useVerifyInviteCode(): UseMutationResult<
+ void,
+ Error,
+ { token: string; code: string }
+> {
+ return useMutation({
+ mutationFn: teamMemberInviteApi.verifyCode,
+ meta: INLINE_ERRORS,
+ });
+}
+
+export function useCompleteRegistration(): UseMutationResult<
+ AuthUser,
+ Error,
+ CompleteRegistrationInput
+> {
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: teamMemberInviteApi.complete,
+ meta: INLINE_ERRORS,
+ onSuccess: (user) => {
+ startSignedInSession(queryClient, user);
+ toast.success(REGISTRATION_COMPLETE_MESSAGE);
+ navigate("/", { replace: true });
+ },
+ });
+}
diff --git a/src/domain/team-members/api/team-members-api.ts b/src/domain/team-members/api/team-members-api.ts
index 0e53e989..c1462a69 100644
--- a/src/domain/team-members/api/team-members-api.ts
+++ b/src/domain/team-members/api/team-members-api.ts
@@ -1,4 +1,4 @@
-import { apiGet, apiPost, apiPut } from "@/api/api";
+import { apiGet, apiPost, apiPostNoContent, apiPut } from "@/api/api";
import { API_PATHS } from "@/api/api-paths";
import { handleApiResponse } from "@/api/handle-api-response";
import {
@@ -39,4 +39,9 @@ export const teamMembersApi = {
);
return mapTeamMemberDetail(handleApiResponse(data));
},
+
+ /** Emails a fresh invite link and revokes every earlier one for this pending member. */
+ resendInvite: async (id: string | number): Promise => {
+ await apiPostNoContent(API_PATHS.teamMember.invite(id));
+ },
};
diff --git a/src/domain/team-members/mappers/team-member-mapper.ts b/src/domain/team-members/mappers/team-member-mapper.ts
index d283da28..de1652ba 100644
--- a/src/domain/team-members/mappers/team-member-mapper.ts
+++ b/src/domain/team-members/mappers/team-member-mapper.ts
@@ -88,9 +88,12 @@ export function mapTeamMemberListItems(rawItems: unknown[]): TeamMemberListItem[
export function mapTeamMemberDetail(raw: unknown): TeamMemberDetail {
const record = (typeof raw === "object" && raw !== null ? raw : {}) as Record;
const listItem = mapTeamMemberListItem(record);
+ // Present only on an update that re-sent a pending member's invite.
+ const inviteEmailSent = record.inviteEmailSent ?? record.InviteEmailSent;
return {
...listItem,
serviceAreas: readStringArray(record, "serviceAreas", "ServiceAreas"),
permissions: readPermissions(record),
+ ...(typeof inviteEmailSent === "boolean" && { inviteEmailSent }),
};
}
diff --git a/src/domain/team-members/types/team-member.ts b/src/domain/team-members/types/team-member.ts
index 5b2d07c5..975eb697 100644
--- a/src/domain/team-members/types/team-member.ts
+++ b/src/domain/team-members/types/team-member.ts
@@ -32,6 +32,8 @@ export interface TeamMemberCreated {
phone: string;
serviceAreas: string[];
pendingRegistration: boolean;
+ /** Present on create only: false means the member exists but the invite email failed. */
+ inviteEmailSent?: boolean;
}
export type TeamPermissionState = 0 | 1 | 2;
@@ -45,6 +47,8 @@ export interface TeamPermissionValue {
export interface TeamMemberDetail extends TeamMemberListItem {
serviceAreas: string[];
permissions: TeamPermissionValue[];
+ /** Set only after an update that changed a pending member's email and re-sent their invite. */
+ inviteEmailSent?: boolean;
}
export interface UpdateTeamMemberInput {
diff --git a/src/domain/team-members/use-cases/use-create-team-member.ts b/src/domain/team-members/use-cases/use-create-team-member.ts
index bf025222..de10fe90 100644
--- a/src/domain/team-members/use-cases/use-create-team-member.ts
+++ b/src/domain/team-members/use-cases/use-create-team-member.ts
@@ -7,6 +7,9 @@ import type {
} from "@/domain/team-members/types/team-member";
import { queryKeys } from "@/infra/query-key/query-key";
+export const INVITE_EMAIL_FAILED_MESSAGE =
+ "Team member created, but the invite email couldn't be sent. Open their details and use Resend invite.";
+
export function useCreateTeamMember(): UseMutationResult<
TeamMemberCreated,
Error,
@@ -16,8 +19,12 @@ export function useCreateTeamMember(): UseMutationResult<
return useMutation({
mutationFn: teamMembersApi.create,
- onSuccess: () => {
+ onSuccess: (created) => {
void queryClient.invalidateQueries({ queryKey: queryKeys.teamMembers.all });
+ if (created.inviteEmailSent === false) {
+ toast.warning(INVITE_EMAIL_FAILED_MESSAGE);
+ return;
+ }
toast.success("Team member created with pending registration.");
},
onError: (error: Error) => {
diff --git a/src/domain/team-members/use-cases/use-resend-team-member-invite.ts b/src/domain/team-members/use-cases/use-resend-team-member-invite.ts
new file mode 100644
index 00000000..e2d6830f
--- /dev/null
+++ b/src/domain/team-members/use-cases/use-resend-team-member-invite.ts
@@ -0,0 +1,19 @@
+import { useMutation, type UseMutationResult } from "@tanstack/react-query";
+import { toast } from "react-toastify";
+
+import { teamMembersApi } from "@/domain/team-members/api/team-members-api";
+
+export type ResendInviteInput = {
+ id: string | number;
+ email: string;
+};
+
+/** Failures surface through the global mutation error toast with the server's message. */
+export function useResendTeamMemberInvite(): UseMutationResult {
+ return useMutation({
+ mutationFn: ({ id }: ResendInviteInput) => teamMembersApi.resendInvite(id),
+ onSuccess: (_data, { email }) => {
+ toast.success(`Invite sent to ${email}`);
+ },
+ });
+}
diff --git a/src/domain/team-members/use-cases/use-team-member-detail.ts b/src/domain/team-members/use-cases/use-team-member-detail.ts
index 3622ab5c..c54b1f7c 100644
--- a/src/domain/team-members/use-cases/use-team-member-detail.ts
+++ b/src/domain/team-members/use-cases/use-team-member-detail.ts
@@ -23,6 +23,15 @@ export function useTeamMemberDetail(
});
}
+export const UPDATED_INVITE_EMAIL_FAILED_MESSAGE =
+ "Team member updated, but the invite to their new email couldn't be sent. Use Resend invite.";
+
+function updatedMessage(member: TeamMemberDetail): string {
+ return member.inviteEmailSent === true
+ ? `Team member updated. A new invite was sent to ${member.email}.`
+ : "Team member updated.";
+}
+
interface UpdateVariables {
id: string | number;
input: UpdateTeamMemberInput;
@@ -36,7 +45,11 @@ export function useUpdateTeamMember(): UseMutationResult {
void queryClient.invalidateQueries({ queryKey: queryKeys.teamMembers.all });
void queryClient.setQueryData(queryKeys.teamMembers.detail(member.id), member);
- toast.success("Team member updated.");
+ if (member.inviteEmailSent === false) {
+ toast.warning(UPDATED_INVITE_EMAIL_FAILED_MESSAGE);
+ return;
+ }
+ toast.success(updatedMessage(member));
},
onError: (error: Error) => {
toast.error(error.message || "Failed to update team member");
diff --git a/src/infra/query-key/query-key.ts b/src/infra/query-key/query-key.ts
index eb8a0664..cbb9b6e6 100644
--- a/src/infra/query-key/query-key.ts
+++ b/src/infra/query-key/query-key.ts
@@ -49,6 +49,9 @@ export const queryKeys = {
[...queryKeys.employees.all, "list", params ?? {}] as const,
detail: (id: string | number) => [...queryKeys.employees.all, "detail", id] as const,
},
+ teamMemberInvite: {
+ details: (token: string) => ["teamMemberInvite", "details", token] as const,
+ },
teamMembers: {
all: ["teamMembers"] as const,
list: () => [...queryKeys.teamMembers.all, "list"] as const,
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/app/(protected)/team-members/resend-invite-button.test.tsx b/src/test/app/(protected)/team-members/resend-invite-button.test.tsx
new file mode 100644
index 00000000..fc9023c3
--- /dev/null
+++ b/src/test/app/(protected)/team-members/resend-invite-button.test.tsx
@@ -0,0 +1,61 @@
+import { screen, waitFor } 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 { ResendInviteButton } from "@/app/(protected)/team-members/_components/resend-invite-button";
+import { teamMembersApi } from "@/domain/team-members/api/team-members-api";
+import type { TeamMemberDetail } from "@/domain/team-members/types/team-member";
+import { renderWithProviders } from "@/test/test-utils";
+
+vi.mock("react-toastify", () => ({
+ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
+}));
+
+vi.mock("@/domain/team-members/api/team-members-api", () => ({
+ teamMembersApi: { resendInvite: vi.fn() },
+}));
+
+function member(overrides: Partial = {}): TeamMemberDetail {
+ return {
+ id: "user-7",
+ name: "Taylor Reed",
+ role: "Dispatcher",
+ color: "#0D9488",
+ email: "taylor@example.com",
+ phone: "555-0100",
+ status: "Active",
+ pendingRegistration: true,
+ serviceAreas: ["East"],
+ permissions: [],
+ ...overrides,
+ } as TeamMemberDetail;
+}
+
+describe("ResendInviteButton", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("re-sends the invite for a pending member and confirms where it went", async () => {
+ vi.mocked(teamMembersApi.resendInvite).mockResolvedValue(undefined);
+ const user = userEvent.setup();
+ renderWithProviders(, { withAuth: false });
+
+ await user.click(screen.getByRole("button", { name: "Resend invite" }));
+
+ await waitFor(() =>
+ expect(vi.mocked(toast.success)).toHaveBeenCalledWith("Invite sent to taylor@example.com"),
+ );
+ expect(teamMembersApi.resendInvite).toHaveBeenCalledWith("user-7");
+ });
+
+ it.each([
+ ["a registered member", { pendingRegistration: false }],
+ ["a deactivated pending member", { status: "Inactive" as const }],
+ ])("is not offered for %s", (_label, overrides) => {
+ renderWithProviders(, { withAuth: false });
+
+ expect(screen.queryByRole("button", { name: "Resend invite" })).not.toBeInTheDocument();
+ });
+});
diff --git a/src/test/app/invite/invite-registration-page.test.tsx b/src/test/app/invite/invite-registration-page.test.tsx
new file mode 100644
index 00000000..fa8b35fc
--- /dev/null
+++ b/src/test/app/invite/invite-registration-page.test.tsx
@@ -0,0 +1,382 @@
+import { screen, waitFor, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { toast } from "react-toastify";
+import { Route, Routes } from "react-router";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import InviteRegistrationPage from "@/app/invite";
+import { getAuthUser } from "@/lib/auth/auth-storage";
+import { renderWithProviders } from "@/test/test-utils";
+
+vi.mock("react-toastify", () => ({
+ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
+}));
+
+const TOKEN = "Zm9yLXRlc3RzLW9ubHktbm90LWEtcmVhbC10b2tlbg";
+const CODE = "246810";
+const DETAILS = {
+ name: "Taylor Reed",
+ role: "Dispatcher",
+ email: "taylor@example.com",
+ phone: "555-0100",
+};
+const SESSION = {
+ token: "session-jwt",
+ expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
+ email: DETAILS.email,
+ userRoles: "Dispatcher",
+ phoneNumber: "555-123-4567",
+ fullname: "Taylor Reed",
+ id: "user-7",
+};
+
+type Call = { path: string; url: string; body: Record };
+
+type ServerOptions = {
+ sendCodeTooSoon?: boolean;
+ resendTooSoon?: boolean;
+ /** The admin re-sent the invite after the code went out, revoking this token. */
+ revokedBeforeVerify?: boolean;
+ rejectPasswordAtFinish?: boolean;
+};
+
+const INVALID_INVITE_BODY = {
+ code: "invalid_invite",
+ message: "This invite link is invalid or has expired. Ask your admin to send a new invite.",
+};
+const PASSWORD_REJECTED_MESSAGE = "Choose a different password";
+
+function json(status: number, body: unknown) {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+/** A fake of the four anonymous invite endpoints, scoped to one valid token. */
+function installInviteServer({
+ sendCodeTooSoon = false,
+ resendTooSoon = false,
+ revokedBeforeVerify = false,
+ rejectPasswordAtFinish = false,
+}: ServerOptions = {}) {
+ const calls: Call[] = [];
+ let sends = 0;
+
+ vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => {
+ const request = input instanceof Request ? input : new Request(input, init);
+ const path = new URL(request.url).pathname.replace(/^.*\/team-member-invites\//, "");
+ const body = (await request.json()) as Record;
+ calls.push({ path, url: request.url, body });
+
+ if (body.token !== TOKEN || (revokedBeforeVerify && path === "verify-code")) {
+ return json(400, INVALID_INVITE_BODY);
+ }
+
+ switch (path) {
+ case "resolve":
+ return json(200, DETAILS);
+ case "send-code": {
+ sends += 1;
+ const refuse = sends === 1 ? sendCodeTooSoon : resendTooSoon;
+ return refuse
+ ? json(429, {
+ code: "resend_too_soon",
+ message: "Please wait a moment before requesting another code",
+ retryAfterSeconds: 42,
+ })
+ : json(200, { message: "Code sent" });
+ }
+ case "verify-code":
+ return body.code === CODE
+ ? json(200, { message: "Email confirmed" })
+ : json(400, {
+ code: "code_incorrect",
+ message: "Incorrect code — check your email and try again",
+ });
+ case "complete":
+ return rejectPasswordAtFinish
+ ? json(400, { code: "password_rejected", message: PASSWORD_REJECTED_MESSAGE })
+ : json(200, SESSION);
+ default:
+ return json(404, {});
+ }
+ });
+
+ return calls;
+}
+
+function renderInvite(hash: string) {
+ return renderWithProviders(
+
+ } />
+ Dashboard home} />
+ Login page} />
+ ,
+ { route: `/invite${hash}` },
+ );
+}
+
+function input(label: RegExp) {
+ return screen.getByLabelText(label, { selector: "input" });
+}
+
+function ruleStates() {
+ 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")]),
+ );
+}
+
+async function completePasswordStep(user: ReturnType) {
+ await screen.findByRole("heading", { name: "Create your password" });
+ await user.type(input(/^password/i), "Abc1!x");
+ await user.type(input(/^confirm password/i), "Abc1!x");
+ await user.click(screen.getByRole("button", { name: "Continue" }));
+ await screen.findByRole("heading", { name: "Confirm your email" });
+}
+
+async function completeCodeStep(user: ReturnType) {
+ await user.type(input(/^confirmation code/i), CODE);
+ await user.click(screen.getByRole("button", { name: "Continue" }));
+ await screen.findByRole("heading", { name: "Review your info" });
+}
+
+describe("Invite registration", { timeout: 15_000 }, () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ localStorage.clear();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("shows the generic unavailable message for an unknown token", async () => {
+ const calls = installInviteServer();
+ renderInvite("#not-a-real-token");
+
+ expect(await screen.findByRole("heading", { name: "Invite unavailable" })).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ "This invite link is invalid or has expired. Ask your admin to send a new invite.",
+ ),
+ ).toBeInTheDocument();
+ expect(calls.map((call) => call.path)).toEqual(["resolve"]);
+ expect(screen.getByRole("link", { name: "Back to login" })).toHaveAttribute("href", "/login");
+ });
+
+ it("does not call the server when the link carries no token", async () => {
+ const calls = installInviteServer();
+ renderInvite("");
+
+ expect(await screen.findByRole("heading", { name: "Invite unavailable" })).toBeInTheDocument();
+ expect(calls).toHaveLength(0);
+ });
+
+ it("step 1 rejects a password missing a rule, shows the live checklist, and requires a match", async () => {
+ installInviteServer();
+ const user = userEvent.setup();
+ renderInvite(`#${TOKEN}`);
+
+ expect(await screen.findByText("Step 1 of 3")).toBeInTheDocument();
+ expect(screen.getByText("Setting up the account for Taylor Reed")).toBeInTheDocument();
+ const continueButton = screen.getByRole("button", { name: "Continue" });
+
+ await user.type(input(/^password/i), "abcdefgh");
+ await user.type(input(/^confirm password/i), "abcdefgh");
+ expect(ruleStates()).toEqual({
+ length: "true",
+ uppercase: "false",
+ number: "false",
+ special: "false",
+ });
+ expect(continueButton).toBeDisabled();
+
+ await user.clear(input(/^password/i));
+ await user.type(input(/^password/i), "Abc1!x");
+ expect(ruleStates()).toEqual({
+ length: "true",
+ uppercase: "true",
+ number: "true",
+ special: "true",
+ });
+ await user.click(input(/^confirm password/i));
+ await user.tab();
+ expect(await screen.findByText("Passwords don't match")).toBeInTheDocument();
+ expect(continueButton).toBeDisabled();
+
+ await user.clear(input(/^confirm password/i));
+ await user.type(input(/^confirm password/i), "Abc1!x");
+ expect(continueButton).toBeEnabled();
+ });
+
+ it("step 2 shows a clear error for a wrong code, resends on request, and advances on the right code", async () => {
+ const calls = installInviteServer();
+ const user = userEvent.setup();
+ renderInvite(`#${TOKEN}`);
+ await completePasswordStep(user);
+
+ expect(screen.getByText("Step 2 of 3")).toBeInTheDocument();
+ expect(screen.getByText(`Enter the 6-digit code sent to ${DETAILS.email}`)).toBeInTheDocument();
+ const continueButton = screen.getByRole("button", { name: "Continue" });
+ await user.type(input(/^confirmation code/i), "123");
+ expect(continueButton).toBeDisabled();
+
+ await user.type(input(/^confirmation code/i), "456");
+ await user.click(continueButton);
+ expect(
+ await screen.findByText("Incorrect code — check your email and try again"),
+ ).toBeInTheDocument();
+ expect(screen.getByRole("heading", { name: "Confirm your email" })).toBeInTheDocument();
+
+ await user.click(screen.getByRole("button", { name: "Resend code" }));
+ await waitFor(() =>
+ expect(vi.mocked(toast.success)).toHaveBeenCalledWith(`Code resent to ${DETAILS.email}`),
+ );
+ expect(calls.filter((call) => call.path === "send-code")).toHaveLength(2);
+
+ await user.clear(input(/^confirmation code/i));
+ await completeCodeStep(user);
+ expect(screen.getByText("Step 3 of 3")).toBeInTheDocument();
+ });
+
+ it("shows the cooldown message when a resend is refused", async () => {
+ installInviteServer({ resendTooSoon: true });
+ const user = userEvent.setup();
+ renderInvite(`#${TOKEN}`);
+ await completePasswordStep(user);
+
+ await user.click(screen.getByRole("button", { name: "Resend code" }));
+
+ expect(
+ await screen.findByText("Please wait a moment before requesting another code"),
+ ).toBeInTheDocument();
+ expect(vi.mocked(toast.success)).not.toHaveBeenCalled();
+ });
+
+ it("still advances to step 2 when a code was already sent moments ago", async () => {
+ installInviteServer({ sendCodeTooSoon: true });
+ const user = userEvent.setup();
+ renderInvite(`#${TOKEN}`);
+
+ await completePasswordStep(user);
+
+ expect(screen.getByText("Step 2 of 3")).toBeInTheDocument();
+ });
+
+ it("step 3 keeps name, role and email read-only, lets the member edit phone, and finishing signs them in", async () => {
+ const calls = installInviteServer();
+ const user = userEvent.setup();
+ renderInvite(`#${TOKEN}`);
+ await completePasswordStep(user);
+ await completeCodeStep(user);
+
+ for (const value of [DETAILS.name, DETAILS.role, DETAILS.email]) {
+ expect(screen.getByText(value)).toBeInTheDocument();
+ expect(screen.queryByDisplayValue(value)).not.toBeInTheDocument();
+ }
+ expect(screen.getAllByRole("textbox")).toHaveLength(1);
+ const phone = input(/^phone/i);
+ expect(phone).toHaveValue(DETAILS.phone);
+ expect(phone).toHaveAttribute("type", "tel");
+
+ await user.clear(phone);
+ await user.type(phone, "555-123-4567");
+ await user.click(screen.getByRole("button", { name: /finish registration/i }));
+
+ expect(await screen.findByText("Dashboard home")).toBeInTheDocument();
+ expect(vi.mocked(toast.success)).toHaveBeenCalledWith(
+ "Registration complete — welcome to Seahaven",
+ );
+ expect(getAuthUser()).toMatchObject({ token: "session-jwt", id: "user-7" });
+ expect(calls.find((call) => call.path === "complete")?.body).toEqual({
+ token: TOKEN,
+ password: "Abc1!x",
+ phone: "555-123-4567",
+ });
+ });
+
+ it("step 3 blocks finishing with an invalid phone and never calls the server", async () => {
+ const calls = installInviteServer();
+ const user = userEvent.setup();
+ renderInvite(`#${TOKEN}`);
+ await completePasswordStep(user);
+ await completeCodeStep(user);
+
+ await user.clear(input(/^phone/i));
+ await user.type(input(/^phone/i), "call me");
+ await user.click(screen.getByRole("button", { name: /finish registration/i }));
+
+ expect(await screen.findByText("Enter a valid phone number")).toBeInTheDocument();
+ expect(screen.getByRole("heading", { name: "Review your info" })).toBeInTheDocument();
+ expect(calls.some((call) => call.path === "complete")).toBe(false);
+ });
+
+ it("sends an empty phone when the member clears it", async () => {
+ const calls = installInviteServer();
+ const user = userEvent.setup();
+ renderInvite(`#${TOKEN}`);
+ await completePasswordStep(user);
+ await completeCodeStep(user);
+
+ await user.clear(input(/^phone/i));
+ await user.click(screen.getByRole("button", { name: /finish registration/i }));
+ await screen.findByText("Dashboard home");
+
+ expect(calls.find((call) => call.path === "complete")?.body.phone).toBe("");
+ });
+
+ it("ends the wizard with the generic message when the invite is revoked mid-way", async () => {
+ installInviteServer({ revokedBeforeVerify: true });
+ const user = userEvent.setup();
+ renderInvite(`#${TOKEN}`);
+ await completePasswordStep(user);
+
+ await user.type(input(/^confirmation code/i), CODE);
+ await user.click(screen.getByRole("button", { name: "Continue" }));
+
+ expect(await screen.findByRole("heading", { name: "Invite unavailable" })).toBeInTheDocument();
+ expect(screen.getByText(INVALID_INVITE_BODY.message)).toBeInTheDocument();
+ expect(screen.queryByRole("heading", { name: "Confirm your email" })).not.toBeInTheDocument();
+ });
+
+ it("returns to step 1 with the server's message when the password is rejected at finish", async () => {
+ installInviteServer({ rejectPasswordAtFinish: true });
+ const user = userEvent.setup();
+ renderInvite(`#${TOKEN}`);
+ await completePasswordStep(user);
+ await completeCodeStep(user);
+
+ await user.click(screen.getByRole("button", { name: /finish registration/i }));
+
+ expect(
+ await screen.findByRole("heading", { name: "Create your password" }),
+ ).toBeInTheDocument();
+ expect(screen.getByText(PASSWORD_REJECTED_MESSAGE)).toBeInTheDocument();
+ expect(getAuthUser()).toBeNull();
+ });
+
+ it("sends the invite token only in request bodies, never in a URL", async () => {
+ const calls = installInviteServer();
+ const user = userEvent.setup();
+ renderInvite(`#${TOKEN}`);
+ await completePasswordStep(user);
+ await completeCodeStep(user);
+ await user.click(screen.getByRole("button", { name: /finish registration/i }));
+ await screen.findByText("Dashboard home");
+
+ expect(calls.map((call) => call.path)).toEqual([
+ "resolve",
+ "send-code",
+ "verify-code",
+ "complete",
+ ]);
+ for (const call of calls) {
+ expect(call.url).not.toContain(TOKEN);
+ expect(call.body.token).toBe(TOKEN);
+ }
+ });
+});
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([]);
+ });
+});
diff --git a/src/test/domain/team-members/invite-calls-without-body.test.ts b/src/test/domain/team-members/invite-calls-without-body.test.ts
new file mode 100644
index 00000000..83986c95
--- /dev/null
+++ b/src/test/domain/team-members/invite-calls-without-body.test.ts
@@ -0,0 +1,21 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { teamMemberInviteApi } from "@/domain/team-member-invite/api/team-member-invite-api";
+import { teamMembersApi } from "@/domain/team-members/api/team-members-api";
+
+/** The server has already acted when it answers, so an empty success body must still succeed. */
+describe("invite calls that return nothing", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it.each([200, 204])("treat an empty %i response as success", async (status) => {
+ vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response(null, { status }));
+
+ await expect(teamMembersApi.resendInvite("user-7")).resolves.toBeUndefined();
+ await expect(teamMemberInviteApi.sendCode("token")).resolves.toBeUndefined();
+ await expect(
+ teamMemberInviteApi.verifyCode({ token: "token", code: "246810" }),
+ ).resolves.toBeUndefined();
+ });
+});
diff --git a/src/test/domain/team-members/team-member-mapper.test.ts b/src/test/domain/team-members/team-member-mapper.test.ts
index 164a2ef7..9073f169 100644
--- a/src/test/domain/team-members/team-member-mapper.test.ts
+++ b/src/test/domain/team-members/team-member-mapper.test.ts
@@ -54,4 +54,10 @@ describe("mapTeamMemberDetail", () => {
],
});
});
+
+ it("keeps the invite email result only when the update reported one", () => {
+ expect(mapTeamMemberDetail({ id: "u1", inviteEmailSent: false }).inviteEmailSent).toBe(false);
+ expect(mapTeamMemberDetail({ Id: "u1", InviteEmailSent: true }).inviteEmailSent).toBe(true);
+ expect(mapTeamMemberDetail({ id: "u1" })).not.toHaveProperty("inviteEmailSent");
+ });
});
diff --git a/src/test/domain/team-members/use-create-team-member.test.tsx b/src/test/domain/team-members/use-create-team-member.test.tsx
new file mode 100644
index 00000000..a18564b6
--- /dev/null
+++ b/src/test/domain/team-members/use-create-team-member.test.tsx
@@ -0,0 +1,66 @@
+import type { ReactNode } from "react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { toast } from "react-toastify";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { teamMembersApi } from "@/domain/team-members/api/team-members-api";
+import type {
+ CreateTeamMemberInput,
+ TeamMemberCreated,
+} from "@/domain/team-members/types/team-member";
+import {
+ INVITE_EMAIL_FAILED_MESSAGE,
+ useCreateTeamMember,
+} from "@/domain/team-members/use-cases/use-create-team-member";
+
+vi.mock("react-toastify", () => ({
+ toast: { success: vi.fn(), warning: vi.fn(), error: vi.fn() },
+}));
+
+vi.mock("@/domain/team-members/api/team-members-api", () => ({
+ teamMembersApi: { create: vi.fn() },
+}));
+
+const CREATED: TeamMemberCreated = {
+ id: "user-7",
+ name: "Taylor Reed",
+ role: "Dispatcher",
+ color: "#0D9488",
+ email: "taylor@example.com",
+ phone: "555-0100",
+ serviceAreas: ["East"],
+ pendingRegistration: true,
+};
+
+function wrapper({ children }: { children: ReactNode }) {
+ const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } });
+ return {children};
+}
+
+async function create(created: TeamMemberCreated) {
+ vi.mocked(teamMembersApi.create).mockResolvedValue(created);
+ const { result } = renderHook(() => useCreateTeamMember(), { wrapper });
+ act(() => result.current.mutate({} as CreateTeamMemberInput));
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+}
+
+describe("useCreateTeamMember", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("confirms the pending member when the invite email went out", async () => {
+ await create({ ...CREATED, inviteEmailSent: true });
+
+ expect(toast.success).toHaveBeenCalledWith("Team member created with pending registration.");
+ expect(toast.warning).not.toHaveBeenCalled();
+ });
+
+ it("warns the admin to re-send when the invite email could not be sent", async () => {
+ await create({ ...CREATED, inviteEmailSent: false });
+
+ expect(toast.warning).toHaveBeenCalledWith(INVITE_EMAIL_FAILED_MESSAGE);
+ expect(toast.success).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/test/domain/team-members/use-update-team-member.test.tsx b/src/test/domain/team-members/use-update-team-member.test.tsx
new file mode 100644
index 00000000..aa7cc5a4
--- /dev/null
+++ b/src/test/domain/team-members/use-update-team-member.test.tsx
@@ -0,0 +1,72 @@
+import type { ReactNode } from "react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { toast } from "react-toastify";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { teamMembersApi } from "@/domain/team-members/api/team-members-api";
+import type {
+ TeamMemberDetail,
+ UpdateTeamMemberInput,
+} from "@/domain/team-members/types/team-member";
+import {
+ UPDATED_INVITE_EMAIL_FAILED_MESSAGE,
+ useUpdateTeamMember,
+} from "@/domain/team-members/use-cases/use-team-member-detail";
+
+vi.mock("react-toastify", () => ({
+ toast: { success: vi.fn(), warning: vi.fn(), error: vi.fn() },
+}));
+
+vi.mock("@/domain/team-members/api/team-members-api", () => ({
+ teamMembersApi: { update: vi.fn() },
+}));
+
+const UPDATED = {
+ id: "user-7",
+ name: "Taylor Reed",
+ email: "taylor.reed@example.com",
+ pendingRegistration: true,
+ serviceAreas: ["East"],
+ permissions: [],
+} as unknown as TeamMemberDetail;
+
+function wrapper({ children }: { children: ReactNode }) {
+ const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } });
+ return {children};
+}
+
+async function update(updated: TeamMemberDetail) {
+ vi.mocked(teamMembersApi.update).mockResolvedValue(updated);
+ const { result } = renderHook(() => useUpdateTeamMember(), { wrapper });
+ act(() => result.current.mutate({ id: "user-7", input: {} as UpdateTeamMemberInput }));
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+}
+
+describe("useUpdateTeamMember", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("confirms an ordinary update", async () => {
+ await update(UPDATED);
+
+ expect(toast.success).toHaveBeenCalledWith("Team member updated.");
+ expect(toast.warning).not.toHaveBeenCalled();
+ });
+
+ it("tells the admin a new invite went to the corrected email", async () => {
+ await update({ ...UPDATED, inviteEmailSent: true });
+
+ expect(toast.success).toHaveBeenCalledWith(
+ "Team member updated. A new invite was sent to taylor.reed@example.com.",
+ );
+ });
+
+ it("warns the admin to re-send when the new invite could not be emailed", async () => {
+ await update({ ...UPDATED, inviteEmailSent: false });
+
+ expect(toast.warning).toHaveBeenCalledWith(UPDATED_INVITE_EMAIL_FAILED_MESSAGE);
+ expect(toast.success).not.toHaveBeenCalled();
+ });
+});