mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 09:30:59 +00:00
feat: setup account page for customer and centeralize the otps and phone usages to use the iam user
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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({
|
||||
|
||||
374
apps/edr-freight-web/portal/src/pages/settings/TabAccount.tsx
Normal file
374
apps/edr-freight-web/portal/src/pages/settings/TabAccount.tsx
Normal 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 & Save
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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("; ")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user