From 976cd908e39a6c5a5619dc6742500e08e88cdafe Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 12:04:56 -0300 Subject: [PATCH 1/4] feat(team-members): invite registration wizard and resend invite (SH-385) --- src/api/api-paths.ts | 7 + .../_components/resend-invite-button.tsx | 26 ++ .../_components/team-member-detail-drawer.tsx | 7 +- .../invite/_components/invite-code-step.tsx | 100 ++++++ .../_components/invite-password-step.tsx | 101 ++++++ .../invite/_components/invite-review-step.tsx | 100 ++++++ .../invite/_components/invite-step-header.tsx | 46 +++ src/app/invite/_components/invite-wizard.tsx | 137 +++++++ src/app/invite/index.tsx | 61 ++++ src/domain/auth/use-cases/use-login.ts | 12 +- .../api/team-member-invite-api.ts | 59 +++ .../schemas/invite-registration-schema.ts | 39 ++ .../types/team-member-invite.ts | 30 ++ .../use-cases/use-team-member-invite.ts | 74 ++++ .../team-members/api/team-members-api.ts | 5 + src/domain/team-members/types/team-member.ts | 2 + .../use-cases/use-create-team-member.ts | 9 +- .../use-resend-team-member-invite.ts | 19 + src/infra/query-key/query-key.ts | 3 + .../resend-invite-button.test.tsx | 61 ++++ .../invite/invite-registration-page.test.tsx | 339 ++++++++++++++++++ .../use-create-team-member.test.tsx | 66 ++++ 22 files changed, 1298 insertions(+), 5 deletions(-) create mode 100644 src/app/(protected)/team-members/_components/resend-invite-button.tsx create mode 100644 src/app/invite/_components/invite-code-step.tsx create mode 100644 src/app/invite/_components/invite-password-step.tsx create mode 100644 src/app/invite/_components/invite-review-step.tsx create mode 100644 src/app/invite/_components/invite-step-header.tsx create mode 100644 src/app/invite/_components/invite-wizard.tsx create mode 100644 src/app/invite/index.tsx create mode 100644 src/domain/team-member-invite/api/team-member-invite-api.ts create mode 100644 src/domain/team-member-invite/schemas/invite-registration-schema.ts create mode 100644 src/domain/team-member-invite/types/team-member-invite.ts create mode 100644 src/domain/team-member-invite/use-cases/use-team-member-invite.ts create mode 100644 src/domain/team-members/use-cases/use-resend-team-member-invite.ts create mode 100644 src/test/app/(protected)/team-members/resend-invite-button.test.tsx create mode 100644 src/test/app/invite/invite-registration-page.test.tsx create mode 100644 src/test/domain/team-members/use-create-team-member.test.tsx 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)/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} + + + +
+ ); +} 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 ( + + + + + ); +} 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..64470e29 --- /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 } 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 apiPost(API_PATHS.teamMemberInvite.sendCode, { token }); + }, + + verifyCode: async ({ token, code }: { token: string; code: string }): Promise => { + await apiPost(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..bc6b5a70 100644 --- a/src/domain/team-members/api/team-members-api.ts +++ b/src/domain/team-members/api/team-members-api.ts @@ -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 apiPost(API_PATHS.teamMember.invite(id)); + }, }; diff --git a/src/domain/team-members/types/team-member.ts b/src/domain/team-members/types/team-member.ts index 5b2d07c5..6faa47c5 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; 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/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)/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..c12a9ba4 --- /dev/null +++ b/src/test/app/invite/invite-registration-page.test.tsx @@ -0,0 +1,339 @@ +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 }; + +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, +}: 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) { + return json(400, { + code: "invalid_invite", + message: "This invite link is invalid or has expired. Ask your admin to send a new invite.", + }); + } + + 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 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", () => { + 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("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/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(); + }); +}); From ca42d6e59f54c37c490228bfc4a9ae140b8d5db1 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 12:33:37 -0300 Subject: [PATCH 2/4] fix(team-members): cover mid-wizard invite loss and accept empty invite responses --- .../api/team-member-invite-api.ts | 6 +- .../team-members/api/team-members-api.ts | 4 +- .../invite/invite-registration-page.test.tsx | 59 ++++++++++++++++--- .../invite-calls-without-body.test.ts | 21 +++++++ 4 files changed, 77 insertions(+), 13 deletions(-) create mode 100644 src/test/domain/team-members/invite-calls-without-body.test.ts 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 index 64470e29..b0545c4d 100644 --- a/src/domain/team-member-invite/api/team-member-invite-api.ts +++ b/src/domain/team-member-invite/api/team-member-invite-api.ts @@ -4,7 +4,7 @@ */ import { isHTTPError } from "ky"; -import { apiPost } from "@/api/api"; +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"; @@ -22,11 +22,11 @@ export const teamMemberInviteApi = { }, sendCode: async (token: string): Promise => { - await apiPost(API_PATHS.teamMemberInvite.sendCode, { token }); + await apiPostNoContent(API_PATHS.teamMemberInvite.sendCode, { token }); }, verifyCode: async ({ token, code }: { token: string; code: string }): Promise => { - await apiPost(API_PATHS.teamMemberInvite.verifyCode, { token, code }); + await apiPostNoContent(API_PATHS.teamMemberInvite.verifyCode, { token, code }); }, /** Finishes registration and stores the returned session exactly as login does. */ diff --git a/src/domain/team-members/api/team-members-api.ts b/src/domain/team-members/api/team-members-api.ts index bc6b5a70..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 { @@ -42,6 +42,6 @@ export const teamMembersApi = { /** Emails a fresh invite link and revokes every earlier one for this pending member. */ resendInvite: async (id: string | number): Promise => { - await apiPost(API_PATHS.teamMember.invite(id)); + await apiPostNoContent(API_PATHS.teamMember.invite(id)); }, }; diff --git a/src/test/app/invite/invite-registration-page.test.tsx b/src/test/app/invite/invite-registration-page.test.tsx index c12a9ba4..fa8b35fc 100644 --- a/src/test/app/invite/invite-registration-page.test.tsx +++ b/src/test/app/invite/invite-registration-page.test.tsx @@ -32,7 +32,19 @@ const SESSION = { type Call = { path: string; url: string; body: Record }; -type ServerOptions = { sendCodeTooSoon?: boolean; resendTooSoon?: boolean }; +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), { @@ -45,6 +57,8 @@ function json(status: number, body: unknown) { function installInviteServer({ sendCodeTooSoon = false, resendTooSoon = false, + revokedBeforeVerify = false, + rejectPasswordAtFinish = false, }: ServerOptions = {}) { const calls: Call[] = []; let sends = 0; @@ -55,11 +69,8 @@ function installInviteServer({ const body = (await request.json()) as Record; calls.push({ path, url: request.url, body }); - if (body.token !== TOKEN) { - return json(400, { - code: "invalid_invite", - message: "This invite link is invalid or has expired. Ask your admin to send a new invite.", - }); + if (body.token !== TOKEN || (revokedBeforeVerify && path === "verify-code")) { + return json(400, INVALID_INVITE_BODY); } switch (path) { @@ -84,7 +95,9 @@ function installInviteServer({ message: "Incorrect code — check your email and try again", }); case "complete": - return json(200, SESSION); + return rejectPasswordAtFinish + ? json(400, { code: "password_rejected", message: PASSWORD_REJECTED_MESSAGE }) + : json(200, SESSION); default: return json(404, {}); } @@ -131,7 +144,7 @@ async function completeCodeStep(user: ReturnType) { await screen.findByRole("heading", { name: "Review your info" }); } -describe("Invite registration", () => { +describe("Invite registration", { timeout: 15_000 }, () => { beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); @@ -316,6 +329,36 @@ describe("Invite registration", () => { 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(); 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(); + }); +}); From 383d5c3c6c668d18f688263361ac88c4bb413b6e Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 12:54:31 -0300 Subject: [PATCH 3/4] feat(team-members): warn when the invite to a corrected email could not be sent --- .../mappers/team-member-mapper.ts | 3 + src/domain/team-members/types/team-member.ts | 2 + .../use-cases/use-team-member-detail.ts | 15 +++- .../team-members/team-member-mapper.test.ts | 6 ++ .../use-update-team-member.test.tsx | 72 +++++++++++++++++++ 5 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 src/test/domain/team-members/use-update-team-member.test.tsx 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 6faa47c5..975eb697 100644 --- a/src/domain/team-members/types/team-member.ts +++ b/src/domain/team-members/types/team-member.ts @@ -47,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-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/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-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(); + }); +}); From 3b7cf833325198dc052cb37ac55182f50d46d4c9 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 13:42:16 -0300 Subject: [PATCH 4/4] fix(team-members): offer a retry when the invite cannot be checked The invite page showed "invalid or has expired, ask your admin for a new invite" for any failed resolve call, including offline, timeouts and 5xx. A member on a flaky connection was sent to their admin for a resend, which revokes a token that was still valid. Only the server's invalid_invite answer now ends on "Invite unavailable". Any other failure shows "Couldn't load your invite" with a Try again button that re-runs resolve, without a global error toast. Regression tests cover a network rejection and a 500 followed by a successful retry. --- src/app/invite/index.tsx | 28 +++++++++++- .../types/team-member-invite.ts | 4 ++ .../invite/invite-registration-page.test.tsx | 43 +++++++++++++++++++ 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/src/app/invite/index.tsx b/src/app/invite/index.tsx index 1c56def5..519e4354 100644 --- a/src/app/invite/index.tsx +++ b/src/app/invite/index.tsx @@ -6,7 +6,12 @@ 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 { Button } from "@/components/ui/button"; +import { inviteErrorCode } from "@/domain/team-member-invite/api/team-member-invite-api"; +import { + INVALID_INVITE_MESSAGE, + INVITE_UNREACHABLE_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"; @@ -28,6 +33,27 @@ function InviteBody({ token }: { token: string }) { ); } + // Only the server's own `invalid_invite` answer means the link is dead. A request that never + // got that answer (offline, timeout, 5xx) says nothing about the token, so offer a retry + // instead of sending the member to their admin for a new invite. + if (!inviteEnded && details.isError && inviteErrorCode(details.error) !== "invalid_invite") { + return ( + + + + + ); + } + if (inviteEnded || details.data === undefined) { return ; } diff --git a/src/domain/team-member-invite/types/team-member-invite.ts b/src/domain/team-member-invite/types/team-member-invite.ts index c89ac3e4..a661ef49 100644 --- a/src/domain/team-member-invite/types/team-member-invite.ts +++ b/src/domain/team-member-invite/types/team-member-invite.ts @@ -2,6 +2,10 @@ export const INVALID_INVITE_MESSAGE = "This invite link is invalid or has expired. Ask your admin to send a new invite."; +/** Shown when the invite could not be checked at all (offline, timeout, server error); the link may still be valid. */ +export const INVITE_UNREACHABLE_MESSAGE = + "We couldn't reach the server to check your invite. Check your connection and try again."; + /** The read-only identity an admin set when inviting the member. */ export interface TeamMemberInviteDetails { name: string; diff --git a/src/test/app/invite/invite-registration-page.test.tsx b/src/test/app/invite/invite-registration-page.test.tsx index fa8b35fc..d640dc3c 100644 --- a/src/test/app/invite/invite-registration-page.test.tsx +++ b/src/test/app/invite/invite-registration-page.test.tsx @@ -38,6 +38,8 @@ type ServerOptions = { /** The admin re-sent the invite after the code went out, revoking this token. */ revokedBeforeVerify?: boolean; rejectPasswordAtFinish?: boolean; + /** How the first `resolve` call fails before the server answers normally. */ + resolveFailsOnce?: "network" | "server-error"; }; const INVALID_INVITE_BODY = { @@ -59,9 +61,11 @@ function installInviteServer({ resendTooSoon = false, revokedBeforeVerify = false, rejectPasswordAtFinish = false, + resolveFailsOnce, }: ServerOptions = {}) { const calls: Call[] = []; let sends = 0; + let resolves = 0; vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { const request = input instanceof Request ? input : new Request(input, init); @@ -69,6 +73,13 @@ function installInviteServer({ const body = (await request.json()) as Record; calls.push({ path, url: request.url, body }); + if (path === "resolve" && resolveFailsOnce && ++resolves === 1) { + if (resolveFailsOnce === "network") { + throw new TypeError("Failed to fetch"); + } + return json(500, { message: "Internal Server Error" }); + } + if (body.token !== TOKEN || (revokedBeforeVerify && path === "verify-code")) { return json(400, INVALID_INVITE_BODY); } @@ -168,6 +179,38 @@ describe("Invite registration", { timeout: 15_000 }, () => { expect(screen.getByRole("link", { name: "Back to login" })).toHaveAttribute("href", "/login"); }); + it.each(["network", "server-error"] as const)( + "offers a retry instead of the dead-invite message when resolve fails (%s)", + async (failure) => { + const calls = installInviteServer({ resolveFailsOnce: failure }); + const user = userEvent.setup(); + renderInvite(`#${TOKEN}`); + + expect( + await screen.findByRole("heading", { name: "Couldn't load your invite" }), + ).toBeInTheDocument(); + expect( + screen.getByText( + "We couldn't reach the server to check your invite. Check your connection and try again.", + ), + ).toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "Invite unavailable" })).not.toBeInTheDocument(); + expect( + screen.queryByText( + "This invite link is invalid or has expired. Ask your admin to send a new invite.", + ), + ).not.toBeInTheDocument(); + expect(vi.mocked(toast.error)).not.toHaveBeenCalled(); + + await user.click(screen.getByRole("button", { name: "Try again" })); + + expect( + await screen.findByRole("heading", { name: "Create your password" }), + ).toBeInTheDocument(); + expect(calls.map((call) => call.path)).toEqual(["resolve", "resolve"]); + }, + ); + it("does not call the server when the link carries no token", async () => { const calls = installInviteServer(); renderInvite("");