mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 03:38:17 +00:00
400 lines
12 KiB
TypeScript
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 & Save
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
)}
|
|
</Modal>
|
|
</Card>
|
|
|
|
<ChangePasswordCard />
|
|
</Stack>
|
|
);
|
|
}
|