Merge pull request #274 from Sea-Haven-Industries/feat/ab/sh-385-invite-registration

feat(team-members): invite registration wizard and resend invite (SH-385)
This commit is contained in:
Alexandre Brandizzi 2026-09-25 22:16:36 +00:00 • committed by GitHub
commit a27695b217
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 1533 additions and 7 deletions

View file

@ -86,8 +86,15 @@ export const API_PATHS = {
getList: "User",
create: "team-members",
detail: "team-members",
invite: (id: string | number) => `team-members/${encodeURIComponent(String(id))}/invite`,
myPermissions: "team-members/me/permissions",
},
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",
getContacts: "Contact/GetContacts",

View file

@ -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 (
<Button
variant="outline"
onClick={() => resendInvite.mutate({ id: member.id, email: member.email })}
loading={resendInvite.isPending}
loadingText="Sending…"
startIcon={<Mail size={15} aria-hidden="true" />}
>
Resend invite
</Button>
);
}

View file

@ -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<SetStateAction<TeamMemberForm>>;
@ -257,7 +260,8 @@ function DrawerFooter({
</Stack>
</Stack>
) : (
<Stack direction="row" sx={{ justifyContent: "flex-end" }}>
<Stack direction="row" sx={{ justifyContent: "flex-end", gap: 1.5 }}>
<ResendInviteButton member={member} />
<Button variant="contained" onClick={onEdit}>
Edit
</Button>
@ -475,6 +479,7 @@ export function TeamMemberDetailDrawer({
{currentMember !== undefined && (
<DrawerFooter
member={currentMember}
editing={editor.editing}
form={editor.form}
setForm={editor.setForm}

View file

@ -0,0 +1,100 @@
import { useForm, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Box } from "@mui/material";
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 {
inviteCodeStepSchema,
type InviteCodeStepValues,
} from "@/domain/team-member-invite/schemas/invite-registration-schema";
import { InviteStepHeader } from "./invite-step-header";
export type InviteCodeStepProps = {
email: string;
isVerifying: boolean;
isResending: boolean;
/** Why the entered code was refused; shown under the field. */
codeError: string | null;
/** Why a new code could not be sent. */
resendError: string | null;
onVerify: (code: string) => void;
onResend: () => void;
onCodeChange: () => void;
};
export function InviteCodeStep({
email,
isVerifying,
isResending,
codeError,
resendError,
onVerify,
onResend,
onCodeChange,
}: InviteCodeStepProps) {
const { register, handleSubmit, control } = useForm<InviteCodeStepValues>({
resolver: zodResolver(inviteCodeStepSchema),
defaultValues: { code: "" },
});
const values = useWatch({ control });
const canContinue = inviteCodeStepSchema.safeParse(values).success;
const codeField = register("code", { onChange: onCodeChange });
return (
<Box
component="form"
aria-label="Confirm your email"
onSubmit={handleSubmit((form) => onVerify(form.code))}
className="flex flex-col gap-4"
>
<InviteStepHeader
step={2}
title="Confirm your email"
subtitle={`Enter the 6-digit code sent to ${email}`}
/>
<FormField
label="Confirmation code"
htmlFor="invite-code"
required
appearance="auth"
error={codeError !== null}
helperText={codeError ?? undefined}
>
<TextField
{...codeField}
id="invite-code"
placeholder="123456"
autoComplete="one-time-code"
error={codeError !== null}
slotProps={{ htmlInput: { inputMode: "numeric", maxLength: 6 } }}
/>
</FormField>
<div className="flex flex-col items-start gap-1">
<Button type="button" variant="link" onClick={onResend} disabled={isResending}>
Resend code
</Button>
<Text variant="error" when={resendError !== null}>
{resendError}
</Text>
</div>
<Button
type="submit"
fullWidth
size="lg"
disabled={!canContinue}
loading={isVerifying}
loadingText="Checking code…"
>
Continue
</Button>
</Box>
);
}

View file

@ -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<InvitePasswordStepValues>({
resolver: zodResolver(invitePasswordStepSchema),
defaultValues: { password: defaultPassword, confirmPassword: defaultPassword },
mode: "onTouched",
});
const values = useWatch({ control });
const canContinue = invitePasswordStepSchema.safeParse(values).success;
return (
<Box
component="form"
aria-label="Create your password"
onSubmit={handleSubmit((form) => onContinue(form.password))}
className="flex flex-col gap-4"
>
<InviteStepHeader
step={1}
title="Create your password"
subtitle={`Setting up the account for ${name}`}
/>
<FormField label="Password" htmlFor="invite-password" required appearance="auth">
<PasswordField
{...register("password")}
id="invite-password"
autoComplete="new-password"
slotProps={{ htmlInput: { "aria-describedby": "invite-password-rules" } }}
/>
<PasswordChecklist id="invite-password-rules" password={values.password ?? ""} />
</FormField>
<FormField
label="Confirm password"
htmlFor="invite-confirm-password"
required
appearance="auth"
error={Boolean(errors.confirmPassword)}
helperText={errors.confirmPassword?.message}
>
<PasswordField
{...register("confirmPassword")}
id="invite-confirm-password"
autoComplete="new-password"
error={Boolean(errors.confirmPassword)}
/>
</FormField>
<Text variant="error" when={error !== null}>
{error}
</Text>
<Button
type="submit"
fullWidth
size="lg"
disabled={!canContinue}
loading={isSubmitting}
loadingText="Sending code…"
>
Continue
</Button>
</Box>
);
}

View file

@ -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 (
<div>
<Text variant="label" className="block">
{label}
</Text>
<Text variant="body" className="m-0 text-(length:--text-base-sm)">
{value}
</Text>
</div>
);
}
export function InviteReviewStep({ details, isFinishing, error, onFinish }: InviteReviewStepProps) {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<InviteReviewStepValues>({
resolver: zodResolver(inviteReviewStepSchema),
defaultValues: { phone: details.phone ?? "" },
mode: "onTouched",
});
return (
<Box
component="form"
aria-label="Review your info"
onSubmit={handleSubmit((form) => onFinish(form.phone))}
className="flex flex-col gap-4"
>
<InviteStepHeader
step={3}
title="Review your info"
subtitle="Name, role, and email are set by your admin — contact them to change these."
/>
<div className="grid gap-3">
<ReadOnlyValue label="Name" value={details.name} />
<ReadOnlyValue label="Role" value={details.role} />
<ReadOnlyValue label="Email" value={details.email} />
<FormField
label="Phone"
htmlFor="invite-phone"
appearance="auth"
error={Boolean(errors.phone)}
helperText={errors.phone?.message}
>
<TextField
{...register("phone")}
id="invite-phone"
type="tel"
autoComplete="tel"
placeholder="(000) 000-0000"
error={Boolean(errors.phone)}
/>
</FormField>
</div>
<Text variant="error" when={error !== null}>
{error}
</Text>
<Button
type="submit"
fullWidth
size="lg"
loading={isFinishing}
loadingText="Finishing…"
startIcon={<CircleCheck size={15} aria-hidden="true" />}
>
Finish registration
</Button>
</Box>
);
}

View file

@ -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 (
<div className="mb-5">
<div
role="progressbar"
aria-label="Registration progress"
aria-valuemin={1}
aria-valuemax={INVITE_STEP_COUNT}
aria-valuenow={step}
className="mb-5 flex items-center gap-1.5"
>
{[1, 2, 3].map((bar) => (
<span
key={bar}
className={cn(
"h-[3px] flex-1 rounded-sm",
bar <= step ? "bg-(--color-primary)" : "bg-(--color-border)",
)}
/>
))}
</div>
<Text variant="caption" tone="muted" className="block">
{`Step ${step} of ${INVITE_STEP_COUNT}`}
</Text>
<Text variant="title" className="m-0 mt-1! ![font-size:var(--text-lg)] !font-semibold">
{title}
</Text>
<Text variant="description" tone="muted" className="mt-1!">
{subtitle}
</Text>
</div>
);
}

View file

@ -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<InviteStep>(1);
const [password, setPassword] = useState("");
const [stepError, setStepError] = useState<string | null>(null);
const [codeError, setCodeError] = useState<string | null>(null);
const [resendError, setResendError] = useState<string | null>(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 (
<InvitePasswordStep
name={details.name}
defaultPassword={password}
isSubmitting={sendCode.isPending}
error={stepError}
onContinue={handlePasswordContinue}
/>
);
}
if (step === 2) {
return (
<InviteCodeStep
email={details.email}
isVerifying={verifyCode.isPending}
isResending={sendCode.isPending}
codeError={codeError}
resendError={resendError}
onVerify={handleVerify}
onResend={handleResend}
onCodeChange={() => setCodeError(null)}
/>
);
}
return (
<InviteReviewStep
details={details}
isFinishing={complete.isPending}
error={stepError}
onFinish={handleFinish}
/>
);
}

87
src/app/invite/index.tsx Normal file
View file

@ -0,0 +1,87 @@
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 { 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";
/** The invite email links to `/invite#<token>`; 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 (
<Box className="flex justify-center py-8" role="status" aria-label="Loading invite">
<CircularProgress />
</Box>
);
}
// 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 (
<Box>
<AuthCardHeader title="Couldn't load your invite" subtitle={INVITE_UNREACHABLE_MESSAGE} />
<Button
type="button"
fullWidth
size="lg"
loading={details.isFetching}
loadingText="Trying again…"
onClick={() => void details.refetch()}
>
Try again
</Button>
</Box>
);
}
if (inviteEnded || details.data === undefined) {
return <AuthCardHeader title="Invite unavailable" subtitle={INVALID_INVITE_MESSAGE} />;
}
return (
<InviteWizard
token={token}
details={details.data}
onInviteInvalid={() => setInviteEnded(true)}
/>
);
}
export default function InviteRegistrationPage() {
const { hash } = useLocation();
const token = readInviteToken(hash);
return (
<AuthPageShell>
<AuthBrandLogo className="mb-8" />
<Link
to="/login"
className="mb-6 inline-flex items-center gap-1 text-(length:--text-xs) text-(--color-text-muted) no-underline hover:text-foreground"
>
<ChevronLeft size={13} aria-hidden="true" />
Back to login
</Link>
<InviteBody token={token} />
</AuthPageShell>
);
}

View file

@ -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<AuthUser | null, Error> {
});
}
/** 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<AuthUser, Error, LoginCredentials> {
const navigate = useNavigate();
const queryClient = useQueryClient();
@ -29,9 +37,7 @@ export function useLogin(): UseMutationResult<AuthUser, Error, LoginCredentials>
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("/");
},

View file

@ -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<TeamMemberInviteDetails> => {
const data = await apiPost<unknown>(API_PATHS.teamMemberInvite.resolve, { token });
return handleApiResponse<TeamMemberInviteDetails>(data);
},
sendCode: async (token: string): Promise<void> => {
await apiPostNoContent(API_PATHS.teamMemberInvite.sendCode, { token });
},
verifyCode: async ({ token, code }: { token: string; code: string }): Promise<void> => {
await apiPostNoContent(API_PATHS.teamMemberInvite.verifyCode, { token, code });
},
/** Finishes registration and stores the returned session exactly as login does. */
complete: async (input: CompleteRegistrationInput): Promise<AuthUser> => {
const data = await apiPost<unknown>(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<AuthUser>(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;
}

View file

@ -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<typeof invitePasswordStepSchema>;
export const inviteCodeStepSchema = z.object({
code: z
.string()
.trim()
.regex(/^[0-9]{6}$/, CODE_FORMAT_MESSAGE),
});
export type InviteCodeStepValues = z.infer<typeof inviteCodeStepSchema>;
export const inviteReviewStepSchema = z.object({
phone: z
.string()
.trim()
.refine((phone) => phone === "" || PHONE_PATTERN.test(phone), PHONE_FORMAT_MESSAGE),
});
export type InviteReviewStepValues = z.infer<typeof inviteReviewStepSchema>;

View file

@ -0,0 +1,34 @@
/** 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.";
/** 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;
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";

View file

@ -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<TeamMemberInviteDetails, Error> {
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<void, Error, string> {
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 });
},
});
}

View file

@ -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<unknown>(data));
},
/** Emails a fresh invite link and revokes every earlier one for this pending member. */
resendInvite: async (id: string | number): Promise<void> => {
await apiPostNoContent(API_PATHS.teamMember.invite(id));
},
};

View file

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

View file

@ -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 {

View file

@ -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) => {

View file

@ -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<void, Error, ResendInviteInput> {
return useMutation({
mutationFn: ({ id }: ResendInviteInput) => teamMembersApi.resendInvite(id),
onSuccess: (_data, { email }) => {
toast.success(`Invite sent to ${email}`);
},
});
}

View file

@ -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<TeamMemberDetail, Error
onSuccess: (member) => {
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");

View file

@ -51,6 +51,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,

View file

@ -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> = {}): 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(<ResendInviteButton member={member()} />, { 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(<ResendInviteButton member={member(overrides)} />, { withAuth: false });
expect(screen.queryByRole("button", { name: "Resend invite" })).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,425 @@
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<string, unknown> };
type ServerOptions = {
sendCodeTooSoon?: boolean;
resendTooSoon?: boolean;
/** 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 = {
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,
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);
const path = new URL(request.url).pathname.replace(/^.*\/team-member-invites\//, "");
const body = (await request.json()) as Record<string, unknown>;
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);
}
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(
<Routes>
<Route path="/invite" element={<InviteRegistrationPage />} />
<Route path="/" element={<div>Dashboard home</div>} />
<Route path="/login" element={<div>Login page</div>} />
</Routes>,
{ 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<typeof userEvent.setup>) {
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<typeof userEvent.setup>) {
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.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("");
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);
}
});
});

View file

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

View file

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

View file

@ -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 <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}
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();
});
});

View file

@ -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 <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}
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();
});
});