Merge 383d5c3c6c into 358ec3e890
|
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 146 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 149 KiB After Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 154 KiB After Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 195 KiB After Width: | Height: | Size: 198 KiB |
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
import { Text } from "@/components/ui/text";
|
||||
import type { AuthUser } from "@/domain/auth/types/auth-user";
|
||||
|
||||
function ReadOnlyValue({ label, value }: { label: string; value: string | undefined }) {
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<Text variant="label" tone="muted" className="text-[12px]!">
|
||||
{label}
|
||||
</Text>
|
||||
<Text variant="body" className="m-0 text-[14px]!">
|
||||
{value || "—"}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountSummaryCard({ user }: { user: AuthUser }) {
|
||||
return (
|
||||
<section
|
||||
aria-label="Account details"
|
||||
className="grid gap-4 rounded-lg border border-(--color-border) bg-(--color-bg-surface) p-5"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<ReadOnlyValue label="Name" value={user.fullname} />
|
||||
<ReadOnlyValue label="Role" value={user.userRoles} />
|
||||
</div>
|
||||
<ReadOnlyValue label="Email" value={user.email} />
|
||||
<Text variant="caption" tone="muted" className="text-[11.5px]!">
|
||||
Contact your Admin to change your name, role, or email.
|
||||
</Text>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
import { useForm, useWatch } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Box } from "@mui/material";
|
||||
import { KeyRound } from "lucide-react";
|
||||
|
||||
import { PasswordChecklist } from "@/components/auth/password-checklist";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FormField } from "@/components/ui/form-field";
|
||||
import { PasswordField } from "@/components/ui/password-field";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import {
|
||||
CURRENT_PASSWORD_INCORRECT_MESSAGE,
|
||||
changePasswordSchema,
|
||||
type ChangePasswordFormValues,
|
||||
} from "@/domain/auth/schemas/change-password-schema";
|
||||
import { useChangePassword } from "@/domain/auth/use-cases/use-change-password";
|
||||
|
||||
const EMPTY_VALUES: ChangePasswordFormValues = {
|
||||
currentPassword: "",
|
||||
newPassword: "",
|
||||
confirmPassword: "",
|
||||
};
|
||||
|
||||
export function ChangePasswordSection() {
|
||||
const changePassword = useChangePassword();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
setError,
|
||||
formState: { errors },
|
||||
} = useForm<ChangePasswordFormValues>({
|
||||
resolver: zodResolver(changePasswordSchema),
|
||||
defaultValues: EMPTY_VALUES,
|
||||
mode: "onTouched",
|
||||
});
|
||||
|
||||
const values = useWatch({ control });
|
||||
const newPassword = values.newPassword ?? "";
|
||||
const canSubmit = changePasswordSchema.safeParse(values).success;
|
||||
const serverError = changePassword.error?.message;
|
||||
const showServerError =
|
||||
Boolean(serverError) && serverError !== CURRENT_PASSWORD_INCORRECT_MESSAGE;
|
||||
|
||||
const onSubmit = (form: ChangePasswordFormValues) => {
|
||||
changePassword.mutate(
|
||||
{
|
||||
Currentpassword: form.currentPassword,
|
||||
Newpassword: form.newPassword,
|
||||
Confirmpassword: form.confirmPassword,
|
||||
},
|
||||
{
|
||||
onSuccess: () => reset(EMPTY_VALUES),
|
||||
onError: (error) => {
|
||||
if (error.message === CURRENT_PASSWORD_INCORRECT_MESSAGE) {
|
||||
setError("currentPassword", { message: CURRENT_PASSWORD_INCORRECT_MESSAGE });
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="form"
|
||||
aria-labelledby="change-password-heading"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="grid gap-4 rounded-lg border border-(--color-border) bg-(--color-bg-surface) p-5"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyRound size={15} aria-hidden="true" className="text-primary" />
|
||||
<Text id="change-password-heading" variant="heading" className="m-0 text-[15px]!">
|
||||
Change Password
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
label="Current password"
|
||||
htmlFor="account-current-password"
|
||||
required
|
||||
error={Boolean(errors.currentPassword)}
|
||||
helperText={errors.currentPassword?.message}
|
||||
>
|
||||
<PasswordField
|
||||
{...register("currentPassword")}
|
||||
id="account-current-password"
|
||||
autoComplete="current-password"
|
||||
error={Boolean(errors.currentPassword)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="New password" htmlFor="account-new-password" required>
|
||||
<PasswordField
|
||||
{...register("newPassword")}
|
||||
id="account-new-password"
|
||||
autoComplete="new-password"
|
||||
slotProps={{ htmlInput: { "aria-describedby": "account-password-rules" } }}
|
||||
/>
|
||||
<PasswordChecklist id="account-password-rules" password={newPassword} />
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Confirm new password"
|
||||
htmlFor="account-confirm-password"
|
||||
required
|
||||
error={Boolean(errors.confirmPassword)}
|
||||
helperText={errors.confirmPassword?.message}
|
||||
>
|
||||
<PasswordField
|
||||
{...register("confirmPassword")}
|
||||
id="account-confirm-password"
|
||||
autoComplete="new-password"
|
||||
error={Boolean(errors.confirmPassword)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{showServerError && <Text variant="error">{serverError}</Text>}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
loading={changePassword.isPending}
|
||||
loadingText="Updating…"
|
||||
>
|
||||
Update password
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
24
src/app/(protected)/account/index.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { Box } from "@mui/material";
|
||||
|
||||
import { AccountSummaryCard } from "@/app/(protected)/account/_components/account-summary-card";
|
||||
import { ChangePasswordSection } from "@/app/(protected)/account/_components/change-password-section";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { useAuthContext } from "@/providers/auth-context";
|
||||
|
||||
export default function MyAccountPage() {
|
||||
const { user } = useAuthContext();
|
||||
|
||||
return (
|
||||
<Box className="flex flex-col gap-2 p-2.5">
|
||||
<PageHeader
|
||||
surface
|
||||
title="My Account"
|
||||
subtitle="Review your account and manage your password"
|
||||
/>
|
||||
<Box className="grid max-w-md gap-5 px-5 pb-5">
|
||||
{user != null && <AccountSummaryCard user={user} />}
|
||||
<ChangePasswordSection />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
100
src/app/invite/_components/invite-code-step.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
101
src/app/invite/_components/invite-password-step.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
100
src/app/invite/_components/invite-review-step.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
src/app/invite/_components/invite-step-header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
137
src/app/invite/_components/invite-wizard.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
61
src/app/invite/index.tsx
Normal file
|
|
@ -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#<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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
63
src/components/auth/password-checklist.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { useState } from "react";
|
||||
import { Circle, CircleCheck } from "lucide-react";
|
||||
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { evaluatePasswordRules, type PasswordRuleResult } from "@/domain/auth/password-policy";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type PasswordChecklistProps = {
|
||||
password: string;
|
||||
id?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function describeChanges(previous: PasswordRuleResult[], next: PasswordRuleResult[]): string {
|
||||
return next
|
||||
.filter((rule, index) => rule.met !== previous[index]?.met)
|
||||
.map((rule) => `${rule.label}: ${rule.met ? "requirement met" : "requirement not met"}`)
|
||||
.join(". ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Live, per-rule feedback for the shared password policy. A polite status region
|
||||
* announces only the rules whose state changed, so screen-reader users hear
|
||||
* progress while typing without the whole list being re-read on each keystroke.
|
||||
*/
|
||||
export function PasswordChecklist({ password, id, className }: PasswordChecklistProps) {
|
||||
const results = evaluatePasswordRules(password);
|
||||
const signature = results.map((rule) => (rule.met ? "1" : "0")).join("");
|
||||
const [previous, setPrevious] = useState({ signature, results });
|
||||
const [announcement, setAnnouncement] = useState("");
|
||||
|
||||
if (previous.signature !== signature) {
|
||||
setPrevious({ signature, results });
|
||||
setAnnouncement(describeChanges(previous.results, results));
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul
|
||||
id={id}
|
||||
aria-label="Password requirements"
|
||||
className={cn("m-0 mt-2 grid list-none gap-1 p-0", className)}
|
||||
>
|
||||
{results.map(({ id: ruleId, label, met }) => (
|
||||
<li key={ruleId} data-rule={ruleId} data-met={met} className="flex items-center gap-1.5">
|
||||
{met ? (
|
||||
<CircleCheck size={13} aria-hidden="true" className="text-(--color-success)" />
|
||||
) : (
|
||||
<Circle size={13} aria-hidden="true" className="text-(--color-text-muted)" />
|
||||
)}
|
||||
<Text variant="caption" tone={met ? "success" : "muted"}>
|
||||
{label}
|
||||
</Text>
|
||||
<span className="sr-only">{met ? "requirement met" : "requirement not met"}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Text variant="feedback" className="sr-only">
|
||||
{announcement}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,11 +1,14 @@
|
|||
import { useState } from "react";
|
||||
import { Link, useLocation } from "react-router";
|
||||
import { Box, Drawer } from "@mui/material";
|
||||
import { CircleUserRound } from "lucide-react";
|
||||
|
||||
import { BrandLockup, BrandMark } from "@/components/common/brand-lockup";
|
||||
import { useNavGroups } from "@/components/layout/use-nav-groups";
|
||||
import { getActiveNavPath, getSidebarWidth, type NavItemDef } from "@/config/menu";
|
||||
|
||||
const ACCOUNT_PATH = "/account";
|
||||
|
||||
function getNavItemAppearance(isActive: boolean, isHover: boolean) {
|
||||
if (isActive) {
|
||||
return {
|
||||
|
|
@ -222,6 +225,21 @@ export function AppSidebar({ collapsed, mobile = false, open = true, onClose }:
|
|||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
component="nav"
|
||||
aria-label="Account navigation"
|
||||
sx={{ borderTop: "1px solid var(--color-sidebar-border)", p: "8px" }}
|
||||
>
|
||||
<NavItem
|
||||
label="My Account"
|
||||
icon={CircleUserRound}
|
||||
path={ACCOUNT_PATH}
|
||||
collapsed={collapsed}
|
||||
active={pathname === ACCOUNT_PATH}
|
||||
onNavigate={mobile ? onClose : undefined}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Drawer>
|
||||
);
|
||||
|
|
|
|||
53
src/domain/auth/password-policy.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* The single password rule for every surface that sets a password. It mirrors the
|
||||
* server's Identity options: the character classes are ASCII, matching how the
|
||||
* server classifies uppercase letters, digits and non-alphanumeric characters.
|
||||
*/
|
||||
export const PASSWORD_MIN_LENGTH = 6;
|
||||
|
||||
export type PasswordRuleId = "length" | "uppercase" | "number" | "special";
|
||||
|
||||
export type PasswordRule = {
|
||||
id: PasswordRuleId;
|
||||
label: string;
|
||||
test: (password: string) => boolean;
|
||||
};
|
||||
|
||||
export type PasswordRuleResult = {
|
||||
id: PasswordRuleId;
|
||||
label: string;
|
||||
met: boolean;
|
||||
};
|
||||
|
||||
export const PASSWORD_RULES: readonly PasswordRule[] = [
|
||||
{
|
||||
id: "length",
|
||||
label: `At least ${PASSWORD_MIN_LENGTH} characters`,
|
||||
test: (password) => password.length >= PASSWORD_MIN_LENGTH,
|
||||
},
|
||||
{ id: "uppercase", label: "One uppercase letter", test: (password) => /[A-Z]/.test(password) },
|
||||
{ id: "number", label: "One number", test: (password) => /[0-9]/.test(password) },
|
||||
{
|
||||
id: "special",
|
||||
label: "One special character",
|
||||
test: (password) => /[^A-Za-z0-9]/.test(password),
|
||||
},
|
||||
];
|
||||
|
||||
export const PASSWORD_REQUIREMENTS_MESSAGE = "Password must meet every requirement below";
|
||||
|
||||
export const PASSWORDS_DO_NOT_MATCH_MESSAGE = "Passwords don't match";
|
||||
|
||||
export function evaluatePasswordRules(password: string): PasswordRuleResult[] {
|
||||
return PASSWORD_RULES.map(({ id, label, test }) => ({ id, label, met: test(password) }));
|
||||
}
|
||||
|
||||
export function meetsPasswordPolicy(password: string): boolean {
|
||||
return PASSWORD_RULES.every((rule) => rule.test(password));
|
||||
}
|
||||
|
||||
export const passwordSchema = z.string().refine(meetsPasswordPolicy, {
|
||||
message: PASSWORD_REQUIREMENTS_MESSAGE,
|
||||
});
|
||||
18
src/domain/auth/schemas/change-password-schema.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import { PASSWORDS_DO_NOT_MATCH_MESSAGE, passwordSchema } from "@/domain/auth/password-policy";
|
||||
|
||||
export const CURRENT_PASSWORD_INCORRECT_MESSAGE = "Current password is incorrect";
|
||||
|
||||
export const changePasswordSchema = z
|
||||
.object({
|
||||
currentPassword: z.string().min(1, "Current password is required"),
|
||||
newPassword: passwordSchema,
|
||||
confirmPassword: z.string(),
|
||||
})
|
||||
.refine((values) => values.newPassword === values.confirmPassword, {
|
||||
path: ["confirmPassword"],
|
||||
message: PASSWORDS_DO_NOT_MATCH_MESSAGE,
|
||||
});
|
||||
|
||||
export type ChangePasswordFormValues = z.infer<typeof changePasswordSchema>;
|
||||
|
|
@ -3,14 +3,13 @@ import { toast } from "react-toastify";
|
|||
import * as authApi from "@/domain/auth/api/auth-api";
|
||||
import type { ChangePasswordPayload } from "@/domain/auth/types/auth-user";
|
||||
|
||||
/** Errors are rendered inline by the form that initiated the change. */
|
||||
export function useChangePassword(): UseMutationResult<unknown, Error, ChangePasswordPayload> {
|
||||
return useMutation({
|
||||
mutationFn: (data: ChangePasswordPayload) => authApi.changePassword(data),
|
||||
meta: { suppressErrorToast: true },
|
||||
onSuccess: () => {
|
||||
toast.success("Password changed successfully!");
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to change password");
|
||||
toast.success("Password updated");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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("/");
|
||||
},
|
||||
|
|
|
|||
59
src/domain/team-member-invite/api/team-member-invite-api.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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>;
|
||||
30
src/domain/team-member-invite/types/team-member-invite.ts
Normal file
|
|
@ -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";
|
||||
|
|
@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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));
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
144
src/test/app/(protected)/account/my-account-page.test.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { toast } from "react-toastify";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import MyAccountPage from "@/app/(protected)/account";
|
||||
import * as authApi from "@/domain/auth/api/auth-api";
|
||||
import type { AuthUser } from "@/domain/auth/types/auth-user";
|
||||
import { createAppQueryClient } from "@/lib/query/query-client";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
const { toastSuccessMock } = vi.hoisted(() => ({ toastSuccessMock: vi.fn() }));
|
||||
|
||||
const SIGNED_IN_USER: AuthUser = {
|
||||
token: "test-jwt-token",
|
||||
expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
email: "dana@seahavenind.com",
|
||||
userRoles: "Dispatcher",
|
||||
phoneNumber: "5551234567",
|
||||
fullname: "Dana Reyes",
|
||||
id: "user-1",
|
||||
};
|
||||
|
||||
vi.mock("@/domain/auth/api/auth-api", () => ({
|
||||
getSession: vi.fn(),
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
changePassword: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("react-toastify", () => ({
|
||||
toast: { success: toastSuccessMock, error: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
|
||||
/** Uses the app's query client, so the global mutation error toast is live. */
|
||||
function renderPage() {
|
||||
return renderWithProviders(<MyAccountPage />, {
|
||||
route: "/account",
|
||||
queryClient: createAppQueryClient(),
|
||||
});
|
||||
}
|
||||
|
||||
function field(label: RegExp) {
|
||||
return screen.getByLabelText(label, { selector: "input" });
|
||||
}
|
||||
|
||||
async function fillPasswords(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
{ current, next, confirm }: { current: string; next: string; confirm: string },
|
||||
) {
|
||||
await user.type(field(/^current password/i), current);
|
||||
await user.type(field(/^new password/i), next);
|
||||
await user.type(field(/^confirm new password/i), confirm);
|
||||
}
|
||||
|
||||
describe("My Account page", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(authApi.getSession).mockResolvedValue(SIGNED_IN_USER);
|
||||
});
|
||||
|
||||
it("shows the signed-in user's name, role and email as read-only details", async () => {
|
||||
renderPage();
|
||||
|
||||
const details = await screen.findByRole("region", { name: "Account details" });
|
||||
expect(within(details).getByText("Dana Reyes")).toBeInTheDocument();
|
||||
expect(within(details).getByText("Dispatcher")).toBeInTheDocument();
|
||||
expect(within(details).getByText("dana@seahavenind.com")).toBeInTheDocument();
|
||||
expect(within(details).queryByRole("textbox")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the live checklist for the new password", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
const checklist = screen.getByRole("list", { name: "Password requirements" });
|
||||
expect(within(checklist).getAllByText("requirement not met")).toHaveLength(4);
|
||||
|
||||
await user.type(field(/^new password/i), "Abc1!x");
|
||||
|
||||
expect(within(checklist).getAllByText("requirement met")).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("keeps Update password disabled until every rule is met and the confirmation matches", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
const submit = screen.getByRole("button", { name: "Update password" });
|
||||
|
||||
await fillPasswords(user, { current: "Current1!", next: "abcdef", confirm: "abcdef" });
|
||||
expect(submit).toBeDisabled();
|
||||
|
||||
await user.clear(field(/^new password/i));
|
||||
await user.type(field(/^new password/i), "Abc1!x");
|
||||
expect(submit).toBeDisabled();
|
||||
|
||||
await user.clear(field(/^confirm new password/i));
|
||||
await user.type(field(/^confirm new password/i), "Abc1!x");
|
||||
expect(submit).toBeEnabled();
|
||||
expect(authApi.changePassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("tells the user when the confirmation does not match", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
await fillPasswords(user, { current: "Current1!", next: "Abc1!x", confirm: "Abc1!y" });
|
||||
await user.tab();
|
||||
|
||||
expect(await screen.findByText("Passwords don't match")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sends the current and new password and clears the form after a successful change", async () => {
|
||||
vi.mocked(authApi.changePassword).mockResolvedValue({ status: "Success " });
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
await fillPasswords(user, { current: "Current1!", next: "Abc1!x", confirm: "Abc1!x" });
|
||||
await user.click(screen.getByRole("button", { name: "Update password" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(authApi.changePassword).toHaveBeenCalledWith({
|
||||
Currentpassword: "Current1!",
|
||||
Newpassword: "Abc1!x",
|
||||
Confirmpassword: "Abc1!x",
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(field(/^current password/i)).toHaveValue(""));
|
||||
expect(toastSuccessMock).toHaveBeenCalledWith("Password updated");
|
||||
});
|
||||
|
||||
it("shows the server's current-password rejection on the current password field", async () => {
|
||||
vi.mocked(authApi.changePassword).mockRejectedValue(new Error("Current password is incorrect"));
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
await fillPasswords(user, { current: "Wrong1!", next: "Abc1!x", confirm: "Abc1!x" });
|
||||
await user.click(screen.getByRole("button", { name: "Update password" }));
|
||||
|
||||
expect(await screen.findByText("Current password is incorrect")).toBeInTheDocument();
|
||||
expect(field(/^current password/i)).toHaveAttribute("aria-invalid", "true");
|
||||
expect(toastSuccessMock).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(toast.error)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
382
src/test/app/invite/invite-registration-page.test.tsx
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { toast } from "react-toastify";
|
||||
import { Route, Routes } from "react-router";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import InviteRegistrationPage from "@/app/invite";
|
||||
import { getAuthUser } from "@/lib/auth/auth-storage";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
vi.mock("react-toastify", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
|
||||
const TOKEN = "Zm9yLXRlc3RzLW9ubHktbm90LWEtcmVhbC10b2tlbg";
|
||||
const CODE = "246810";
|
||||
const DETAILS = {
|
||||
name: "Taylor Reed",
|
||||
role: "Dispatcher",
|
||||
email: "taylor@example.com",
|
||||
phone: "555-0100",
|
||||
};
|
||||
const SESSION = {
|
||||
token: "session-jwt",
|
||||
expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
email: DETAILS.email,
|
||||
userRoles: "Dispatcher",
|
||||
phoneNumber: "555-123-4567",
|
||||
fullname: "Taylor Reed",
|
||||
id: "user-7",
|
||||
};
|
||||
|
||||
type Call = { path: string; url: string; body: Record<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;
|
||||
};
|
||||
|
||||
const INVALID_INVITE_BODY = {
|
||||
code: "invalid_invite",
|
||||
message: "This invite link is invalid or has expired. Ask your admin to send a new invite.",
|
||||
};
|
||||
const PASSWORD_REJECTED_MESSAGE = "Choose a different password";
|
||||
|
||||
function json(status: number, body: unknown) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
/** A fake of the four anonymous invite endpoints, scoped to one valid token. */
|
||||
function installInviteServer({
|
||||
sendCodeTooSoon = false,
|
||||
resendTooSoon = false,
|
||||
revokedBeforeVerify = false,
|
||||
rejectPasswordAtFinish = false,
|
||||
}: ServerOptions = {}) {
|
||||
const calls: Call[] = [];
|
||||
let sends = 0;
|
||||
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init);
|
||||
const path = new URL(request.url).pathname.replace(/^.*\/team-member-invites\//, "");
|
||||
const body = (await request.json()) as Record<string, unknown>;
|
||||
calls.push({ path, url: request.url, body });
|
||||
|
||||
if (body.token !== TOKEN || (revokedBeforeVerify && path === "verify-code")) {
|
||||
return json(400, INVALID_INVITE_BODY);
|
||||
}
|
||||
|
||||
switch (path) {
|
||||
case "resolve":
|
||||
return json(200, DETAILS);
|
||||
case "send-code": {
|
||||
sends += 1;
|
||||
const refuse = sends === 1 ? sendCodeTooSoon : resendTooSoon;
|
||||
return refuse
|
||||
? json(429, {
|
||||
code: "resend_too_soon",
|
||||
message: "Please wait a moment before requesting another code",
|
||||
retryAfterSeconds: 42,
|
||||
})
|
||||
: json(200, { message: "Code sent" });
|
||||
}
|
||||
case "verify-code":
|
||||
return body.code === CODE
|
||||
? json(200, { message: "Email confirmed" })
|
||||
: json(400, {
|
||||
code: "code_incorrect",
|
||||
message: "Incorrect code — check your email and try again",
|
||||
});
|
||||
case "complete":
|
||||
return rejectPasswordAtFinish
|
||||
? json(400, { code: "password_rejected", message: PASSWORD_REJECTED_MESSAGE })
|
||||
: json(200, SESSION);
|
||||
default:
|
||||
return json(404, {});
|
||||
}
|
||||
});
|
||||
|
||||
return calls;
|
||||
}
|
||||
|
||||
function renderInvite(hash: string) {
|
||||
return renderWithProviders(
|
||||
<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("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);
|
||||
}
|
||||
});
|
||||
});
|
||||
99
src/test/components/auth/password-checklist.test.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { useState } from "react";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { PasswordChecklist } from "@/components/auth/password-checklist";
|
||||
|
||||
function ChecklistHarness() {
|
||||
const [password, setPassword] = useState("");
|
||||
return (
|
||||
<>
|
||||
<label htmlFor="pw">Password</label>
|
||||
<input id="pw" value={password} onChange={(event) => setPassword(event.target.value)} />
|
||||
<PasswordChecklist password={password} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ruleStates(): Record<string, string | null> {
|
||||
const list = screen.getByRole("list", { name: "Password requirements" });
|
||||
return Object.fromEntries(
|
||||
within(list)
|
||||
.getAllByRole("listitem")
|
||||
.map((item) => [item.getAttribute("data-rule") ?? "", item.getAttribute("data-met")]),
|
||||
);
|
||||
}
|
||||
|
||||
describe("PasswordChecklist", () => {
|
||||
it("starts with every rule unmet", () => {
|
||||
render(<ChecklistHarness />);
|
||||
|
||||
expect(ruleStates()).toEqual({
|
||||
length: "false",
|
||||
uppercase: "false",
|
||||
number: "false",
|
||||
special: "false",
|
||||
});
|
||||
expect(screen.getAllByText("requirement not met")).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("marks each rule met live as the user types", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ChecklistHarness />);
|
||||
const input = screen.getByLabelText("Password");
|
||||
|
||||
await user.type(input, "A");
|
||||
expect(ruleStates()).toMatchObject({ uppercase: "true", number: "false", special: "false" });
|
||||
|
||||
await user.type(input, "1");
|
||||
expect(ruleStates()).toMatchObject({ uppercase: "true", number: "true", special: "false" });
|
||||
|
||||
await user.type(input, "!");
|
||||
expect(ruleStates()).toMatchObject({ special: "true", length: "false" });
|
||||
|
||||
await user.type(input, "xyz");
|
||||
expect(ruleStates()).toEqual({
|
||||
length: "true",
|
||||
uppercase: "true",
|
||||
number: "true",
|
||||
special: "true",
|
||||
});
|
||||
expect(screen.getAllByText("requirement met")).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("announces only the rules whose state changed through a polite status region", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ChecklistHarness />);
|
||||
const input = screen.getByLabelText("Password");
|
||||
const status = screen.getByRole("status");
|
||||
|
||||
expect(status).toHaveAttribute("aria-live", "polite");
|
||||
expect(status).toHaveTextContent(/^$/);
|
||||
|
||||
await user.type(input, "A");
|
||||
expect(status).toHaveTextContent(/^One uppercase letter: requirement met$/);
|
||||
|
||||
await user.type(input, "b");
|
||||
expect(status).toHaveTextContent(/^One uppercase letter: requirement met$/);
|
||||
|
||||
await user.type(input, "1!xy");
|
||||
expect(status).toHaveTextContent(/^At least 6 characters: requirement met$/);
|
||||
|
||||
await user.clear(input);
|
||||
expect(status).toHaveTextContent(
|
||||
"At least 6 characters: requirement not met. One uppercase letter: requirement not met. One number: requirement not met. One special character: requirement not met",
|
||||
);
|
||||
});
|
||||
|
||||
it("marks a rule unmet again when the user deletes the character that satisfied it", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ChecklistHarness />);
|
||||
const input = screen.getByLabelText("Password");
|
||||
|
||||
await user.type(input, "Abcde1!");
|
||||
await user.type(input, "{Backspace}");
|
||||
|
||||
expect(ruleStates()).toMatchObject({ special: "false", length: "true", number: "true" });
|
||||
});
|
||||
});
|
||||
|
|
@ -26,6 +26,17 @@ function sidebarLinks(): Record<string, string> {
|
|||
}
|
||||
|
||||
describe("AppSidebar navigation entries", () => {
|
||||
it("links every signed-in user to My Account and marks it current on that page", () => {
|
||||
canApproveMock.mockReturnValue({ data: false });
|
||||
|
||||
renderSidebar("/account");
|
||||
const accountNav = screen.getByRole("navigation", { name: "Account navigation" });
|
||||
const link = within(accountNav).getByRole("link", { name: "My Account" });
|
||||
|
||||
expect(link).toHaveAttribute("href", "/account");
|
||||
expect(link).toHaveAttribute("aria-current", "page");
|
||||
});
|
||||
|
||||
it("shows Uplift Approvals for a user the backend says can approve uplifts", () => {
|
||||
canApproveMock.mockReturnValue({ data: true });
|
||||
|
||||
|
|
|
|||
46
src/test/domain/auth/password-policy.test.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
PASSWORD_RULES,
|
||||
evaluatePasswordRules,
|
||||
meetsPasswordPolicy,
|
||||
passwordSchema,
|
||||
} from "@/domain/auth/password-policy";
|
||||
|
||||
function unmetRules(password: string): string[] {
|
||||
return evaluatePasswordRules(password)
|
||||
.filter((rule) => !rule.met)
|
||||
.map((rule) => rule.id);
|
||||
}
|
||||
|
||||
describe("password policy", () => {
|
||||
it("lists the four rules in checklist order with the shared copy", () => {
|
||||
expect(PASSWORD_RULES.map((rule) => rule.label)).toEqual([
|
||||
"At least 6 characters",
|
||||
"One uppercase letter",
|
||||
"One number",
|
||||
"One special character",
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Ab1!x", "length"],
|
||||
["abc12!", "uppercase"],
|
||||
["Abcde!", "number"],
|
||||
["Abcde1", "special"],
|
||||
])("rejects %s because only the %s rule is missing", (password, missingRule) => {
|
||||
expect(unmetRules(password)).toEqual([missingRule]);
|
||||
expect(meetsPasswordPolicy(password)).toBe(false);
|
||||
expect(passwordSchema.safeParse(password).success).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["Abc1!x", "ABC12!"])("accepts the six-character compliant password %s", (password) => {
|
||||
expect(unmetRules(password)).toEqual([]);
|
||||
expect(meetsPasswordPolicy(password)).toBe(true);
|
||||
expect(passwordSchema.safeParse(password).success).toBe(true);
|
||||
});
|
||||
|
||||
it("treats a non-ASCII letter as a special character, as the server does", () => {
|
||||
expect(unmetRules("Abcdé1")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
66
src/test/domain/team-members/use-create-team-member.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
72
src/test/domain/team-members/use-update-team-member.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||