Files
edr-platform/apps/edr-freight-web/portal/src/pages/settings/TabAccount.tsx
2026-07-16 12:37:52 +00:00

400 lines
12 KiB
TypeScript

import { useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
CheckCircle2,
Save,
ShieldCheck,
UserCog,
XCircle,
} from "lucide-react";
import {
Alert,
Button,
Card,
Group,
Modal,
PinInput,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { api } from "@/services/api";
import {
ControlledPhoneField,
isValidPhone,
toEthiopianE164,
} from "@/components/PhoneField";
import type { AuthUser, ContactChannel } from "@/types/auth";
import ChangePasswordCard from "./ChangePasswordCard";
const schema = z.object({
phoneNumber: z
.string()
.min(1, "Phone number is required")
.refine(isValidPhone, "Enter a valid phone number"),
email: z.string().min(1, "Email is required").email("Enter a valid email"),
nameEn: z.string().min(1, "Name is required"),
nameAm: z.string().min(1, "Amharic name is required"),
});
type FormData = z.infer<typeof schema>;
/** A contact change that still needs its code entered. */
interface PendingChange {
channel: ContactChannel;
value: string;
}
const CHANNEL_LABEL: Record<ContactChannel, string> = {
phone: "phone number",
email: "email address",
};
const normaliseEmail = (v: string) => v.trim().toLowerCase();
interface TabAccountProps {
user: AuthUser;
}
/**
* The signed-in user's own account — the phone and email that OTPs and SMS
* notifications are actually delivered to. Distinct from the company profile
* tabs, which hold business contact details for the organisation.
*
* Changing phone or email is verified: the server sends a code to the NEW value
* and only writes it once the code comes back, so a typo'd number can never
* silently take over the account's notifications. Each code is bound to a single
* channel, so changing both walks the user through one verification per channel.
*/
export default function TabAccount({ user }: TabAccountProps) {
const queryClient = useQueryClient();
// Head of the queue is the change currently being verified. Changing phone AND
// email in one save enqueues both — a code proves one channel, never two.
const [queue, setQueue] = useState<PendingChange[]>([]);
const [sentTo, setSentTo] = useState<string | null>(null);
const [completed, setCompleted] = useState<ContactChannel[]>([]);
const [otp, setOtp] = useState("");
const current = queue[0] ?? null;
const step = completed.length + 1;
const totalSteps = completed.length + queue.length;
const defaultValues = useMemo(
(): FormData => ({
// Normalise to the same E.164 shape the phone input emits. Some accounts
// store a local `0911…`; comparing raw against the field's `+2519…` would
// read as "changed" on every save and hijack the email's turn.
phoneNumber: toEthiopianE164(user.phoneNumber),
email: user.email ?? "",
nameEn: user.name?.en ?? "",
nameAm: user.name?.am ?? "",
}),
[user],
);
const {
register,
control,
handleSubmit,
reset,
formState: { errors, isDirty },
} = useForm<FormData>({
resolver: zodResolver(schema),
values: defaultValues,
// Verifying one channel refetches the user, which re-syncs `values`. Without
// keepDirtyValues that resync would silently discard an edit the user has
// typed into the *other* field but not yet verified.
resetOptions: { keepDirtyValues: true },
});
const refreshUser = () =>
queryClient.invalidateQueries({ queryKey: api.auth.getMyInfo.queryKey() });
/** Name needs no proof of possession, so it saves straight through. */
const nameMutation = useMutation({
mutationFn: (data: FormData) =>
api.account.updateName.call({
name: { en: data.nameEn, am: data.nameAm },
}),
onSuccess: refreshUser,
});
/** Step 1 of a contact change: ask the server to code the new value. */
const otpMutation = useMutation({
mutationFn: (change: PendingChange) =>
api.account.sendContactOtp.call(change),
onSuccess: (res) => {
setOtp("");
setSentTo(res.sentTo);
},
onError: () => {
// Could not even send — drop the flow rather than strand the user in a
// modal asking for a code that was never issued.
setQueue([]);
setSentTo(null);
},
});
/** Step 2: hand the code back; the server verifies and writes atomically. */
const contactMutation = useMutation({
mutationFn: (body: PendingChange & { otp: string }) =>
api.account.updateContact.call(body),
onSuccess: async (_res, body) => {
setOtp("");
setSentTo(null);
setCompleted((prev) => [...prev, body.channel]);
await refreshUser();
// Advance to the next queued channel, keeping the modal open so a
// both-changed save is one continuous flow.
const rest = queue.slice(1);
setQueue(rest);
if (rest[0]) otpMutation.mutate(rest[0]);
},
});
const startQueue = (changes: PendingChange[]) => {
setCompleted([]);
setQueue(changes);
otpMutation.mutate(changes[0]);
};
const cancelQueue = () => {
setQueue([]);
setSentTo(null);
setOtp("");
contactMutation.reset();
};
const onSubmit = (data: FormData) => {
setCompleted([]);
const changes: PendingChange[] = [];
if (toEthiopianE164(data.phoneNumber) !== defaultValues.phoneNumber) {
changes.push({ channel: "phone", value: data.phoneNumber });
}
if (normaliseEmail(data.email) !== normaliseEmail(defaultValues.email)) {
changes.push({ channel: "email", value: normaliseEmail(data.email) });
}
// Name carries no verification, so it saves alongside rather than queueing.
if (
data.nameEn !== defaultValues.nameEn ||
data.nameAm !== defaultValues.nameAm
) {
nameMutation.mutate(data);
}
if (changes.length) startQueue(changes);
};
const errorMessage = (err: unknown): string => {
const res = (
err as { response?: { data?: { message?: string | string[] } } }
)?.response?.data?.message;
if (Array.isArray(res)) return res[0];
return res ?? "Something went wrong. Please try again.";
};
const busy =
otpMutation.isPending ||
contactMutation.isPending ||
nameMutation.isPending;
const savedSummary =
completed.length && !queue.length
? `Your ${completed.map((c) => CHANNEL_LABEL[c]).join(" and ")} ${
completed.length > 1 ? "were" : "was"
} verified and updated`
: null;
return (
<Stack gap="lg">
<Card padding="lg">
<Group gap="sm" mb="xs">
<UserCog size={20} />
<Title order={3}>Account</Title>
</Group>
<Text c="edr-muted" size="sm" mb="lg">
Your login details. Verification codes and SMS notifications are sent
to the phone number below.
</Text>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<ControlledPhoneField
control={control}
name="phoneNumber"
label="Phone Number"
required
/>
<TextInput
label="Email"
placeholder="you@company.com"
error={errors.email?.message}
{...register("email")}
/>
<TextInput
label="Full Name"
placeholder="Abebe Bekele"
error={errors.nameEn?.message}
{...register("nameEn")}
/>
<TextInput
label="Full Name (Amharic)"
placeholder="አበበ በቀለ"
error={errors.nameAm?.message}
{...register("nameAm")}
/>
</Stack>
<Text c="edr-muted" size="xs" mt="sm">
Changing your phone number or email requires a verification code
sent to the new one.
</Text>
<Group
justify="space-between"
mt="xl"
pt="md"
style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }}
>
<Group gap="xs">
{savedSummary && (
<Group gap={6} c="green">
<CheckCircle2 size={16} />
<Text size="sm" fw={500}>
{savedSummary}
</Text>
</Group>
)}
{nameMutation.isSuccess && !savedSummary && !queue.length && (
<Group gap={6} c="green">
<CheckCircle2 size={16} />
<Text size="sm" fw={500}>
Saved successfully
</Text>
</Group>
)}
{(otpMutation.isError || nameMutation.isError) && (
<Group gap={6} c="red">
<XCircle size={16} />
<Text size="sm" fw={500}>
{errorMessage(otpMutation.error ?? nameMutation.error)}
</Text>
</Group>
)}
</Group>
<Group gap="md">
<Button
type="button"
variant="outline"
disabled={busy || !isDirty}
onClick={() => reset()}
>
Reset
</Button>
<Button
type="submit"
leftSection={<Save size={16} />}
loading={busy}
>
Save Changes
</Button>
</Group>
</Group>
</form>
<Modal
opened={current !== null}
onClose={cancelQueue}
title="Verify your new contact details"
centered
>
{current && (
<Stack gap="md">
{totalSteps > 1 && (
<Text size="sm" c="edr-muted">
Step {step} of {totalSteps}
</Text>
)}
<Alert icon={<ShieldCheck size={16} />} color="blue">
{sentTo ? (
<>
We sent a 6-digit code to <b>{sentTo}</b>. Enter it to
confirm your new {CHANNEL_LABEL[current.channel]}.
</>
) : (
<>
Sending a code to your new {CHANNEL_LABEL[current.channel]}
</>
)}
</Alert>
{completed.length > 0 && queue.length > 0 && (
<Group gap={6} c="green">
<CheckCircle2 size={16} />
<Text size="sm">
{CHANNEL_LABEL[completed[completed.length - 1]]} updated
one more to confirm.
</Text>
</Group>
)}
<PinInput
length={6}
type="number"
oneTimeCode
value={otp}
onChange={setOtp}
disabled={!sentTo || otpMutation.isPending}
aria-label="Verification code"
/>
{contactMutation.isError && (
<Group gap={6} c="red">
<XCircle size={16} />
<Text size="sm">{errorMessage(contactMutation.error)}</Text>
</Group>
)}
<Group justify="space-between">
<Button
variant="subtle"
disabled={otpMutation.isPending || !sentTo}
onClick={() => {
// Clear any "invalid code" error first: the user is asking for
// a fresh code, not retrying the old one, so leaving the
// failure on screen would describe a request they didn't make.
contactMutation.reset();
otpMutation.mutate(current);
}}
>
Resend code
</Button>
<Button
loading={contactMutation.isPending}
disabled={otp.length !== 6 || !sentTo}
onClick={() => contactMutation.mutate({ ...current, otp })}
>
Verify &amp; Save
</Button>
</Group>
</Stack>
)}
</Modal>
</Card>
<ChangePasswordCard />
</Stack>
);
}