diff --git a/apps/edr-freight-api/src/migrations/1810000000003-CreateOtpVerifications.ts b/apps/edr-freight-api/src/migrations/1810000000003-CreateOtpVerifications.ts new file mode 100644 index 000000000..ec0610f52 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1810000000003-CreateOtpVerifications.ts @@ -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 { + 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 { + await queryRunner.dropTable("otp_verifications", true); + } +} diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 071161650..1ef467d73 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -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) diff --git a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts index 97f2d9f50..89a52b5e6 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts index d94cb5f35..c62547551 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/otp/otp.controller.ts b/apps/edr-freight-api/src/modules/otp/otp.controller.ts index 9866ca570..5850cbb1a 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.controller.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.controller.ts @@ -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); } // --------------------------------------------------------------------------- diff --git a/apps/edr-freight-api/src/modules/otp/otp.service.ts b/apps/edr-freight-api/src/modules/otp/otp.service.ts index 4e16be20a..d70bc0ce8 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.service.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.service.ts @@ -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 = diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx index a12c959c4..8d6bc3d93 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -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: , + title: "Verify Contact Person", + description: "Confirm the contact phone with a one-time SMS code.", + }, poa: { icon: , title: "Power of Attorney", diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index 80d2b4db3..4f61c7a61 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -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 = { "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( + 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(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" && ( <> - + Contact Person - {watch("generalManagerName") && ( + - )} + {watch("generalManagerName") && ( + + )} + )} + {step === "verify" && ( + + + + + Verify the contact person + + + + We'll text a one-time code to the contact person's phone to + confirm it's reachable. This is required before you continue. + + + {!contactPhoneE164 ? ( + } + > + Add a valid contact phone number on the previous step first. + + ) : phoneVerified ? ( + } + title="Phone verified" + > + {maskPhone(contactPhoneE164)} has been verified. + + ) : ( + + + + + {maskPhone(contactPhoneE164)} + + + + {!otpSent ? ( + + ) : ( + + + Enter the 6-digit code we sent to{" "} + {maskPhone(contactPhoneE164)}. + + + + + + + + )} + + {otpError && ( + } + > + {otpError} + + )} + + )} + + )} + {step === "poa" && ( <> @@ -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" diff --git a/apps/edr-freight-web/portal/src/types/auth.ts b/apps/edr-freight-web/portal/src/types/auth.ts index d95949bb9..e9a84dd93 100644 --- a/apps/edr-freight-web/portal/src/types/auth.ts +++ b/apps/edr-freight-web/portal/src/types/auth.ts @@ -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 { diff --git a/apps/edr-freight-web/portal/src/types/profile.ts b/apps/edr-freight-web/portal/src/types/profile.ts index 951a1f129..3d3f2bad6 100644 --- a/apps/edr-freight-web/portal/src/types/profile.ts +++ b/apps/edr-freight-web/portal/src/types/profile.ts @@ -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;