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 + + + + + + + + + + + + + +