From 4e6e614b48b276b187c5c34001c1978836519ecd Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 3 Jul 2026 10:59:06 +0000 Subject: [PATCH 1/7] feat: add email to notification and otp --- apps/edr-freight-api/.env.example | 6 +- .../modules/notifications/dtos/email.dto.ts | 30 +++++ .../notifications/email-client.service.ts | 51 ++++++++ .../notifications/notifications.module.ts | 20 ++- .../src/modules/otp/otp.controller.ts | 25 +++- .../src/modules/otp/otp.entity.ts | 11 +- .../src/modules/otp/otp.module.ts | 1 + .../src/modules/otp/otp.repository.ts | 49 ++++++- .../src/modules/otp/otp.service.ts | 123 ++++++++++++++---- 9 files changed, 278 insertions(+), 38 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/notifications/dtos/email.dto.ts create mode 100644 apps/edr-freight-api/src/modules/notifications/email-client.service.ts diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index e02391ccd..1ed8ff9fc 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -55,8 +55,10 @@ REDIS_HOST=localhost REDIS_PORT=6379 # --- Notification broker (RabbitMQ) --------------------------------------------- -# SMS OTP / notifications are queued to RabbitMQ (consumed by the shared SMS service). -# Set RABBITMQ_ENABLED=false to skip the broker entirely (dev without a local broker). +# SMS/email OTP + notifications are queued to RabbitMQ (consumed by the shared +# SMS/email services). Set RABBITMQ_ENABLED=false to skip the broker entirely +# (dev without a local broker). RABBITMQ_ENABLED=false RABBITMQ_URL=amqp://localhost:5672 SMS_QUEUE=sms_queue +EMAIL_QUEUE=email_queue diff --git a/apps/edr-freight-api/src/modules/notifications/dtos/email.dto.ts b/apps/edr-freight-api/src/modules/notifications/dtos/email.dto.ts new file mode 100644 index 000000000..79a6547bc --- /dev/null +++ b/apps/edr-freight-api/src/modules/notifications/dtos/email.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsEmail, IsNotEmpty, IsOptional, IsString } from "class-validator"; + +export class SendEmailDto { + @ApiProperty({ + description: "Recipient email address", + example: "customer@example.com", + }) + @IsEmail() + @IsNotEmpty() + to!: string; + + @ApiProperty({ + description: "Email subject", + example: "Your EDR Freight verification code", + }) + @IsString() + @IsNotEmpty() + subject!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + text?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + html?: string; +} diff --git a/apps/edr-freight-api/src/modules/notifications/email-client.service.ts b/apps/edr-freight-api/src/modules/notifications/email-client.service.ts new file mode 100644 index 000000000..161b2486a --- /dev/null +++ b/apps/edr-freight-api/src/modules/notifications/email-client.service.ts @@ -0,0 +1,51 @@ +import { + Inject, + Injectable, + Logger, + OnApplicationBootstrap, +} from "@nestjs/common"; +import { ClientProxy } from "@nestjs/microservices"; +import { SendEmailDto } from "./dtos/email.dto"; + +@Injectable() +export class EmailClientService implements OnApplicationBootstrap { + private readonly logger = new Logger(EmailClientService.name); + + constructor( + @Inject("EMAIL_SERVICE") + private readonly emailClient: ClientProxy, + ) {} + + private readonly enabled = process.env.RABBITMQ_ENABLED !== "false"; + + async onApplicationBootstrap() { + if (!this.enabled) return; + this.emailClient + .connect() + .then(() => this.logger.log("connected to Email service")) + .catch((err) => { + console.error("Error happened at Email service", err); + }); + } + + async sendEmail(dto: SendEmailDto): Promise<{ queued: boolean }> { + if (!this.enabled) { + this.logger.warn(`RABBITMQ disabled — skipped EMAIL to=${dto.to}`); + return { queued: false }; + } + this.emailClient.emit("send-email", { + to: dto.to, + subject: dto.subject, + text: dto.text, + html: dto.html, + appKey: "IFHCRS-LICENSE-MANAGEMENT", + }); + // Fire-and-forget enqueue: confirms hand-off to RabbitMQ, NOT delivery. + this.logger.log( + `EMAIL queued to RabbitMQ [${process.env.EMAIL_QUEUE ?? "email_queue"}] pattern='send-email'`, + ); + // Recipient + content are PII — debug only. + this.logger.debug(`EMAIL payload to=${dto.to} subject="${dto.subject}"`); + return { queued: true }; + } +} diff --git a/apps/edr-freight-api/src/modules/notifications/notifications.module.ts b/apps/edr-freight-api/src/modules/notifications/notifications.module.ts index 663f931ef..4e56c8b70 100644 --- a/apps/edr-freight-api/src/modules/notifications/notifications.module.ts +++ b/apps/edr-freight-api/src/modules/notifications/notifications.module.ts @@ -4,6 +4,7 @@ import { ClientsModule, Transport } from "@nestjs/microservices"; import { NotificationsService } from "./notifications.service"; import { SmsClientService } from "./sms-client.service"; +import { EmailClientService } from "./email-client.service"; import { EmailNotificationStrategy } from "./strategies/notification.email.strategy"; import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy"; @@ -20,10 +21,25 @@ import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy" queueOptions: { durable: true }, }, }, + { + name: "EMAIL_SERVICE", + transport: Transport.RMQ, + options: { + urls: [process.env.RABBITMQ_URL as string], + queue: process.env.EMAIL_QUEUE ?? "email_queue", + queueOptions: { durable: true }, + }, + }, ]), ], controllers: [], - providers: [EmailNotificationStrategy, SmsNotificationStrategy, NotificationsService, SmsClientService], - exports: [NotificationsService, SmsClientService], + providers: [ + EmailNotificationStrategy, + SmsNotificationStrategy, + NotificationsService, + SmsClientService, + EmailClientService, + ], + exports: [NotificationsService, SmsClientService, EmailClientService], }) export class NotificationsModule {} 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 5850cbb1a..155657a74 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.controller.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.controller.ts @@ -1,15 +1,24 @@ // otp.controller.ts import { + BadRequestException, Body, Controller, Post, } from "@nestjs/common"; -import { OtpService } from "./otp.service"; +import { OtpService, OtpTarget } from "./otp.service"; import { Public } from "@edr/api-common"; +// Exactly one of phone/email must be present per request — the channel the +// code is sent through / checked against. +function toTarget(phone?: string, email?: string): OtpTarget { + if (email) return { email }; + if (phone) return { phone }; + throw new BadRequestException("phone or email is required"); +} + @Controller("otp") @Public() export class OtpController { @@ -24,9 +33,12 @@ export class OtpController { @Post("send") async sendOtp( @Body("phone") - phone: string + phone?: string, + + @Body("email") + email?: string ) { - return this.otpService.sendOtp(phone); + return this.otpService.sendOtp(toTarget(phone, email)); } // --------------------------------------------------------------------------- @@ -36,13 +48,16 @@ export class OtpController { @Post("verify") async verifyOtp( @Body("phone") - phone: string, + phone: string | undefined, + + @Body("email") + email: string | undefined, @Body("otp") otp: string ) { return this.otpService.verifyOtp( - phone, + toTarget(phone, email), otp ); } diff --git a/apps/edr-freight-api/src/modules/otp/otp.entity.ts b/apps/edr-freight-api/src/modules/otp/otp.entity.ts index f5900f6b8..022bbf767 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.entity.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.entity.ts @@ -10,10 +10,19 @@ import { BaseEntity } from "@edr/api-common"; name: "otp_verifications", }) export class OtpVerification extends BaseEntity{ + // Exactly one of phone/email is set per row — the channel the code was sent + // through. @Column({ unique: true, + nullable: true, }) - phone!: string; + phone?: string; + + @Column({ + unique: true, + nullable: true, + }) + email?: string; @Column() otp!: string; diff --git a/apps/edr-freight-api/src/modules/otp/otp.module.ts b/apps/edr-freight-api/src/modules/otp/otp.module.ts index ec1d9f9ed..511fe4bbb 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.module.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.module.ts @@ -31,6 +31,7 @@ import { NotificationsModule } from "../notifications/notifications.module"; exports: [ OtpRepository, + OtpService, ], }) export class OtpModule {} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/otp/otp.repository.ts b/apps/edr-freight-api/src/modules/otp/otp.repository.ts index 8aa69dcd6..7abd434d8 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.repository.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.repository.ts @@ -31,17 +31,44 @@ export class OtpRepository { }); } + // --------------------------------------------------------------------------- + // Find By Email + // --------------------------------------------------------------------------- + + async findByEmail( + email: string + ) { + return this.repository.findOne({ + where: { + email, + }, + }); + } + + // --------------------------------------------------------------------------- + // Find By Target (either channel) + // --------------------------------------------------------------------------- + + async findByTarget( + target: { phone?: string; email?: string } + ) { + return target.email + ? this.findByEmail(target.email) + : this.findByPhone(target.phone!); + } + // --------------------------------------------------------------------------- // Create OTP // --------------------------------------------------------------------------- async createOtp( - phone: string, + target: { phone?: string; email?: string }, otp: string ) { const entity = this.repository.create({ - phone, + phone: target.phone, + email: target.email, otp, verified: false, }); @@ -70,10 +97,10 @@ export class OtpRepository { } // --------------------------------------------------------------------------- - // Verify Phone + // Mark Verified // --------------------------------------------------------------------------- - async verifyPhone( + async markVerified( otpVerification: OtpVerification ) { otpVerification.verified = @@ -83,4 +110,18 @@ export class OtpRepository { otpVerification ); } + + // --------------------------------------------------------------------------- + // Delete OTP (single-use consume) + // --------------------------------------------------------------------------- + + // Hard delete so the unique `phone` row is freed and a fresh code can be + // requested for the same number on the next action. + async deleteOtp( + otpVerification: OtpVerification + ) { + return this.repository.remove( + otpVerification + ); + } } \ No newline at end of file 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 ffa9c4e68..436f34411 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.service.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.service.ts @@ -8,12 +8,18 @@ import { import { OtpRepository } from "./otp.repository"; import { SmsClientService } from "../notifications/sms-client.service"; +import { EmailClientService } from "../notifications/email-client.service"; + +// Exactly one of phone/email is set — enforced by the controller before it +// reaches here. +export type OtpTarget = { phone?: string; email?: string }; @Injectable() export class OtpService { constructor( private readonly otpRepository: OtpRepository, - private readonly smsClient: SmsClientService + private readonly smsClient: SmsClientService, + private readonly emailClient: EmailClientService ) {} // --------------------------------------------------------------------------- @@ -30,38 +36,47 @@ export class OtpService { // Send OTP // --------------------------------------------------------------------------- - async sendOtp(phone: string) { + async sendOtp(target: OtpTarget) { try { // 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. + // recipient of the SMS/email. const otp = this.generateOtp(); - // find existing phone - const existingPhone = - await this.otpRepository.findByPhone( - phone + // find existing row for this channel + const existing = + await this.otpRepository.findByTarget( + target ); // update existing otp - if (existingPhone) { + if (existing) { await this.otpRepository.updateOtp( - existingPhone, + existing, otp ); } else { // create new otp await this.otpRepository.createOtp( - phone, + target, otp ); } - // send sms (queued to RabbitMQ via the shared SMS service) - await this.smsClient.sendSms({ - to: phone, - message: `Your verification code is ${otp}`, - }); + if (target.email) { + // send email (queued to RabbitMQ via the shared Email service) + await this.emailClient.sendEmail({ + to: target.email, + subject: "Your EDR Freight verification code", + text: `Your verification code is ${otp}`, + }); + } else { + // send sms (queued to RabbitMQ via the shared SMS service) + await this.smsClient.sendSms({ + to: target.phone as string, + message: `Your verification code is ${otp}`, + }); + } return { success: true, @@ -83,19 +98,21 @@ export class OtpService { // --------------------------------------------------------------------------- async verifyOtp( - phone: string, + target: OtpTarget, otp: string ) { - // find phone + // find the channel's row const otpData = - await this.otpRepository.findByPhone( - phone + await this.otpRepository.findByTarget( + target ); - // phone not found + // not found if (!otpData) { throw new BadRequestException( - "Phone number not found" + target.email + ? "Email address not found" + : "Phone number not found" ); } @@ -106,8 +123,8 @@ export class OtpService { ); } - // verify phone - await this.otpRepository.verifyPhone( + // mark verified + await this.otpRepository.markVerified( otpData ); @@ -115,7 +132,65 @@ export class OtpService { success: true, message: - "Phone verified successfully", + target.email + ? "Email verified successfully" + : "Phone verified successfully", }; } + + // --------------------------------------------------------------------------- + // Verify OTP for a sensitive action (sudo mode) + // --------------------------------------------------------------------------- + + // Fresh, single-use challenge gating a sensitive action (e.g. applying a + // contract signature). Unlike verifyOtp above — which marks a phone verified + // and leaves the code in place — this enforces a short TTL and consumes the + // code on success so it can never be replayed. + private readonly ACTION_OTP_TTL_MS = + 5 * 60 * 1000; + + async verifyOtpForAction( + phone: string, + otp: string + ) { + const otpData = + await this.otpRepository.findByPhone( + phone + ); + + if (!otpData) { + throw new BadRequestException( + "No verification code was requested for this phone" + ); + } + + const ageMs = + Date.now() - + new Date( + otpData.updatedAt + ).getTime(); + + if (ageMs > this.ACTION_OTP_TTL_MS) { + await this.otpRepository.deleteOtp( + otpData + ); + + throw new BadRequestException( + "Verification code has expired. Request a new one." + ); + } + + if (otpData.otp !== otp) { + throw new BadRequestException( + "Invalid verification code" + ); + } + + // single-use: consume on success + await this.otpRepository.deleteOtp( + otpData + ); + + return { success: true }; + } } \ No newline at end of file From 2d71f24937af4880cdf174661c8ddb1d2aea5b69 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 3 Jul 2026 11:08:57 +0000 Subject: [PATCH 2/7] chore: rm the verify step in onboarding --- .../onboarding/OnboardingWizardDialog.tsx | 7 - .../src/pages/accounts/CompanyProfileForm.tsx | 205 +----------------- .../accounts/companyProfileForm/schema.ts | 2 - 3 files changed, 1 insertion(+), 213 deletions(-) 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 4d4c8664a..d6c965053 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -46,7 +46,6 @@ type FormStep = | "company" | "personnel" | "contact" - | "verify" | "poa" | "documents" | "additional"; @@ -54,7 +53,6 @@ const FORM_STEPS: FormStep[] = [ "company", "personnel", "contact", - "verify", "poa", "documents", "additional", @@ -95,11 +93,6 @@ 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 bfde5c43b..3c8ad0271 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -4,7 +4,6 @@ import { Divider, Group, Loader, - PinInput, SimpleGrid, Stack, Text, @@ -16,9 +15,6 @@ import { AlertCircle, ArrowLeft, ArrowRight, - CheckCircle2, - RotateCw, - Smartphone, UserCheck, } from "lucide-react"; import { useEffect, useRef, useState } from "react"; @@ -35,7 +31,6 @@ import RoleLicenseStep, { type RoleLicenseProfile, } from "@/components/onboarding/RoleLicenseStep"; import ETradeInfo from "@/components/onboarding/ETradeInfo"; -import { extractApiError } from "@/utils/result"; import { type CompanyStep, type FormData, @@ -44,8 +39,6 @@ import { } from "./companyProfileForm/schema"; import { buildPayload, - maskPhone, - samePhone, stepPayload, toFormValues, } from "./companyProfileForm/helpers"; @@ -350,85 +343,6 @@ export default function CompanyProfileForm({ } }; - // --- 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 @@ -451,7 +365,6 @@ export default function CompanyProfileForm({ "company", "personnel", "contact", - "verify", "poa", "documents", "additional", @@ -495,20 +408,6 @@ 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 auto-uploads whatever the user selected as they // continue (partial uploads are allowed — required-doc completeness is // re-checked on resume). A failed upload holds them on the step. @@ -783,107 +682,6 @@ export default function CompanyProfileForm({ )} - {step === "verify" && ( - - - 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 ? ( - - ) : ( - - - - - - - - )} - - {otpError && ( - } - > - {otpError} - - )} - - )} - - )} - {step === "poa" && ( <> @@ -1009,8 +807,7 @@ export default function CompanyProfileForm({ disabled={ isPending || saving || - (step === "documents" && !hasDocuments && loadingDocuments) || - (step === "verify" && !phoneVerified) + (step === "documents" && !hasDocuments && loadingDocuments) } loading={isPending || saving} rightSection={ diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts index 31ee21ced..9a123e255 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts @@ -6,7 +6,6 @@ export type CompanyStep = | "company" | "personnel" | "contact" - | "verify" | "poa" | "documents" | "additional"; @@ -103,7 +102,6 @@ export const stepFields: Record = { "contactPersonEmail", "contactPersonPhone", ], - verify: [], poa: [], documents: [], additional: [], From 37ec1c40ab31f4308fef7c6dba1cf62425dcec3d Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 3 Jul 2026 11:48:39 +0000 Subject: [PATCH 3/7] chore: add loger --- .../src/modules/otp/otp.service.ts | 103 +++++------------- 1 file changed, 29 insertions(+), 74 deletions(-) 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 436f34411..67fbdec9b 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.service.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.service.ts @@ -1,9 +1,6 @@ // otp.service.ts -import { - BadRequestException, - Injectable, -} from "@nestjs/common"; +import { BadRequestException, Injectable, Logger } from "@nestjs/common"; import { OtpRepository } from "./otp.repository"; @@ -16,20 +13,19 @@ export type OtpTarget = { phone?: string; email?: string }; @Injectable() export class OtpService { + logger = new Logger(OtpService.name); constructor( private readonly otpRepository: OtpRepository, private readonly smsClient: SmsClientService, - private readonly emailClient: EmailClientService - ) {} + private readonly emailClient: EmailClientService, + ) { } // --------------------------------------------------------------------------- // Generate OTP // --------------------------------------------------------------------------- generateOtp(): string { - return Math.floor( - 100000 + Math.random() * 900000 - ).toString(); + return Math.floor(100000 + Math.random() * 900000).toString(); } // --------------------------------------------------------------------------- @@ -44,23 +40,14 @@ export class OtpService { const otp = this.generateOtp(); // find existing row for this channel - const existing = - await this.otpRepository.findByTarget( - target - ); + const existing = await this.otpRepository.findByTarget(target); // update existing otp if (existing) { - await this.otpRepository.updateOtp( - existing, - otp - ); + await this.otpRepository.updateOtp(existing, otp); } else { // create new otp - await this.otpRepository.createOtp( - target, - otp - ); + await this.otpRepository.createOtp(target, otp); } if (target.email) { @@ -78,18 +65,16 @@ export class OtpService { }); } + this.logger.log(`OTP send for ${target.email ?? target.phone}: ${otp}`); return { success: true, - message: - "OTP sent successfully", + message: "OTP sent successfully", }; } catch (error) { console.log(error); - throw new BadRequestException( - "Failed to send OTP" - ); + throw new BadRequestException("Failed to send OTP"); } } @@ -97,44 +82,31 @@ export class OtpService { // Verify OTP // --------------------------------------------------------------------------- - async verifyOtp( - target: OtpTarget, - otp: string - ) { + async verifyOtp(target: OtpTarget, otp: string) { // find the channel's row - const otpData = - await this.otpRepository.findByTarget( - target - ); + const otpData = await this.otpRepository.findByTarget(target); // not found if (!otpData) { throw new BadRequestException( - target.email - ? "Email address not found" - : "Phone number not found" + target.email ? "Email address not found" : "Phone number not found", ); } // invalid otp if (otpData.otp !== otp) { - throw new BadRequestException( - "Invalid OTP" - ); + throw new BadRequestException("Invalid OTP"); } // mark verified - await this.otpRepository.markVerified( - otpData - ); + await this.otpRepository.markVerified(otpData); return { success: true, - message: - target.email - ? "Email verified successfully" - : "Phone verified successfully", + message: target.email + ? "Email verified successfully" + : "Phone verified successfully", }; } @@ -146,51 +118,34 @@ export class OtpService { // contract signature). Unlike verifyOtp above — which marks a phone verified // and leaves the code in place — this enforces a short TTL and consumes the // code on success so it can never be replayed. - private readonly ACTION_OTP_TTL_MS = - 5 * 60 * 1000; + private readonly ACTION_OTP_TTL_MS = 5 * 60 * 1000; - async verifyOtpForAction( - phone: string, - otp: string - ) { - const otpData = - await this.otpRepository.findByPhone( - phone - ); + async verifyOtpForAction(phone: string, otp: string) { + const otpData = await this.otpRepository.findByPhone(phone); if (!otpData) { throw new BadRequestException( - "No verification code was requested for this phone" + "No verification code was requested for this phone", ); } - const ageMs = - Date.now() - - new Date( - otpData.updatedAt - ).getTime(); + const ageMs = Date.now() - new Date(otpData.updatedAt).getTime(); if (ageMs > this.ACTION_OTP_TTL_MS) { - await this.otpRepository.deleteOtp( - otpData - ); + await this.otpRepository.deleteOtp(otpData); throw new BadRequestException( - "Verification code has expired. Request a new one." + "Verification code has expired. Request a new one.", ); } if (otpData.otp !== otp) { - throw new BadRequestException( - "Invalid verification code" - ); + throw new BadRequestException("Invalid verification code"); } // single-use: consume on success - await this.otpRepository.deleteOtp( - otpData - ); + await this.otpRepository.deleteOtp(otpData); return { success: true }; } -} \ No newline at end of file +} From 414c9610dc0afddc3dfa687880377559c7d0dda6 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 3 Jul 2026 11:57:40 +0000 Subject: [PATCH 4/7] chore: migrate to otp table --- ...900000000000-AddEmailToOtpVerifications.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 apps/edr-freight-api/src/migrations/1900000000000-AddEmailToOtpVerifications.ts diff --git a/apps/edr-freight-api/src/migrations/1900000000000-AddEmailToOtpVerifications.ts b/apps/edr-freight-api/src/migrations/1900000000000-AddEmailToOtpVerifications.ts new file mode 100644 index 000000000..1bd3bbc27 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1900000000000-AddEmailToOtpVerifications.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Support email as a second OTP channel alongside phone (e.g. signup lets the + * user choose which one to verify). `phone` becomes nullable since an + * email-channel row has none, and `email` is added as a nullable unique column + * mirroring `phone`'s shape. + */ +export class AddEmailToOtpVerifications1900000000000 + implements MigrationInterface +{ + name = "AddEmailToOtpVerifications1900000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE public.otp_verifications + ALTER COLUMN phone DROP NOT NULL + `); + await queryRunner.query(` + ALTER TABLE public.otp_verifications + ADD COLUMN IF NOT EXISTS email varchar UNIQUE + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE public.otp_verifications + DROP COLUMN IF EXISTS email + `); + await queryRunner.query(` + ALTER TABLE public.otp_verifications + ALTER COLUMN phone SET NOT NULL + `); + } +} From d3707fe704ea444bd01614fe80c8a8480a3a65c9 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 3 Jul 2026 12:09:35 +0000 Subject: [PATCH 5/7] feat: merge the role and nationality step --- .../onboarding/OnboardingWizardDialog.tsx | 79 +++++-------------- 1 file changed, 19 insertions(+), 60 deletions(-) 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 d6c965053..ab00965fa 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -10,10 +10,8 @@ import { } from "@mantine/core"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { - ArrowLeft, ArrowRight, Building2, - CheckCircle2, Clock, FileText, Globe2, @@ -42,42 +40,29 @@ import type { UpdateProfilePayload } from "@/types/profile"; import { extractApiError } from "@/utils/result"; /** Form steps rendered by CompanyProfileForm. */ -type FormStep = - | "company" - | "personnel" - | "contact" - | "poa" - | "documents" - | "additional"; +type FormStep = "company" | "personnel" | "contact" | "poa" | "documents"; const FORM_STEPS: FormStep[] = [ "company", "personnel", "contact", "poa", "documents", - "additional", ]; /** The full onboarding journey: the two pre-form phases + the form steps. */ -type WizardStep = "nationality" | "role" | FormStep; -const WIZARD_STEPS: WizardStep[] = ["nationality", "role", ...FORM_STEPS]; +type WizardStep = "nationality-role" | FormStep; +const WIZARD_STEPS: WizardStep[] = ["nationality-role", ...FORM_STEPS]; /** Icon + title + description shown in the global dialog header per step. */ const STEP_META: Record< WizardStep, { icon: ReactNode; title: string; description: string } > = { - nationality: { + "nationality-role": { icon: , - title: "Where is your company registered?", + title: "Tell us about your company", description: "This determines the documents we'll ask you to provide.", }, - role: { - icon: , - title: "What does your company do?", - description: - "Pick any combination of Importer, Exporter and Freight Forwarder — each is set up with its own business license.", - }, company: { icon: , title: "Company Information", @@ -103,11 +88,6 @@ const STEP_META: Record< title: "Upload Documents", description: "Provide the required company documents.", }, - additional: { - icon: , - title: "Business License", - description: "Upload a business license for each operational profile.", - }, }; interface OnboardingWizardDialogProps { @@ -165,12 +145,8 @@ export default function OnboardingWizardDialog({ // Phases: nationality → role → form. If a draft already exists, resume // straight into the form with nationality + roles pre-selected. - const [phase, setPhase] = useState<"nationality" | "role" | "form">( - companyAlreadyStarted - ? hasOperationalProfiles - ? "form" - : "role" - : "nationality", + const [phase, setPhase] = useState<"nationality-role" | "form">( + companyAlreadyStarted ? "form" : "nationality-role", ); const [nationality, setNationality] = useState( savedNationality, @@ -295,16 +271,12 @@ export default function OnboardingWizardDialog({ setNationality(savedNationality); // Resume into the form only when profiles exist; otherwise send the user to // role selection so the missing operational profiles get created. - setPhase(hasOperationalProfiles ? "form" : "role"); + setPhase(hasOperationalProfiles ? "form" : "nationality-role"); const idx = FORM_STEPS.indexOf(resumeFormStep); if (idx > furthestIdxRef.current) furthestIdxRef.current = idx; // eslint-disable-next-line react-hooks/exhaustive-deps }, [companyAlreadyStarted, resumeFormStep]); - const handleNationalityContinue = useCallback(() => { - if (nationality) setPhase("role"); - }, [nationality]); - const handleRolesContinue = useCallback(() => { setStartError(null); startMutation.mutate({ @@ -387,6 +359,7 @@ export default function OnboardingWizardDialog({ // The active step across the whole journey, driving the header + progress pill. const activeStep: WizardStep = phase === "form" ? formStep : phase; const stepMeta = STEP_META[activeStep]; + console.log({ stepMeta, activeStep, STEP_META }); const activeIdx = WIZARD_STEPS.indexOf(activeStep); // Closing from the congratulations panel also clears the completed flag so a @@ -418,7 +391,7 @@ export default function OnboardingWizardDialog({ ); const effectiveResumeStep: FormStep = requiredDocsMissing && - FORM_STEPS.indexOf(resumeFormStep) > FORM_STEPS.indexOf("documents") + FORM_STEPS.indexOf(resumeFormStep) > FORM_STEPS.indexOf("documents") ? "documents" : resumeFormStep; @@ -490,26 +463,19 @@ export default function OnboardingWizardDialog({ ) : ( - {phase === "nationality" ? ( + {phase === "nationality-role" ? ( + + Where is your company registered? + - - - - - ) : phase === "role" ? ( - + + What does your company do?(multiple) + )} - - + ); From 79731e58ec7ed5d1456266f119219b8ee4c205f2 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 3 Jul 2026 12:19:07 +0000 Subject: [PATCH 6/7] feat: add otp to contract --- .../contracts/contract-transition.service.ts | 8 + .../src/modules/contracts/contracts.module.ts | 2 + .../contracts/dto/sign-contract.dto.ts | 17 +- .../src/pages/accounts/CompanyProfileForm.tsx | 63 +- .../portal/src/pages/accounts/SignupPage.tsx | 539 +++++++++++------- .../src/pages/contracts/ContractViewPage.tsx | 158 ++++- .../portal/src/services/bookings.service.ts | 4 + apps/edr-freight-web/portal/src/types/auth.ts | 4 +- 8 files changed, 553 insertions(+), 242 deletions(-) diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index e87112bc2..9bb4b2de6 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -19,6 +19,7 @@ import { CargoTypesService } from '../rule-engine/services/cargo-types.service'; import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; import { FilesService } from '../files/files.service'; import { SignaturesService } from '../signatures/signatures.service'; +import { OtpService } from '../otp/otp.service'; import { ContractPricingService } from './contract-pricing.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ContractsRepository } from './contracts.repository'; @@ -63,6 +64,7 @@ export class ContractTransitionService { private readonly renderer: ContractRendererService, private readonly pdfService: ContractPdfService, private readonly minioService: MinioService, + private readonly otpService: OtpService, ) {} /** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */ @@ -520,6 +522,12 @@ export class ContractTransitionService { if (existing) { throw new BadRequestException('Customer has already signed this contract'); } + // Sudo-mode gate: a fresh, single-use OTP (SMS'd to the customer's phone) + // must be verified before the signature is applied. + if (!dto.otpPhone || !dto.otp) { + throw new BadRequestException('OTP verification is required to sign the contract'); + } + await this.otpService.verifyOtpForAction(dto.otpPhone, dto.otp); await this.applySignature(contract, dto, options); await this.contractsRepository.update(contractId, { status: 'SIGNED_CUSTOMER', diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts index e0eece986..5bf6ddb4f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts @@ -11,6 +11,7 @@ import { RuleEngineModule } from '../rule-engine/rule-engine.module'; import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module'; import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module'; import { SignaturesModule } from '../signatures/signatures.module'; +import { OtpModule } from '../otp/otp.module'; import { BookingsModule } from '../bookings/bookings.module'; import { ContractsController } from './contracts.controller'; @@ -72,6 +73,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum FilesModule, MinioModule, SignaturesModule, + OtpModule, CompaniesModule, // BookingsModule provides BookingsRepository/BookingPricingService used by the // contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3). diff --git a/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts index febe7a83b..f0676b629 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsIn, IsOptional, IsString, MinLength } from 'class-validator'; +import { IsIn, IsOptional, IsString, Matches, MinLength } from 'class-validator'; export class SignContractDto { @ApiProperty({ enum: ['CUSTOMER', 'STAFF', 'DIRECTOR', 'CEO'] }) @@ -26,4 +26,19 @@ export class SignContractDto { @IsOptional() @IsString() consentText?: string; + + // Sudo-mode OTP challenge. Required when role=CUSTOMER: a fresh 6-digit code + // SMS'd to the signer's phone, verified server-side before the signature is + // applied. `otpPhone` is the number the code was sent to (the signed-in + // customer's registered phone). + @ApiPropertyOptional({ description: '6-digit OTP; required when role=CUSTOMER' }) + @IsOptional() + @IsString() + @Matches(/^\d{6}$/, { message: 'otp must be 6 digits' }) + otp?: string; + + @ApiPropertyOptional({ description: 'Phone the OTP was sent to; required when role=CUSTOMER' }) + @IsOptional() + @IsString() + otpPhone?: string; } 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 3c8ad0271..85af9bfdd 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -11,12 +11,7 @@ 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, UserCheck } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; @@ -288,6 +283,7 @@ export default function CompanyProfileForm({ const useOwnerAsManager = () => { if (!etradeOwner) return; setValue("generalManagerName", etradeOwner.name); + setValue("generalManagerEmail", user.email); setValue("generalManagerPhone", etradeOwner.phone ?? "", { shouldValidate: true, }); @@ -367,7 +363,6 @@ export default function CompanyProfileForm({ "contact", "poa", "documents", - "additional", ]; const currentIdx = stepOrder.indexOf(step); @@ -398,16 +393,6 @@ export default function CompanyProfileForm({ const nextStep = async () => { userNavigatedRef.current = true; - if (step === "additional") { - if (!licenseComplete) { - setSaveError( - "Please upload a business license for each of your operational profiles.", - ); - return; - } - handleSubmit((data) => onSubmit(buildPayload(data, user)))(); - return; - } // The documents step auto-uploads whatever the user selected as they // continue (partial uploads are allowed — required-doc completeness is // re-checked on resume). A failed upload holds them on the step. @@ -424,8 +409,15 @@ export default function CompanyProfileForm({ setSaving(false); } } + + if (!licenseComplete) { + setSaveError( + "Please upload a business license for each of your operational profiles.", + ); + return; + } setSaveError(null); - setStep(stepOrder[currentIdx + 1]); + handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; } // Field steps validate + save before advancing. @@ -450,10 +442,7 @@ export default function CompanyProfileForm({
e.preventDefault()}> {step === "company" && ( - <> - - Enter your TIN to auto-fill company information from eTrade - + - + )} {step === "personnel" && ( @@ -752,15 +741,13 @@ export default function CompanyProfileForm({ onChange={setDocumentFiles} /> )} - - )} - {step === "additional" && ( - { })} - /> + { })} + /> + )} {saveError && ( @@ -768,11 +755,7 @@ export default function CompanyProfileForm({ color="red" variant="light" icon={} - title={ - step === "additional" - ? "Business license required" - : "Couldn't save this step" - } + title={"Couldn't save this step"} > {saveError} @@ -796,7 +779,7 @@ export default function CompanyProfileForm({ onClick={prevStep} leftSection={} > - {step === "additional" ? "Back to Documents" : "Back"} + Back ) : ( @@ -811,12 +794,10 @@ export default function CompanyProfileForm({ } loading={isPending || saving} rightSection={ - !isPending && !saving && step !== "additional" ? ( - - ) : undefined + !isPending && !saving ? : undefined } > - {step === "additional" ? "Submit for review" : "Continue"} + {step === "documents" ? "Submit for review" : "Continue"} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx index 723591597..50320f699 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx @@ -1,18 +1,38 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { zodResolver } from "@hookform/resolvers/zod"; -import { ArrowRight, Check, Eye, EyeOff, X } from "lucide-react"; -import { Controller, useForm } from "react-hook-form"; +import { + Alert, + Button, + PasswordInput, + PinInput, + SegmentedControl, + SimpleGrid, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { + AlertCircle, + ArrowLeft, + ArrowRight, + Check, + Mail, + RotateCw, + ShieldCheck, + Smartphone, + X, +} from "lucide-react"; +import { useForm } from "react-hook-form"; import { useNavigate } from "react-router-dom"; import { z } from "zod"; -import RPNInput from "react-phone-number-input"; -import "react-phone-number-input/style.css"; import { userType } from "@/enums/userType"; import useAuth from "@/hooks/useAuth"; import type { SignupPayload } from "@/types/auth"; -import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell"; -import { isValidPhone } from "@/components/PhoneField"; -import "@/components/phone-field.css"; +import AuthShell from "@/components/auth/AuthShell"; +import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; +import { api } from "@/services/api"; +import { extractApiError } from "@/utils/result"; const EDR_LOGO = "/assets/edr-logo.png"; @@ -50,16 +70,46 @@ const userSchema = z type FormData = z.infer; -const errorText = (msg?: string) => - msg ?

{msg}

: null; +/** 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; + +/** Mask the local part of an email for display (j***e@example.com). */ +const maskEmail = (email: string) => { + const [local, domain] = email.split("@"); + if (!local || !domain) return email; + if (local.length <= 2) return `${local[0] ?? ""}***@${domain}`; + return `${local[0]}***${local[local.length - 1]}@${domain}`; +}; + +type OtpChannel = "phone" | "email"; export default function SignupPage() { const navigate = useNavigate(); const { signup } = useAuth(); const [error, setError] = useState(null); - const [loading, setLoading] = useState(false); - const [showPassword, setShowPassword] = useState(false); - const [showConfirm, setShowConfirm] = useState(false); + + // Two-stage signup: fill the form, then a mandatory SMS OTP challenge on the + // phone number before the account is actually created. The account is only + // created after the code is verified — the OTP is a hard requirement. + const [stage, setStage] = useState<"form" | "otp">("form"); + const [pendingData, setPendingData] = useState(null); + // Which contact method the code was sent to — chosen on the form, locked in + // once the challenge is sent. + const [channel, setChannel] = useState("phone"); + const [otpChannel, setOtpChannel] = useState("phone"); + const [sending, setSending] = useState(false); + const [verifying, setVerifying] = useState(false); + const [otpCode, setOtpCode] = useState(""); + const [otpError, setOtpError] = useState(null); + const [resendIn, setResendIn] = useState(0); + + // Resend cooldown countdown (pure setTimeout ticks — no Date.now needed). + useEffect(() => { + if (resendIn <= 0) return; + const t = setTimeout(() => setResendIn((s) => s - 1), 1000); + return () => clearTimeout(t); + }, [resendIn]); const { register, @@ -80,226 +130,329 @@ export default function SignupPage() { }, }); - const onSubmit = async (data: FormData) => { + const passwordValue = watch("password") ?? ""; + + // Step 1 — form is valid: send a fresh code to the chosen channel, then + // move to the OTP challenge. + const requestOtp = async (data: FormData) => { setError(null); - setLoading(true); + setSending(true); try { + await api.auth.sendOTP.call( + channel === "email" ? { email: data.email } : { phone: data.phone }, + ); + setPendingData(data); + setOtpChannel(channel); + setOtpCode(""); + setOtpError(null); + setResendIn(60); + setStage("otp"); + } catch (err) { + setError(extractApiError(err).message); + } finally { + setSending(false); + } + }; + + const resendOtp = async () => { + if (!pendingData) return; + setOtpError(null); + setSending(true); + try { + await api.auth.sendOTP.call( + otpChannel === "email" + ? { email: pendingData.email } + : { phone: pendingData.phone }, + ); + setOtpCode(""); + setResendIn(60); + } catch (err) { + setOtpError(extractApiError(err).message); + } finally { + setSending(false); + } + }; + + // Step 2 — verify the code, then (only on success) create the account. + const confirmOtp = async () => { + if (!pendingData) return; + setOtpError(null); + if (otpCode.trim().length !== 6) { + setOtpError("Enter the 6-digit code we sent you."); + return; + } + setVerifying(true); + try { + await api.auth.verifyOTP.call({ + ...(otpChannel === "email" + ? { email: pendingData.email } + : { phone: pendingData.phone }), + otp: otpCode.trim(), + }); const payload: SignupPayload = { - email: data.email, - username: data.email, + email: pendingData.email, + username: pendingData.email, // Already a canonical E.164 string from the phone field (e.g. +251912345678). - phoneNumber: data.phone, - userType: data.userType, + phoneNumber: pendingData.phone, + userType: pendingData.userType, name: { - en: `${data.firstName.en} ${data.lastName.en}`, - am: `${data.firstName.en} ${data.lastName.en}`, + en: `${pendingData.firstName.en} ${pendingData.lastName.en}`, + am: `${pendingData.firstName.en} ${pendingData.lastName.en}`, }, - password: data.password, - confirmPassword: data.confirmPassword, + password: pendingData.password, + confirmPassword: pendingData.confirmPassword, }; const result = await signup(payload); if (result.success) { navigate("/portal"); } else { - setError(result.error.message); + setOtpError(result.error.message); } - } catch { - setError("An unexpected error occurred"); + } catch (err) { + setOtpError(extractApiError(err).message); } finally { - setLoading(false); + setVerifying(false); } }; - const passwordValue = watch("password") ?? ""; - return ( - +
EDR Freight
-
-

- Create account -

-

- Register to access EDR Freight services. -

-
- -
-
-
- - - {errorText(errors.firstName?.en?.message)} + {stage === "form" ? ( + +
+

+ Create account +

+

+ Register to access EDR Freight services. +

-
- - + + + + + + - {errorText(errors.lastName?.en?.message)} -
-
-
- - - {errorText(errors.email?.message)} -
- -
- - ( -
- field.onChange(v ?? "")} - onBlur={field.onBlur} - /> -
- )} - /> - {errorText(errors.phone?.message)} -
- -
- -
- - -
- {errorText(errors.password?.message)} - {passwordValue.length > 0 ? ( -
- {passwordRequirements.map((req) => { - const met = req.test(passwordValue); - return ( -
- - {met ? : } - - - {req.label} - -
- ); - })} + +
+ + Send verification code via + + setChannel(v as OtpChannel)} + data={[ + { + value: "phone", + label: ( + + Phone + + ), + }, + { + value: "email", + label: ( + + Email + + ), + }, + ]} + />
- ) : null} -
-
- -
- + + {passwordValue.length > 0 ? ( +
+ {passwordRequirements.map((req) => { + const met = req.test(passwordValue); + return ( +
+ + {met ? : } + + + {req.label} + +
+ ); + })} +
+ ) : null} +
+ + - + Continue + + +

+ Already have an account?{" "} + +

+ + + ) : ( + +
+ + +
- {errorText(errors.confirmPassword?.message)} -
- - {error ? ( -
- {error} +
+

+ Verify your {otpChannel === "email" ? "email" : "phone"} +

+

+ We sent a 6-digit code to{" "} + + {otpChannel === "email" + ? maskEmail(pendingData?.email ?? "") + : maskPhone(pendingData?.phone ?? "")} + + . Enter it to finish creating your account. +

- ) : null} - + {otpError ? ( + }> + {otpError} + + ) : null} -

- Already have an account?{" "} - -

-
- + Verify & create account + + +
+ + +
+ + )} +
); } diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx index 29bdef371..f1e5da228 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx @@ -11,17 +11,27 @@ import { Loader, Modal, Paper, + PinInput, Stack, Text, TextInput, } from "@mantine/core"; -import { ArrowLeft, Download, FileSignature, Printer } from "lucide-react"; +import { + ArrowLeft, + Download, + FileSignature, + Printer, + RotateCw, + ShieldCheck, +} from "lucide-react"; import toast from "react-hot-toast"; import { ContractSignSuccessModal } from "@/components/contracts/ContractSignSuccessModal"; import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad"; import { contractsService } from "@/services/contracts.service"; import { api } from "@/services/api"; +import useAuth from "@/hooks/useAuth"; +import { extractApiError } from "@/utils/result"; const CONSENT_TEXT = "I have read the entire contract and agree to its terms."; @@ -34,9 +44,13 @@ export default function ContractViewPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const qc = useQueryClient(); + const { user } = useAuth(); const iframeRef = useRef(null); const [signOpen, setSignOpen] = useState(false); + const [otpOpen, setOtpOpen] = useState(false); + const [otpCode, setOtpCode] = useState(""); + const [otpError, setOtpError] = useState(null); const [successOpen, setSuccessOpen] = useState(false); const [signerName, setSignerName] = useState(""); const [signatureData, setSignatureData] = useState(null); @@ -44,6 +58,15 @@ export default function ContractViewPage() { const [hasScrolledToBottom, setHasScrolledToBottom] = useState(false); const [agreedToTerms, setAgreedToTerms] = useState(false); + // The signed-in customer's registered phone — where the sudo-mode OTP is sent. + const customerPhone = user?.phoneNumber ?? ""; + const maskedPhone = + customerPhone.length > 4 + ? `${customerPhone.slice(0, 4)}${"*".repeat( + Math.max(customerPhone.length - 6, 0), + )}${customerPhone.slice(-2)}` + : customerPhone; + const { data, isLoading, isError, refetch } = useQuery({ queryKey: ["contract-view", id], queryFn: () => contractsService.getContractView(id!), @@ -95,6 +118,18 @@ export default function ContractViewPage() { }; }, [checkScrollBottom]); + // Send (or resend) the fresh OTP challenge to the customer's phone. On success + // we swap the signature modal for the OTP entry modal. + const sendOtpMutation = useMutation({ + mutationFn: () => api.auth.sendOTP.call({ phone: customerPhone }), + onSuccess: () => { + setSignOpen(false); + setOtpError(null); + setOtpOpen(true); + }, + onError: () => toast.error("Failed to send verification code"), + }); + const signMutation = useMutation({ mutationFn: () => contractsService.signContract(id!, { @@ -104,16 +139,22 @@ export default function ContractViewPage() { : (signatureData as string), signerDisplayName: signerName.trim(), consentText: CONSENT_TEXT, + otp: otpCode.trim(), + otpPhone: customerPhone, }), onSuccess: () => { - setSignOpen(false); + setOtpOpen(false); + setOtpCode(""); setSuccessOpen(true); void refetch(); void qc.invalidateQueries({ queryKey: api.contracts.get.queryKey({ id: id! }), }); }, - onError: () => toast.error("Failed to sign contract"), + onError: (err) => + setOtpError( + extractApiError(err).message ?? "Failed to verify code and sign", + ), }); const openSign = () => { @@ -128,6 +169,17 @@ export default function ContractViewPage() { if (!signerName.trim()) return; const image = usingSaved ? savedSignatureImage : signatureData; if (!image) return; + if (!customerPhone) { + toast.error("No phone number on file to verify your signature."); + return; + } + setOtpCode(""); + sendOtpMutation.mutate(); + }; + + const confirmOtp = () => { + if (otpCode.trim().length !== 6) return; + setOtpError(null); signMutation.mutate(); }; @@ -315,20 +367,114 @@ export default function ContractViewPage() { + setOtpOpen(false)} + title="Verify it's you" + centered + radius="lg" + > + + + + + + + For security, enter the 6-digit code we sent by SMS to{" "} + + {maskedPhone} + {" "} + to confirm and apply your signature. + + + + {otpError && ( + + {otpError} + + )} + + + + Verification code + + + + + + + + + + + + + + Date: Fri, 3 Jul 2026 12:22:46 +0000 Subject: [PATCH 7/7] chore: add logger to mark-paid --- .../payment/internal-payment.controller.ts | 32 +++++++++++-------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts b/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts index 57c95eab3..0fc5a6ba5 100644 --- a/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts @@ -1,9 +1,10 @@ import { - Body, - Controller, - HttpCode, - HttpStatus, - Post, + Body, + Controller, + HttpCode, + HttpStatus, + Logger, + Post, } from "@nestjs/common"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { Public } from "@edr/api-common"; @@ -22,14 +23,17 @@ import { PaymentService } from "./payment.service"; @Public() @Controller("internal/payments") export class InternalPaymentController { - constructor(private readonly paymentService: PaymentService) { } + private readonly logger = new Logger(InternalPaymentController.name); + constructor(private readonly paymentService: PaymentService) { } - @Post("mark-paid") - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: "Apply a payment.succeeded / payment.failed event from the payment service (idempotent)", - }) - async markPaid(@Body() event: PaymentEventDto): Promise { - return this.paymentService.handlePaymentEvent(event); - } + @Post("mark-paid") + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: + "Apply a payment.succeeded / payment.failed event from the payment service (idempotent)", + }) + async markPaid(@Body() event: PaymentEventDto): Promise { + this.logger.log(`Marking payment ${event} as PAID`); + return this.paymentService.handlePaymentEvent(event); + } }