feat: setup account page for customer and centeralize the otps and phone usages to use the iam user

This commit is contained in:
Nathnael
2026-07-16 12:08:45 +00:00
parent 318af79962
commit f71bbbf782
26 changed files with 1057 additions and 62 deletions

View File

@@ -19,6 +19,15 @@ export const URL_CONSTANTS = {
CHECK_AVAILABILITY: "/api/auth/check-availability",
},
// The signed-in user's own account record. Distinct from COMPANIES_API.PROFILE,
// which is the company's business profile — these are the identity fields that
// OTPs and SMS notifications are actually delivered to.
ACCOUNT: {
CONTACT_OTP: "/api/me/contact/otp",
CONTACT: "/api/me/contact",
NAME: "/api/me/name",
},
OTP: {
SEND: "/api/otp/send",
VERIFY: "/api/otp/verify",

View File

@@ -29,17 +29,20 @@ import {
ShieldCheck,
User,
UserCheck,
UserCog,
} from "lucide-react";
import { useCallback, useEffect } from "react";
import { useSearchParams } from "react-router-dom";
import useAuth from "@/hooks/useAuth";
import { rolesForCompanyType } from "./settings/companyRoles";
import TabAccount from "./settings/TabAccount";
import TabCompanyProfile from "./settings/TabCompanyProfile";
import TabContactPerson from "./settings/TabContactPerson";
import TabDocuments from "./settings/TabDocuments";
import TabGeneralManager from "./settings/TabGeneralManager";
import TabPowerOfAttorney from "./settings/TabPowerOfAttorney";
type SettingsTab = "company" | "contact" | "gm" | "poa" | "documents";
type SettingsTab = "account" | "company" | "contact" | "gm" | "poa" | "documents";
/** A section is "incomplete" when its required fields aren't filled in yet. */
function tabIncomplete(
@@ -62,6 +65,9 @@ function tabIncomplete(
!profile.generalManagerEmail ||
!profile.generalManagerPhone
);
case "account":
// Account fields live on the IAM user, not the company profile, and are
// always populated (signup requires them) — nothing to nag about here.
case "poa":
case "documents":
return false;
@@ -69,6 +75,7 @@ function tabIncomplete(
}
const TABS: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [
{ id: "account", label: "Account", icon: <UserCog size={16} /> },
{ id: "company", label: "Company", icon: <Building2 size={16} /> },
{ id: "contact", label: "Contact Person", icon: <User size={16} /> },
{ id: "gm", label: "General Manager", icon: <Briefcase size={16} /> },
@@ -175,6 +182,7 @@ function ProfileHeader({ profile }: { profile: ProfileResponse }) {
export default function SettingsPage() {
const queryClient = useQueryClient();
const { user } = useAuth();
const [searchParams, setSearchParams] = useSearchParams();
const tab = (searchParams.get("tab") as SettingsTab) || "company";
@@ -313,6 +321,21 @@ export default function SettingsPage() {
))}
</Tabs.List>
{/* Deliberately NOT wrapped in the `locked` fieldset below: that lock
is for company-profile edits awaiting review. Account identity is
the user's own login/notification details — they must stay editable
even mid-review, or a customer whose phone changed while pending
would be locked out of their own OTPs. */}
<Tabs.Panel value="account">
{user ? (
<TabAccount user={user} />
) : (
<Center py="xl">
<Loader color="edr-green" />
</Center>
)}
</Tabs.Panel>
{/* While a change request is pending, every panel's inputs + submit
buttons are disabled via the native fieldset; tab switching stays
enabled so the customer can still review what they submitted. */}

View File

@@ -56,11 +56,10 @@ export default function ContractViewPage() {
const [hasScrolledToBottom, setHasScrolledToBottom] = useState(false);
const [agreedToTerms, setAgreedToTerms] = useState(false);
// The signing OTP goes to the CONTRACT COMPANY's registered phone (the number
// the server verifies against), NOT the signed-in user's — those can differ,
// and sending to the user's phone left the code filed under a number verify
// never checks. The server owns the number; we only get back a masked hint of
// where it landed.
// The signing OTP goes to the signed-in user's own registered phone, resolved
// server-side from their account (the same number the server verifies
// against). The client never picks the number, so send and verify can't
// disagree; we only get back a masked hint of where it landed.
const [otpSentTo, setOtpSentTo] = useState<string | null>(null);
const { data, isLoading, isError, refetch } = useQuery({

View File

@@ -0,0 +1,374 @@
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";
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 (
<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>
);
}

View File

@@ -81,6 +81,11 @@ import type {
ForgotPasswordRequestPayload,
ForgotPasswordVerifyPayload,
ResetTicket,
SendContactOtpPayload,
SendContactOtpResponse,
UpdateAccountNamePayload,
UpdateContactPayload,
UpdateContactResponse,
} from "@/types/auth";
// ---------------------------------------------------------------------------
@@ -147,6 +152,26 @@ export const api = {
logout: endpoint<void, void>("auth", "logout", authService.logout),
},
// The signed-in user's own IAM account — the phone/email that OTPs and SMS
// actually go to. Separate from `companies`, which is business profile data.
account: {
sendContactOtp: endpoint<SendContactOtpPayload, SendContactOtpResponse>(
"account",
"sendContactOtp",
authService.sendContactOtp,
),
updateContact: endpoint<UpdateContactPayload, UpdateContactResponse>(
"account",
"updateContact",
authService.updateContact,
),
updateName: endpoint<UpdateAccountNamePayload, { success: true }>(
"account",
"updateName",
authService.updateAccountName,
),
},
companies: {
getInfo: endpoint<void, CompanyInfoResponse | null>(
"companies",

View File

@@ -11,11 +11,17 @@ import type {
OtpPayload,
OtpResponse,
ResetTicket,
SendContactOtpPayload,
SendContactOtpResponse,
SetPasswordPayload,
SignupPayload,
SignupResponse,
UpdateAccountNamePayload,
UpdateContactPayload,
UpdateContactResponse,
} from "@/types/auth";
import { client } from "@/utils/api";
import { unwrap } from "@/utils/endpoint";
import { ApiResponse } from "@edr/types";
export const authService = {
@@ -105,6 +111,35 @@ export const authService = {
return res.data.data;
},
// The three calls below manage the signed-in user's own account record
// (`/api/me`), which is what OTPs and SMS notifications are delivered to.
// Changing phone/email is OTP-gated server-side: the code goes to the NEW
// value, and the write only lands once it is verified.
sendContactOtp: async (body: SendContactOtpPayload) => {
const res = await client.post<ApiResponse<SendContactOtpResponse>>(
URL_CONSTANTS.ACCOUNT.CONTACT_OTP,
body,
);
return unwrap(res.data);
},
updateContact: async (body: UpdateContactPayload) => {
const res = await client.patch<ApiResponse<UpdateContactResponse>>(
URL_CONSTANTS.ACCOUNT.CONTACT,
body,
);
return unwrap(res.data);
},
updateAccountName: async (body: UpdateAccountNamePayload) => {
const res = await client.patch<ApiResponse<{ success: true }>>(
URL_CONSTANTS.ACCOUNT.NAME,
body,
);
return unwrap(res.data);
},
refreshToken: async () => {
const refreshTokenCookie = document.cookie
.split("; ")

View File

@@ -128,8 +128,6 @@ export interface SignContractPayload {
consentText?: string;
/** Sudo-mode OTP challenge; required when role=CUSTOMER. */
otp?: string;
/** Phone the OTP was sent to; required when role=CUSTOMER. */
otpPhone?: string;
}
export interface ApproveDeliveryResponse {

View File

@@ -268,9 +268,10 @@ export const contractsService = {
return data.data ?? data;
},
// Ask the server to send the signing OTP to the CONTRACT COMPANY's registered
// phone. The client never picks the number (the server verifies against the
// same one), so send and verify can't disagree. Returns a masked hint.
// Ask the server to send the signing OTP to the signer's own registered phone.
// The client never picks the number (the server resolves it from the
// authenticated user and verifies against the same one), so send and verify
// can't disagree. Returns a masked hint.
sendSigningOtp: async (id: string): Promise<{ sentTo: string }> => {
const { data } = await client.post(C.CONTRACT_SEND_SIGNING_OTP(id));
return data.data ?? data;

View File

@@ -45,6 +45,34 @@ export interface OtpResponse {
message: string;
}
/** The contact channel being changed on the signed-in user's own account. */
export type ContactChannel = "email" | "phone";
export interface SendContactOtpPayload {
channel: ContactChannel;
/** The NEW value being moved to — the code is sent here, not to the old one. */
value: string;
}
export interface SendContactOtpResponse {
/** Masked hint of where the code landed, e.g. `+251•••••4567`. */
sentTo: string;
}
export interface UpdateContactPayload extends SendContactOtpPayload {
otp: string;
}
export interface UpdateContactResponse {
success: true;
/** The canonical stored value (E.164 for phone, lowercased for email). */
value: string;
}
export interface UpdateAccountNamePayload {
name: { am: string; en?: string };
}
export interface CheckAvailabilityPayload {
email?: string;
phone?: string;