feat: setup contact person verification

This commit is contained in:
Nathnael
2026-06-25 06:17:57 +00:00
parent f7bbb03cc1
commit 360f6885d5
10 changed files with 324 additions and 19 deletions

View File

@@ -0,0 +1,42 @@
import { MigrationInterface, QueryRunner, Table } from "typeorm";
/**
* Create the public.otp_verifications table backing the OTP module
* (OtpVerification entity). One row per phone, holding the latest server-issued
* code and whether that phone has been verified.
*/
export class CreateOtpVerifications1810000000003
implements MigrationInterface
{
name = "CreateOtpVerifications1810000000003";
public async up(queryRunner: QueryRunner): Promise<void> {
const exists = await queryRunner.hasTable("otp_verifications");
if (exists) return;
await queryRunner.createTable(
new Table({
name: "otp_verifications",
columns: [
{
name: "id",
type: "uuid",
isPrimary: true,
default: "gen_random_uuid()",
},
{ name: "phone", type: "varchar", isUnique: true },
{ name: "otp", type: "varchar" },
{ name: "verified", type: "boolean", default: false },
{ name: "created_at", type: "timestamptz", default: "now()" },
{ name: "updated_at", type: "timestamptz", default: "now()" },
{ name: "deleted_at", type: "timestamptz", isNullable: true },
],
}),
true,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable("otp_verifications", true);
}
}

View File

@@ -582,6 +582,8 @@ export class CompaniesService {
attrUpdates.contactPersonEmail = dto.contactPersonEmail;
if (dto.contactPersonPhone !== undefined)
attrUpdates.contactPersonPhone = normalizeE164(dto.contactPersonPhone);
if (dto.contactVerifiedPhone !== undefined)
attrUpdates.contactVerifiedPhone = normalizeE164(dto.contactVerifiedPhone);
if (dto.generalManagerName !== undefined)
attrUpdates.generalManagerName = dto.generalManagerName;
if (dto.generalManagerEmail !== undefined)

View File

@@ -34,6 +34,8 @@ export class ProfileResponseDto {
contactPersonPosition: string | null;
contactPersonEmail: string | null;
contactPersonPhone: string | null;
/** Phone that passed SMS OTP verification (drives the verify-step resume). */
contactVerifiedPhone: string | null;
generalManagerName: string | null;
generalManagerEmail: string | null;
generalManagerPhone: string | null;
@@ -81,6 +83,7 @@ export class ProfileResponseDto {
this.contactPersonPosition = attrs.contactPersonPosition ?? null;
this.contactPersonEmail = attrs.contactPersonEmail ?? null;
this.contactPersonPhone = attrs.contactPersonPhone ?? null;
this.contactVerifiedPhone = attrs.contactVerifiedPhone ?? null;
this.generalManagerName = attrs.generalManagerName ?? null;
this.generalManagerEmail = attrs.generalManagerEmail ?? null;
this.generalManagerPhone = attrs.generalManagerPhone ?? null;

View File

@@ -65,6 +65,16 @@ export class UpdateProfileDto {
@IsValidPhone()
contactPersonPhone?: string;
/**
* The contact-person phone that completed SMS OTP verification. Persisted so
* the onboarding "verify" step can resume its "done" state after a refresh
* (compared against the current contactPersonPhone on the client).
*/
@IsOptional()
@IsString()
@IsValidPhone()
contactVerifiedPhone?: string;
@IsOptional()
@IsString()
generalManagerName?: string;

View File

@@ -24,13 +24,9 @@ export class OtpController {
@Post("send")
async sendOtp(
@Body("phone")
phone: string,
@Body("otp")
otp: string
phone: string
) {
return this.otpService.sendOtp(
phone,otp
);
return this.otpService.sendOtp(phone);
}
// ---------------------------------------------------------------------------

View File

@@ -29,11 +29,12 @@ export class OtpService {
// Send OTP
// ---------------------------------------------------------------------------
async sendOtp(phone: string, otp: string) {
async sendOtp(phone: string) {
try {
// generate otp
// const otp =
// this.generateOtp();
// The verification code is generated server-side — never supplied by the
// caller — so the OTP stays a secret known only to the server and the
// recipient of the SMS.
const otp = this.generateOtp();
// find existing phone
const existingPhone =

View File

@@ -46,6 +46,7 @@ type FormStep =
| "company"
| "personnel"
| "contact"
| "verify"
| "poa"
| "documents"
| "additional";
@@ -53,6 +54,7 @@ const FORM_STEPS: FormStep[] = [
"company",
"personnel",
"contact",
"verify",
"poa",
"documents",
"additional",
@@ -93,6 +95,11 @@ const STEP_META: Record<
title: "Contact Person",
description: "Who should we reach out to about this account?",
},
verify: {
icon: <ShieldCheck size={20} />,
title: "Verify Contact Person",
description: "Confirm the contact phone with a one-time SMS code.",
},
poa: {
icon: <FileText size={20} />,
title: "Power of Attorney",

View File

@@ -4,6 +4,7 @@ import {
Divider,
Group,
Loader,
PinInput,
SimpleGrid,
Stack,
Text,
@@ -11,7 +12,16 @@ import {
} from "@mantine/core";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { AlertCircle, ArrowLeft, ArrowRight, UserCheck } from "lucide-react";
import {
AlertCircle,
ArrowLeft,
ArrowRight,
CheckCircle2,
RotateCw,
ShieldCheck,
Smartphone,
UserCheck,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
@@ -31,15 +41,27 @@ import RoleLicenseStep, {
type RoleLicenseProfile,
} from "@/components/onboarding/RoleLicenseStep";
import ETradeInfo from "@/components/onboarding/ETradeInfo";
import { extractApiError } from "@/utils/result";
type CompanyStep =
| "company"
| "personnel"
| "contact"
| "verify"
| "poa"
| "documents"
| "additional";
/** Last 9 digits (Ethiopian national significant number) for tolerant compare. */
const phoneDigits = (p?: string | null) => (p ?? "").replace(/\D/g, "").slice(-9);
const samePhone = (a?: string | null, b?: string | null) => {
const da = phoneDigits(a);
return da.length === 9 && da === phoneDigits(b);
};
/** Mask all but the first 7 chars of an E.164 phone for display. */
const maskPhone = (p: string) =>
p.length > 4 ? `${p.slice(0, 7)}${"*".repeat(Math.max(0, p.length - 7))}` : p;
const onboardingSchema = z.object({
companyName: z.string().min(1, "Company name is required"),
companyEmail: z.string().email("Invalid email address"),
@@ -134,6 +156,7 @@ const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
"contactPersonEmail",
"contactPersonPhone",
],
verify: [],
poa: [],
documents: [],
additional: [],
@@ -507,6 +530,96 @@ export default function CompanyProfileForm({
setValue("poaPhone", watch("contactPersonPhone"));
};
/** Populate the Contact Person from the currently logged-in user. */
const useLoggedInUserAsContact = () => {
setValue("contactPersonName", user?.name?.en ?? "", {
shouldValidate: true,
});
if (user?.email) setValue("contactPersonEmail", user.email);
setValue("contactPersonPhone", user?.phoneNumber ?? "", {
shouldValidate: true,
});
};
// --- Contact-phone SMS OTP verification -----------------------------------
// The phone we verify is the contact-person phone, normalised to E.164 so it
// matches what the backend persists as `contactVerifiedPhone`.
const contactPhoneE164 = toEthiopianE164(watch("contactPersonPhone") ?? "");
// Source of truth for "already verified" comes from the onboarding/profile
// info (rehydrate) — so a refresh resumes the verify step's "done" state.
const [verifiedPhone, setVerifiedPhone] = useState<string | null>(
rehydrate?.contactVerifiedPhone ?? null,
);
useEffect(() => {
if (rehydrate?.contactVerifiedPhone) {
setVerifiedPhone(rehydrate.contactVerifiedPhone);
}
}, [rehydrate?.contactVerifiedPhone]);
const phoneVerified = samePhone(verifiedPhone, contactPhoneE164);
const [otpSent, setOtpSent] = useState(false);
const [otpCode, setOtpCode] = useState("");
const [sendingOtp, setSendingOtp] = useState(false);
const [verifyingOtp, setVerifyingOtp] = useState(false);
const [otpError, setOtpError] = useState<string | null>(null);
const [resendIn, setResendIn] = useState(0);
// Resend cooldown countdown (no Date.now needed — pure setTimeout ticks).
useEffect(() => {
if (resendIn <= 0) return;
const t = setTimeout(() => setResendIn((s) => s - 1), 1000);
return () => clearTimeout(t);
}, [resendIn]);
// A changed contact phone invalidates any in-flight code entry (the previous
// code was for a different number). Verified state is handled separately via
// the phone comparison, so this only resets the send/enter UI.
useEffect(() => {
setOtpSent(false);
setOtpCode("");
setOtpError(null);
}, [contactPhoneE164]);
const sendContactOtp = async () => {
setOtpError(null);
if (!contactPhoneE164) {
setOtpError("Enter a valid contact phone number first.");
return;
}
setSendingOtp(true);
try {
await api.auth.sendOTP.call({ phone: contactPhoneE164 });
setOtpSent(true);
setOtpCode("");
setResendIn(60);
} catch (err) {
setOtpError(extractApiError(err).message);
} finally {
setSendingOtp(false);
}
};
const verifyContactOtp = async () => {
setOtpError(null);
if (otpCode.length !== 6) {
setOtpError("Enter the 6-digit code we sent you.");
return;
}
setVerifyingOtp(true);
try {
await api.auth.verifyOTP.call({ phone: contactPhoneE164, otp: otpCode });
setVerifiedPhone(contactPhoneE164);
setOtpSent(false);
// Persist the verified phone so the step resumes as "done" after a refresh
// (best-effort — the OTP itself already succeeded server-side).
onSaveStep?.({ contactVerifiedPhone: contactPhoneE164 }).catch(() => {});
} catch (err) {
setOtpError(extractApiError(err).message);
} finally {
setVerifyingOtp(false);
}
};
const hasDocuments = Boolean(uploadSetting?.fields?.length);
// The registration/license details come straight from the eTrade lookup and
@@ -529,6 +642,7 @@ export default function CompanyProfileForm({
"company",
"personnel",
"contact",
"verify",
"poa",
"documents",
"additional",
@@ -571,6 +685,20 @@ export default function CompanyProfileForm({
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
return;
}
// Contact-phone verification gates advancing past the verify step. The
// verified phone is already persisted (on verify success), so there's
// nothing extra to save here.
if (step === "verify") {
if (!phoneVerified) {
setSaveError(
"Please verify the contact person's phone number to continue.",
);
return;
}
setSaveError(null);
setStep(stepOrder[currentIdx + 1]);
return;
}
// The documents step has nothing to persist; field steps validate + save
// before advancing.
if (step !== "documents") {
@@ -789,21 +917,32 @@ export default function CompanyProfileForm({
{step === "contact" && (
<>
<Group justify="space-between" align="center">
<Group justify="space-between" align="center" wrap="nowrap">
<Text fw={600} size="sm" c="edr-text">
Contact Person
</Text>
{watch("generalManagerName") && (
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
<Button
variant="light"
color="edr-green"
size="xs"
leftSection={<UserCheck size={14} />}
onClick={useGmAsContact}
onClick={useLoggedInUserAsContact}
>
Use General Manager
Use me
</Button>
)}
{watch("generalManagerName") && (
<Button
variant="light"
color="edr-green"
size="xs"
leftSection={<UserCheck size={14} />}
onClick={useGmAsContact}
>
Use General Manager
</Button>
)}
</Group>
</Group>
<SimpleGrid cols={2} spacing="md">
<TextInput
@@ -837,6 +976,106 @@ export default function CompanyProfileForm({
</>
)}
{step === "verify" && (
<Stack gap="md">
<Group gap="xs" align="center">
<ShieldCheck size={18} className="text-[var(--mantine-color-edr-green-7)]" />
<Text fw={600} size="sm" c="edr-text">
Verify the contact person
</Text>
</Group>
<Text size="sm" c="edr-muted">
We'll text a one-time code to the contact person's phone to
confirm it's reachable. This is required before you continue.
</Text>
{!contactPhoneE164 ? (
<Alert
color="yellow"
variant="light"
icon={<AlertCircle size={18} />}
>
Add a valid contact phone number on the previous step first.
</Alert>
) : phoneVerified ? (
<Alert
color="edr-green"
variant="light"
icon={<CheckCircle2 size={18} />}
title="Phone verified"
>
{maskPhone(contactPhoneE164)} has been verified.
</Alert>
) : (
<Stack gap="sm">
<Group gap="xs" align="center">
<Smartphone size={16} className="text-[var(--mantine-color-edr-muted)]" />
<Text size="sm" c="edr-text">
{maskPhone(contactPhoneE164)}
</Text>
</Group>
{!otpSent ? (
<Button
color="edr-green"
variant="light"
onClick={sendContactOtp}
loading={sendingOtp}
leftSection={<Smartphone size={16} />}
style={{ alignSelf: "flex-start" }}
>
Send code via SMS
</Button>
) : (
<Stack gap="sm">
<Text size="sm" c="edr-muted">
Enter the 6-digit code we sent to{" "}
{maskPhone(contactPhoneE164)}.
</Text>
<PinInput
length={6}
type="number"
oneTimeCode
value={otpCode}
onChange={setOtpCode}
/>
<Group gap="sm">
<Button
color="edr-green"
onClick={verifyContactOtp}
loading={verifyingOtp}
disabled={otpCode.length !== 6}
>
Verify
</Button>
<Button
variant="subtle"
color="edr-green"
onClick={sendContactOtp}
loading={sendingOtp}
disabled={resendIn > 0 || sendingOtp}
leftSection={<RotateCw size={14} />}
>
{resendIn > 0 ? `Resend in ${resendIn}s` : "Resend code"}
</Button>
</Group>
</Stack>
)}
{otpError && (
<Alert
color="red"
variant="light"
icon={<AlertCircle size={18} />}
>
{otpError}
</Alert>
)}
</Stack>
)}
</Stack>
)}
{step === "poa" && (
<>
<Group justify="space-between" align="center" wrap="nowrap">
@@ -966,7 +1205,8 @@ export default function CompanyProfileForm({
disabled={
isPending ||
saving ||
(step === "documents" && !hasDocuments && loadingDocuments)
(step === "documents" && !hasDocuments && loadingDocuments) ||
(step === "verify" && !phoneVerified)
}
loading={isPending || saving}
rightSection={
@@ -978,7 +1218,7 @@ export default function CompanyProfileForm({
) : undefined
}
>
{step === "documents"
{step === "documents" || step === "verify"
? "Continue"
: step === "additional"
? "Submit for review"

View File

@@ -34,7 +34,8 @@ export interface SignupResponse {
export interface OtpPayload {
phone: string;
otp: string;
/** Required on verify; omitted on send (the server generates the code). */
otp?: string;
}
export interface OtpResponse {

View File

@@ -29,6 +29,8 @@ export interface ProfileResponse {
contactPersonPosition: string | null;
contactPersonEmail: string | null;
contactPersonPhone: string | null;
/** Phone that passed SMS OTP verification (resumes the verify step's state). */
contactVerifiedPhone: string | null;
generalManagerName: string | null;
generalManagerEmail: string | null;
generalManagerPhone: string | null;
@@ -66,6 +68,7 @@ export interface UpdateProfilePayload {
contactPersonPosition?: string;
contactPersonEmail?: string;
contactPersonPhone?: string;
contactVerifiedPhone?: string;
generalManagerName?: string;
generalManagerEmail?: string;
generalManagerPhone?: string;