Merge pull request #419 from Tria-plc/freight/feat/fixes-v1

Freight/feat/fixes v1
This commit is contained in:
Nathnael Wondisha
2026-07-03 15:23:28 +03:00
committed by GitHub
21 changed files with 891 additions and 599 deletions

View File

@@ -55,8 +55,10 @@ REDIS_HOST=localhost
REDIS_PORT=6379 REDIS_PORT=6379
# --- Notification broker (RabbitMQ) --------------------------------------------- # --- Notification broker (RabbitMQ) ---------------------------------------------
# SMS OTP / notifications are queued to RabbitMQ (consumed by the shared SMS service). # SMS/email OTP + notifications are queued to RabbitMQ (consumed by the shared
# Set RABBITMQ_ENABLED=false to skip the broker entirely (dev without a local broker). # SMS/email services). Set RABBITMQ_ENABLED=false to skip the broker entirely
# (dev without a local broker).
RABBITMQ_ENABLED=false RABBITMQ_ENABLED=false
RABBITMQ_URL=amqp://localhost:5672 RABBITMQ_URL=amqp://localhost:5672
SMS_QUEUE=sms_queue SMS_QUEUE=sms_queue
EMAIL_QUEUE=email_queue

View File

@@ -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<void> {
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<void> {
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
`);
}
}

View File

@@ -19,6 +19,7 @@ import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
import { FilesService } from '../files/files.service'; import { FilesService } from '../files/files.service';
import { SignaturesService } from '../signatures/signatures.service'; import { SignaturesService } from '../signatures/signatures.service';
import { OtpService } from '../otp/otp.service';
import { ContractPricingService } from './contract-pricing.service'; import { ContractPricingService } from './contract-pricing.service';
import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ContractsRepository } from './contracts.repository'; import { ContractsRepository } from './contracts.repository';
@@ -63,6 +64,7 @@ export class ContractTransitionService {
private readonly renderer: ContractRendererService, private readonly renderer: ContractRendererService,
private readonly pdfService: ContractPdfService, private readonly pdfService: ContractPdfService,
private readonly minioService: MinioService, private readonly minioService: MinioService,
private readonly otpService: OtpService,
) {} ) {}
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */ /** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
@@ -520,6 +522,12 @@ export class ContractTransitionService {
if (existing) { if (existing) {
throw new BadRequestException('Customer has already signed this contract'); 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.applySignature(contract, dto, options);
await this.contractsRepository.update(contractId, { await this.contractsRepository.update(contractId, {
status: 'SIGNED_CUSTOMER', status: 'SIGNED_CUSTOMER',

View File

@@ -11,6 +11,7 @@ import { RuleEngineModule } from '../rule-engine/rule-engine.module';
import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module'; import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module';
import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module'; import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module';
import { SignaturesModule } from '../signatures/signatures.module'; import { SignaturesModule } from '../signatures/signatures.module';
import { OtpModule } from '../otp/otp.module';
import { BookingsModule } from '../bookings/bookings.module'; import { BookingsModule } from '../bookings/bookings.module';
import { ContractsController } from './contracts.controller'; import { ContractsController } from './contracts.controller';
@@ -72,6 +73,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
FilesModule, FilesModule,
MinioModule, MinioModule,
SignaturesModule, SignaturesModule,
OtpModule,
CompaniesModule, CompaniesModule,
// BookingsModule provides BookingsRepository/BookingPricingService used by the // BookingsModule provides BookingsRepository/BookingPricingService used by the
// contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3). // contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3).

View File

@@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; 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 { export class SignContractDto {
@ApiProperty({ enum: ['CUSTOMER', 'STAFF', 'DIRECTOR', 'CEO'] }) @ApiProperty({ enum: ['CUSTOMER', 'STAFF', 'DIRECTOR', 'CEO'] })
@@ -26,4 +26,19 @@ export class SignContractDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
consentText?: string; 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;
} }

View File

@@ -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;
}

View File

@@ -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 };
}
}

View File

@@ -4,6 +4,7 @@ import { ClientsModule, Transport } from "@nestjs/microservices";
import { NotificationsService } from "./notifications.service"; import { NotificationsService } from "./notifications.service";
import { SmsClientService } from "./sms-client.service"; import { SmsClientService } from "./sms-client.service";
import { EmailClientService } from "./email-client.service";
import { EmailNotificationStrategy } from "./strategies/notification.email.strategy"; import { EmailNotificationStrategy } from "./strategies/notification.email.strategy";
import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy"; import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy";
@@ -20,10 +21,25 @@ import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy"
queueOptions: { durable: true }, 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: [], controllers: [],
providers: [EmailNotificationStrategy, SmsNotificationStrategy, NotificationsService, SmsClientService], providers: [
exports: [NotificationsService, SmsClientService], EmailNotificationStrategy,
SmsNotificationStrategy,
NotificationsService,
SmsClientService,
EmailClientService,
],
exports: [NotificationsService, SmsClientService, EmailClientService],
}) })
export class NotificationsModule {} export class NotificationsModule {}

View File

@@ -1,15 +1,24 @@
// otp.controller.ts // otp.controller.ts
import { import {
BadRequestException,
Body, Body,
Controller, Controller,
Post, Post,
} from "@nestjs/common"; } from "@nestjs/common";
import { OtpService } from "./otp.service"; import { OtpService, OtpTarget } from "./otp.service";
import { Public } from "@edr/api-common"; 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") @Controller("otp")
@Public() @Public()
export class OtpController { export class OtpController {
@@ -24,9 +33,12 @@ export class OtpController {
@Post("send") @Post("send")
async sendOtp( async sendOtp(
@Body("phone") @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") @Post("verify")
async verifyOtp( async verifyOtp(
@Body("phone") @Body("phone")
phone: string, phone: string | undefined,
@Body("email")
email: string | undefined,
@Body("otp") @Body("otp")
otp: string otp: string
) { ) {
return this.otpService.verifyOtp( return this.otpService.verifyOtp(
phone, toTarget(phone, email),
otp otp
); );
} }

View File

@@ -10,10 +10,19 @@ import { BaseEntity } from "@edr/api-common";
name: "otp_verifications", name: "otp_verifications",
}) })
export class OtpVerification extends BaseEntity{ export class OtpVerification extends BaseEntity{
// Exactly one of phone/email is set per row — the channel the code was sent
// through.
@Column({ @Column({
unique: true, unique: true,
nullable: true,
}) })
phone!: string; phone?: string;
@Column({
unique: true,
nullable: true,
})
email?: string;
@Column() @Column()
otp!: string; otp!: string;

View File

@@ -31,6 +31,7 @@ import { NotificationsModule } from "../notifications/notifications.module";
exports: [ exports: [
OtpRepository, OtpRepository,
OtpService,
], ],
}) })
export class OtpModule {} export class OtpModule {}

View File

@@ -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 // Create OTP
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async createOtp( async createOtp(
phone: string, target: { phone?: string; email?: string },
otp: string otp: string
) { ) {
const entity = const entity =
this.repository.create({ this.repository.create({
phone, phone: target.phone,
email: target.email,
otp, otp,
verified: false, verified: false,
}); });
@@ -70,10 +97,10 @@ export class OtpRepository {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Verify Phone // Mark Verified
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async verifyPhone( async markVerified(
otpVerification: OtpVerification otpVerification: OtpVerification
) { ) {
otpVerification.verified = otpVerification.verified =
@@ -83,4 +110,18 @@ export class OtpRepository {
otpVerification 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
);
}
} }

View File

@@ -1,80 +1,80 @@
// otp.service.ts // otp.service.ts
import { import { BadRequestException, Injectable, Logger } from "@nestjs/common";
BadRequestException,
Injectable,
} from "@nestjs/common";
import { OtpRepository } from "./otp.repository"; import { OtpRepository } from "./otp.repository";
import { SmsClientService } from "../notifications/sms-client.service"; 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() @Injectable()
export class OtpService { export class OtpService {
logger = new Logger(OtpService.name);
constructor( constructor(
private readonly otpRepository: OtpRepository, private readonly otpRepository: OtpRepository,
private readonly smsClient: SmsClientService private readonly smsClient: SmsClientService,
) {} private readonly emailClient: EmailClientService,
) { }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Generate OTP // Generate OTP
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
generateOtp(): string { generateOtp(): string {
return Math.floor( return Math.floor(100000 + Math.random() * 900000).toString();
100000 + Math.random() * 900000
).toString();
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Send OTP // Send OTP
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async sendOtp(phone: string) { async sendOtp(target: OtpTarget) {
try { try {
// The verification code is generated server-side — never supplied by the // 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 // 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(); const otp = this.generateOtp();
// find existing phone // find existing row for this channel
const existingPhone = const existing = await this.otpRepository.findByTarget(target);
await this.otpRepository.findByPhone(
phone
);
// update existing otp // update existing otp
if (existingPhone) { if (existing) {
await this.otpRepository.updateOtp( await this.otpRepository.updateOtp(existing, otp);
existingPhone,
otp
);
} else { } else {
// create new otp // create new otp
await this.otpRepository.createOtp( await this.otpRepository.createOtp(target, otp);
phone,
otp
);
} }
// send sms (queued to RabbitMQ via the shared SMS service) if (target.email) {
await this.smsClient.sendSms({ // send email (queued to RabbitMQ via the shared Email service)
to: phone, await this.emailClient.sendEmail({
message: `Your verification code is ${otp}`, 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}`,
});
}
this.logger.log(`OTP send for ${target.email ?? target.phone}: ${otp}`);
return { return {
success: true, success: true,
message: message: "OTP sent successfully",
"OTP sent successfully",
}; };
} catch (error) { } catch (error) {
console.log(error); console.log(error);
throw new BadRequestException( throw new BadRequestException("Failed to send OTP");
"Failed to send OTP"
);
} }
} }
@@ -82,40 +82,70 @@ export class OtpService {
// Verify OTP // Verify OTP
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async verifyOtp( async verifyOtp(target: OtpTarget, otp: string) {
phone: string, // find the channel's row
otp: string const otpData = await this.otpRepository.findByTarget(target);
) {
// find phone
const otpData =
await this.otpRepository.findByPhone(
phone
);
// phone not found // not found
if (!otpData) { if (!otpData) {
throw new BadRequestException( throw new BadRequestException(
"Phone number not found" target.email ? "Email address not found" : "Phone number not found",
); );
} }
// invalid otp // invalid otp
if (otpData.otp !== otp) { if (otpData.otp !== otp) {
throw new BadRequestException( throw new BadRequestException("Invalid OTP");
"Invalid OTP"
);
} }
// verify phone // mark verified
await this.otpRepository.verifyPhone( await this.otpRepository.markVerified(otpData);
otpData
);
return { return {
success: true, success: true,
message: message: target.email
"Phone verified successfully", ? "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 };
}
} }

View File

@@ -1,9 +1,10 @@
import { import {
Body, Body,
Controller, Controller,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
Post, Logger,
Post,
} from "@nestjs/common"; } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { Public } from "@edr/api-common"; import { Public } from "@edr/api-common";
@@ -22,14 +23,17 @@ import { PaymentService } from "./payment.service";
@Public() @Public()
@Controller("internal/payments") @Controller("internal/payments")
export class InternalPaymentController { export class InternalPaymentController {
constructor(private readonly paymentService: PaymentService) { } private readonly logger = new Logger(InternalPaymentController.name);
constructor(private readonly paymentService: PaymentService) { }
@Post("mark-paid") @Post("mark-paid")
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ @ApiOperation({
summary: "Apply a payment.succeeded / payment.failed event from the payment service (idempotent)", summary:
}) "Apply a payment.succeeded / payment.failed event from the payment service (idempotent)",
async markPaid(@Body() event: PaymentEventDto): Promise<MarkPaidResponseDto> { })
return this.paymentService.handlePaymentEvent(event); async markPaid(@Body() event: PaymentEventDto): Promise<MarkPaidResponseDto> {
} this.logger.log(`Marking payment ${event} as PAID`);
return this.paymentService.handlePaymentEvent(event);
}
} }

View File

@@ -10,10 +10,8 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
ArrowLeft,
ArrowRight, ArrowRight,
Building2, Building2,
CheckCircle2,
Clock, Clock,
FileText, FileText,
Globe2, Globe2,
@@ -42,44 +40,29 @@ import type { UpdateProfilePayload } from "@/types/profile";
import { extractApiError } from "@/utils/result"; import { extractApiError } from "@/utils/result";
/** Form steps rendered by CompanyProfileForm. */ /** Form steps rendered by CompanyProfileForm. */
type FormStep = type FormStep = "company" | "personnel" | "contact" | "poa" | "documents";
| "company"
| "personnel"
| "contact"
| "verify"
| "poa"
| "documents"
| "additional";
const FORM_STEPS: FormStep[] = [ const FORM_STEPS: FormStep[] = [
"company", "company",
"personnel", "personnel",
"contact", "contact",
"verify",
"poa", "poa",
"documents", "documents",
"additional",
]; ];
/** The full onboarding journey: the two pre-form phases + the form steps. */ /** The full onboarding journey: the two pre-form phases + the form steps. */
type WizardStep = "nationality" | "role" | FormStep; type WizardStep = "nationality-role" | FormStep;
const WIZARD_STEPS: WizardStep[] = ["nationality", "role", ...FORM_STEPS]; const WIZARD_STEPS: WizardStep[] = ["nationality-role", ...FORM_STEPS];
/** Icon + title + description shown in the global dialog header per step. */ /** Icon + title + description shown in the global dialog header per step. */
const STEP_META: Record< const STEP_META: Record<
WizardStep, WizardStep,
{ icon: ReactNode; title: string; description: string } { icon: ReactNode; title: string; description: string }
> = { > = {
nationality: { "nationality-role": {
icon: <Globe2 size={20} />, icon: <Globe2 size={20} />,
title: "Where is your company registered?", title: "Tell us about your company",
description: "This determines the documents we'll ask you to provide.", description: "This determines the documents we'll ask you to provide.",
}, },
role: {
icon: <Building2 size={20} />,
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: { company: {
icon: <Building2 size={20} />, icon: <Building2 size={20} />,
title: "Company Information", title: "Company Information",
@@ -95,11 +78,6 @@ const STEP_META: Record<
title: "Contact Person", title: "Contact Person",
description: "Who should we reach out to about this account?", description: "Who should we reach out to about this account?",
}, },
verify: {
icon: <ShieldCheck size={20} />,
title: "Verify Contact Person",
description: "Confirm the contact phone with a one-time SMS code.",
},
poa: { poa: {
icon: <FileText size={20} />, icon: <FileText size={20} />,
title: "Power of Attorney", title: "Power of Attorney",
@@ -110,11 +88,6 @@ const STEP_META: Record<
title: "Upload Documents", title: "Upload Documents",
description: "Provide the required company documents.", description: "Provide the required company documents.",
}, },
additional: {
icon: <CheckCircle2 size={20} />,
title: "Business License",
description: "Upload a business license for each operational profile.",
},
}; };
interface OnboardingWizardDialogProps { interface OnboardingWizardDialogProps {
@@ -172,12 +145,8 @@ export default function OnboardingWizardDialog({
// Phases: nationality → role → form. If a draft already exists, resume // Phases: nationality → role → form. If a draft already exists, resume
// straight into the form with nationality + roles pre-selected. // straight into the form with nationality + roles pre-selected.
const [phase, setPhase] = useState<"nationality" | "role" | "form">( const [phase, setPhase] = useState<"nationality-role" | "form">(
companyAlreadyStarted companyAlreadyStarted ? "form" : "nationality-role",
? hasOperationalProfiles
? "form"
: "role"
: "nationality",
); );
const [nationality, setNationality] = useState<CompanyNationality | null>( const [nationality, setNationality] = useState<CompanyNationality | null>(
savedNationality, savedNationality,
@@ -302,16 +271,12 @@ export default function OnboardingWizardDialog({
setNationality(savedNationality); setNationality(savedNationality);
// Resume into the form only when profiles exist; otherwise send the user to // Resume into the form only when profiles exist; otherwise send the user to
// role selection so the missing operational profiles get created. // role selection so the missing operational profiles get created.
setPhase(hasOperationalProfiles ? "form" : "role"); setPhase(hasOperationalProfiles ? "form" : "nationality-role");
const idx = FORM_STEPS.indexOf(resumeFormStep); const idx = FORM_STEPS.indexOf(resumeFormStep);
if (idx > furthestIdxRef.current) furthestIdxRef.current = idx; if (idx > furthestIdxRef.current) furthestIdxRef.current = idx;
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [companyAlreadyStarted, resumeFormStep]); }, [companyAlreadyStarted, resumeFormStep]);
const handleNationalityContinue = useCallback(() => {
if (nationality) setPhase("role");
}, [nationality]);
const handleRolesContinue = useCallback(() => { const handleRolesContinue = useCallback(() => {
setStartError(null); setStartError(null);
startMutation.mutate({ startMutation.mutate({
@@ -394,6 +359,7 @@ export default function OnboardingWizardDialog({
// The active step across the whole journey, driving the header + progress pill. // The active step across the whole journey, driving the header + progress pill.
const activeStep: WizardStep = phase === "form" ? formStep : phase; const activeStep: WizardStep = phase === "form" ? formStep : phase;
const stepMeta = STEP_META[activeStep]; const stepMeta = STEP_META[activeStep];
console.log({ stepMeta, activeStep, STEP_META });
const activeIdx = WIZARD_STEPS.indexOf(activeStep); const activeIdx = WIZARD_STEPS.indexOf(activeStep);
// Closing from the congratulations panel also clears the completed flag so a // Closing from the congratulations panel also clears the completed flag so a
@@ -425,7 +391,7 @@ export default function OnboardingWizardDialog({
); );
const effectiveResumeStep: FormStep = const effectiveResumeStep: FormStep =
requiredDocsMissing && requiredDocsMissing &&
FORM_STEPS.indexOf(resumeFormStep) > FORM_STEPS.indexOf("documents") FORM_STEPS.indexOf(resumeFormStep) > FORM_STEPS.indexOf("documents")
? "documents" ? "documents"
: resumeFormStep; : resumeFormStep;
@@ -497,26 +463,19 @@ export default function OnboardingWizardDialog({
<OnboardingCompletePanel onClose={handleClose} /> <OnboardingCompletePanel onClose={handleClose} />
) : ( ) : (
<Stack gap="xl"> <Stack gap="xl">
{phase === "nationality" ? ( {phase === "nationality-role" ? (
<Stack gap="lg"> <Stack gap="lg">
<Text fw={600} size="lg" c="edr-text">
Where is your company registered?
</Text>
<NationalitySelect <NationalitySelect
value={nationality} value={nationality}
onChange={setNationality} onChange={setNationality}
embedded embedded
/> />
<Group justify="flex-end" pt="xs"> <Text fw={600} size="lg" c="edr-text">
<Button What does your company do?(multiple)
color="edr-green" </Text>
onClick={handleNationalityContinue}
disabled={!nationality}
rightSection={<ArrowRight size={16} />}
>
Continue
</Button>
</Group>
</Stack>
) : phase === "role" ? (
<Stack gap="lg">
<OnboardingRoleSelect <OnboardingRoleSelect
value={roles} value={roles}
onChange={setRoles} onChange={setRoles}
@@ -527,14 +486,7 @@ export default function OnboardingWizardDialog({
{startError} {startError}
</Text> </Text>
)} )}
<Group justify="space-between" pt="xs"> <Group justify="flex-end" pt="xs">
<Button
variant="default"
leftSection={<ArrowLeft size={16} />}
onClick={() => setPhase("nationality")}
>
Back
</Button>
<Button <Button
color="edr-green" color="edr-green"
onClick={handleRolesContinue} onClick={handleRolesContinue}
@@ -616,7 +568,7 @@ function OnboardingCompletePanel({ onClose }: { onClose: () => void }) {
</Stack> </Stack>
<Button color="edr-green" size="md" onClick={onClose} mt="xs"> <Button color="edr-green" size="md" onClick={onClose} mt="xs">
Go to my dashboard Continue to Dashboard
</Button> </Button>
</Stack> </Stack>
); );

View File

@@ -4,7 +4,6 @@ import {
Divider, Divider,
Group, Group,
Loader, Loader,
PinInput,
SimpleGrid, SimpleGrid,
Stack, Stack,
Text, Text,
@@ -12,15 +11,7 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { import { AlertCircle, ArrowLeft, ArrowRight, UserCheck } from "lucide-react";
AlertCircle,
ArrowLeft,
ArrowRight,
CheckCircle2,
RotateCw,
Smartphone,
UserCheck,
} from "lucide-react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -35,7 +26,6 @@ import RoleLicenseStep, {
type RoleLicenseProfile, type RoleLicenseProfile,
} from "@/components/onboarding/RoleLicenseStep"; } from "@/components/onboarding/RoleLicenseStep";
import ETradeInfo from "@/components/onboarding/ETradeInfo"; import ETradeInfo from "@/components/onboarding/ETradeInfo";
import { extractApiError } from "@/utils/result";
import { import {
type CompanyStep, type CompanyStep,
type FormData, type FormData,
@@ -44,8 +34,6 @@ import {
} from "./companyProfileForm/schema"; } from "./companyProfileForm/schema";
import { import {
buildPayload, buildPayload,
maskPhone,
samePhone,
stepPayload, stepPayload,
toFormValues, toFormValues,
} from "./companyProfileForm/helpers"; } from "./companyProfileForm/helpers";
@@ -295,6 +283,7 @@ export default function CompanyProfileForm({
const useOwnerAsManager = () => { const useOwnerAsManager = () => {
if (!etradeOwner) return; if (!etradeOwner) return;
setValue("generalManagerName", etradeOwner.name); setValue("generalManagerName", etradeOwner.name);
setValue("generalManagerEmail", user.email);
setValue("generalManagerPhone", etradeOwner.phone ?? "", { setValue("generalManagerPhone", etradeOwner.phone ?? "", {
shouldValidate: true, shouldValidate: true,
}); });
@@ -350,85 +339,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<string | null>(
rehydrate?.contactVerifiedPhone ?? null,
);
useEffect(() => {
if (rehydrate?.contactVerifiedPhone) {
setVerifiedPhone(rehydrate.contactVerifiedPhone);
}
}, [rehydrate?.contactVerifiedPhone]);
const phoneVerified = samePhone(verifiedPhone, contactPhoneE164);
const [otpSent, setOtpSent] = useState(false);
const [otpCode, setOtpCode] = useState("");
const [sendingOtp, setSendingOtp] = useState(false);
const [verifyingOtp, setVerifyingOtp] = useState(false);
const [otpError, setOtpError] = useState<string | null>(null);
const [resendIn, setResendIn] = useState(0);
// Resend cooldown countdown (no Date.now needed — pure setTimeout ticks).
useEffect(() => {
if (resendIn <= 0) return;
const t = setTimeout(() => setResendIn((s) => s - 1), 1000);
return () => clearTimeout(t);
}, [resendIn]);
// A changed contact phone invalidates any in-flight code entry (the previous
// code was for a different number). Verified state is handled separately via
// the phone comparison, so this only resets the send/enter UI.
useEffect(() => {
setOtpSent(false);
setOtpCode("");
setOtpError(null);
}, [contactPhoneE164]);
const sendContactOtp = async () => {
setOtpError(null);
if (!contactPhoneE164) {
setOtpError("Enter a valid contact phone number first.");
return;
}
setSendingOtp(true);
try {
await api.auth.sendOTP.call({ phone: contactPhoneE164 });
setOtpSent(true);
setOtpCode("");
setResendIn(60);
} catch (err) {
setOtpError(extractApiError(err).message);
} finally {
setSendingOtp(false);
}
};
const verifyContactOtp = async () => {
setOtpError(null);
if (otpCode.length !== 6) {
setOtpError("Enter the 6-digit code we sent you.");
return;
}
setVerifyingOtp(true);
try {
await api.auth.verifyOTP.call({ phone: contactPhoneE164, otp: otpCode });
setVerifiedPhone(contactPhoneE164);
setOtpSent(false);
// Persist the verified phone so the step resumes as "done" after a refresh
// (best-effort — the OTP itself already succeeded server-side).
onSaveStep?.({ contactVerifiedPhone: contactPhoneE164 }).catch(() => { });
} catch (err) {
setOtpError(extractApiError(err).message);
} finally {
setVerifyingOtp(false);
}
};
const hasDocuments = Boolean(uploadSetting?.fields?.length); const hasDocuments = Boolean(uploadSetting?.fields?.length);
// The registration/license details come straight from the eTrade lookup and // The registration/license details come straight from the eTrade lookup and
@@ -451,10 +361,8 @@ export default function CompanyProfileForm({
"company", "company",
"personnel", "personnel",
"contact", "contact",
"verify",
"poa", "poa",
"documents", "documents",
"additional",
]; ];
const currentIdx = stepOrder.indexOf(step); const currentIdx = stepOrder.indexOf(step);
@@ -485,30 +393,6 @@ export default function CompanyProfileForm({
const nextStep = async () => { const nextStep = async () => {
userNavigatedRef.current = true; 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;
}
// 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 // The documents step auto-uploads whatever the user selected as they
// continue (partial uploads are allowed — required-doc completeness is // continue (partial uploads are allowed — required-doc completeness is
// re-checked on resume). A failed upload holds them on the step. // re-checked on resume). A failed upload holds them on the step.
@@ -525,8 +409,15 @@ export default function CompanyProfileForm({
setSaving(false); setSaving(false);
} }
} }
if (!licenseComplete) {
setSaveError(
"Please upload a business license for each of your operational profiles.",
);
return;
}
setSaveError(null); setSaveError(null);
setStep(stepOrder[currentIdx + 1]); handleSubmit((data) => onSubmit(buildPayload(data, user)))();
return; return;
} }
// Field steps validate + save before advancing. // Field steps validate + save before advancing.
@@ -551,10 +442,7 @@ export default function CompanyProfileForm({
<form onSubmit={(e) => e.preventDefault()}> <form onSubmit={(e) => e.preventDefault()}>
<Stack gap="md"> <Stack gap="md">
{step === "company" && ( {step === "company" && (
<> <Stack gap="sm">
<Text fw={600} size="sm" c="edr-text">
Enter your TIN to auto-fill company information from eTrade
</Text>
<ETradeInfo <ETradeInfo
tin={watch("tinNumber")} tin={watch("tinNumber")}
register={register("tinNumber")} register={register("tinNumber")}
@@ -693,7 +581,7 @@ export default function CompanyProfileForm({
{...register("houseNo")} {...register("houseNo")}
/> />
</SimpleGrid> </SimpleGrid>
</> </Stack>
)} )}
{step === "personnel" && ( {step === "personnel" && (
@@ -783,107 +671,6 @@ export default function CompanyProfileForm({
</> </>
)} )}
{step === "verify" && (
<Stack gap="md">
<Text size="sm" c="edr-muted">
We'll text a one-time code to the contact person's phone to
confirm it's reachable. This is required before you continue.
</Text>
{!contactPhoneE164 ? (
<Alert
color="yellow"
variant="light"
icon={<AlertCircle size={18} />}
>
Add a valid contact phone number on the previous step first.
</Alert>
) : phoneVerified ? (
<Alert
color="edr-green"
variant="light"
icon={<CheckCircle2 size={18} />}
title="Phone verified"
>
{maskPhone(contactPhoneE164)} has been verified.
</Alert>
) : (
<Stack gap="sm">
<Group gap="xs" align="center">
<Smartphone
size={16}
className="text-[var(--mantine-color-edr-muted)]"
/>
<Text size="sm" c="edr-text">
{maskPhone(contactPhoneE164)}
</Text>
</Group>
{!otpSent ? (
<Button
color="edr-green"
variant="light"
onClick={sendContactOtp}
loading={sendingOtp}
leftSection={<Smartphone size={16} />}
style={{ alignSelf: "flex-start" }}
>
Send code via SMS
</Button>
) : (
<Stack gap="sm">
<PinInput
length={6}
type="number"
oneTimeCode
value={otpCode}
placeholder="0"
styles={{
input: {
textAlign: "center",
},
}}
onChange={setOtpCode}
/>
<Group gap="sm">
<Button
color="edr-green"
onClick={verifyContactOtp}
loading={verifyingOtp}
disabled={otpCode.length !== 6}
>
Verify
</Button>
<Button
variant="subtle"
color="edr-green"
onClick={sendContactOtp}
loading={sendingOtp}
disabled={resendIn > 0 || sendingOtp}
leftSection={<RotateCw size={14} />}
>
{resendIn > 0
? `Resend in ${resendIn}s`
: "Resend code"}
</Button>
</Group>
</Stack>
)}
{otpError && (
<Alert
color="red"
variant="light"
icon={<AlertCircle size={18} />}
>
{otpError}
</Alert>
)}
</Stack>
)}
</Stack>
)}
{step === "poa" && ( {step === "poa" && (
<> <>
<Text size="sm" c="edr-muted"> <Text size="sm" c="edr-muted">
@@ -954,15 +741,13 @@ export default function CompanyProfileForm({
onChange={setDocumentFiles} onChange={setDocumentFiles}
/> />
)} )}
</>
)}
{step === "additional" && ( <RoleLicenseStep
<RoleLicenseStep profiles={roleProfiles ?? []}
profiles={roleProfiles ?? []} value={licenseFiles ?? {}}
value={licenseFiles ?? {}} onChange={onLicenseChange ?? (() => { })}
onChange={onLicenseChange ?? (() => { })} />
/> </>
)} )}
{saveError && ( {saveError && (
@@ -970,11 +755,7 @@ export default function CompanyProfileForm({
color="red" color="red"
variant="light" variant="light"
icon={<AlertCircle size={18} />} icon={<AlertCircle size={18} />}
title={ title={"Couldn't save this step"}
step === "additional"
? "Business license required"
: "Couldn't save this step"
}
> >
{saveError} {saveError}
</Alert> </Alert>
@@ -998,7 +779,7 @@ export default function CompanyProfileForm({
onClick={prevStep} onClick={prevStep}
leftSection={<ArrowLeft size={16} />} leftSection={<ArrowLeft size={16} />}
> >
{step === "additional" ? "Back to Documents" : "Back"} Back
</Button> </Button>
) : ( ) : (
<span /> <span />
@@ -1009,17 +790,14 @@ export default function CompanyProfileForm({
disabled={ disabled={
isPending || isPending ||
saving || saving ||
(step === "documents" && !hasDocuments && loadingDocuments) || (step === "documents" && !hasDocuments && loadingDocuments)
(step === "verify" && !phoneVerified)
} }
loading={isPending || saving} loading={isPending || saving}
rightSection={ rightSection={
!isPending && !saving && step !== "additional" ? ( !isPending && !saving ? <ArrowRight size={16} /> : undefined
<ArrowRight size={16} />
) : undefined
} }
> >
{step === "additional" ? "Submit for review" : "Continue"} {step === "documents" ? "Submit for review" : "Continue"}
</Button> </Button>
</Group> </Group>
</Stack> </Stack>

View File

@@ -1,18 +1,38 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowRight, Check, Eye, EyeOff, X } from "lucide-react"; import {
import { Controller, useForm } from "react-hook-form"; 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 { useNavigate } from "react-router-dom";
import { z } from "zod"; import { z } from "zod";
import RPNInput from "react-phone-number-input";
import "react-phone-number-input/style.css";
import { userType } from "@/enums/userType"; import { userType } from "@/enums/userType";
import useAuth from "@/hooks/useAuth"; import useAuth from "@/hooks/useAuth";
import type { SignupPayload } from "@/types/auth"; import type { SignupPayload } from "@/types/auth";
import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell"; import AuthShell from "@/components/auth/AuthShell";
import { isValidPhone } from "@/components/PhoneField"; import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import "@/components/phone-field.css"; import { api } from "@/services/api";
import { extractApiError } from "@/utils/result";
const EDR_LOGO = "/assets/edr-logo.png"; const EDR_LOGO = "/assets/edr-logo.png";
@@ -50,16 +70,46 @@ const userSchema = z
type FormData = z.infer<typeof userSchema>; type FormData = z.infer<typeof userSchema>;
const errorText = (msg?: string) => /** Mask all but the first 7 chars of an E.164 phone for display. */
msg ? <p className="mt-1 text-xs text-red-600">{msg}</p> : null; 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() { export default function SignupPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { signup } = useAuth(); const { signup } = useAuth();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false); // Two-stage signup: fill the form, then a mandatory SMS OTP challenge on the
const [showConfirm, setShowConfirm] = useState(false); // 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<FormData | null>(null);
// Which contact method the code was sent to — chosen on the form, locked in
// once the challenge is sent.
const [channel, setChannel] = useState<OtpChannel>("phone");
const [otpChannel, setOtpChannel] = useState<OtpChannel>("phone");
const [sending, setSending] = useState(false);
const [verifying, setVerifying] = useState(false);
const [otpCode, setOtpCode] = useState("");
const [otpError, setOtpError] = useState<string | null>(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 { const {
register, 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); setError(null);
setLoading(true); setSending(true);
try { 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 = { const payload: SignupPayload = {
email: data.email, email: pendingData.email,
username: data.email, username: pendingData.email,
// Already a canonical E.164 string from the phone field (e.g. +251912345678). // Already a canonical E.164 string from the phone field (e.g. +251912345678).
phoneNumber: data.phone, phoneNumber: pendingData.phone,
userType: data.userType, userType: pendingData.userType,
name: { name: {
en: `${data.firstName.en} ${data.lastName.en}`, en: `${pendingData.firstName.en} ${pendingData.lastName.en}`,
am: `${data.firstName.en} ${data.lastName.en}`, am: `${pendingData.firstName.en} ${pendingData.lastName.en}`,
}, },
password: data.password, password: pendingData.password,
confirmPassword: data.confirmPassword, confirmPassword: pendingData.confirmPassword,
}; };
const result = await signup(payload); const result = await signup(payload);
if (result.success) { if (result.success) {
navigate("/portal"); navigate("/portal");
} else { } else {
setError(result.error.message); setOtpError(result.error.message);
} }
} catch { } catch (err) {
setError("An unexpected error occurred"); setOtpError(extractApiError(err).message);
} finally { } finally {
setLoading(false); setVerifying(false);
} }
}; };
const passwordValue = watch("password") ?? "";
return ( return (
<AuthShell <AuthShell
tagline="Smart Freight Operations" tagline="Smart Freight Operations"
taglineBody="Join EDR Freight to manage shipments, track consignments, and streamline logistics workflows across Ethiopia and Djibouti." taglineBody="Join EDR Freight to manage shipments, track consignments, and streamline logistics workflows across Ethiopia and Djibouti."
> >
<form className="flex w-full flex-col" onSubmit={handleSubmit(onSubmit)}> <div className="flex w-full flex-col">
<div className="mb-4 flex justify-center sm:mb-6"> <div className="mb-4 flex justify-center sm:mb-6">
<img src={EDR_LOGO} alt="EDR Freight" className="h-9 w-auto sm:h-11" /> <img src={EDR_LOGO} alt="EDR Freight" className="h-9 w-auto sm:h-11" />
</div> </div>
<div className="mb-4 space-y-1.5 text-center sm:mb-5"> {stage === "form" ? (
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl"> <form onSubmit={handleSubmit(requestOtp)} className="flex w-full flex-col">
Create account <div className="mb-4 space-y-1.5 text-center sm:mb-5">
</h1> <h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
<p className="text-sm leading-relaxed text-gray-500"> Create account
Register to access EDR Freight services. </h1>
</p> <p className="text-sm leading-relaxed text-gray-500">
</div> Register to access EDR Freight services.
</p>
<div className="flex w-full flex-col gap-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-800">
First name <span className="text-red-500">*</span>
</label>
<input
placeholder="John"
disabled={loading}
className={fieldClass}
{...register("firstName.en")}
/>
{errorText(errors.firstName?.en?.message)}
</div> </div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-800"> <Stack gap="md">
Last name <span className="text-red-500">*</span> <SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
</label> <TextInput
<input label="First name"
placeholder="Doe" placeholder="John"
disabled={loading} required
className={fieldClass} disabled={sending}
{...register("lastName.en")} error={errors.firstName?.en?.message}
{...register("firstName.en")}
/>
<TextInput
label="Last name"
placeholder="Doe"
required
disabled={sending}
error={errors.lastName?.en?.message}
{...register("lastName.en")}
/>
</SimpleGrid>
<TextInput
label="Email"
type="email"
placeholder="john@example.com"
required
disabled={sending}
error={errors.email?.message}
{...register("email")}
/> />
{errorText(errors.lastName?.en?.message)}
</div>
</div>
<div className="space-y-1.5"> <ControlledPhoneField
<label className="text-sm font-medium text-gray-800"> control={control}
Email <span className="text-red-500">*</span> name="phone"
</label> label="Phone"
<input required
type="email" disabled={sending}
placeholder="john@example.com"
disabled={loading}
className={fieldClass}
{...register("email")}
/>
{errorText(errors.email?.message)}
</div>
<div className="space-y-1.5">
<label htmlFor="signup-phone" className="text-sm font-medium text-gray-800">
Phone <span className="text-red-500">*</span>
</label>
<Controller
control={control}
name="phone"
render={({ field }) => (
<div
className={`edr-phone-wrapper${
errors.phone ? " edr-phone-wrapper--error" : ""
}`}
>
<RPNInput
international
defaultCountry="ET"
countryCallingCodeEditable={false}
addInternationalOption
id="signup-phone"
placeholder="912 345 678"
disabled={loading}
value={field.value || undefined}
onChange={(v) => field.onChange(v ?? "")}
onBlur={field.onBlur}
/>
</div>
)}
/>
{errorText(errors.phone?.message)}
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-800">
Password <span className="text-red-500">*</span>
</label>
<div className="relative">
<input
type={showPassword ? "text" : "password"}
placeholder="Create a strong password"
disabled={loading}
className={`${fieldClass} pr-11`}
{...register("password")}
/> />
<button
type="button" <div className="space-y-1.5">
onClick={() => setShowPassword((current) => !current)} <Text size="sm" fw={500} c="edr-text">
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-gray-600" Send verification code via
aria-label={showPassword ? "Hide password" : "Show password"} </Text>
> <SegmentedControl
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />} fullWidth
</button> disabled={sending}
</div> value={channel}
{errorText(errors.password?.message)} onChange={(v) => setChannel(v as OtpChannel)}
{passwordValue.length > 0 ? ( data={[
<div className="mt-2 space-y-1"> {
{passwordRequirements.map((req) => { value: "phone",
const met = req.test(passwordValue); label: (
return ( <span className="flex items-center justify-center gap-1.5">
<div key={req.label} className="flex items-center gap-2"> <Smartphone size={14} /> Phone
<span </span>
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full ${ ),
met ? "bg-primary text-primary-foreground" : "bg-gray-200 text-gray-500" },
}`} {
> value: "email",
{met ? <Check className="h-2.5 w-2.5" /> : <X className="h-2.5 w-2.5" />} label: (
</span> <span className="flex items-center justify-center gap-1.5">
<span className={`text-xs ${met ? "text-primary" : "text-gray-500"}`}> <Mail size={14} /> Email
{req.label} </span>
</span> ),
</div> },
); ]}
})} />
</div> </div>
) : null}
</div>
<div className="space-y-1.5"> <div>
<label className="text-sm font-medium text-gray-800"> <PasswordInput
Confirm password <span className="text-red-500">*</span> label="Password"
</label> placeholder="Create a strong password"
<div className="relative"> required
<input disabled={sending}
type={showConfirm ? "text" : "password"} error={errors.password?.message}
{...register("password")}
/>
{passwordValue.length > 0 ? (
<div className="mt-2 space-y-1">
{passwordRequirements.map((req) => {
const met = req.test(passwordValue);
return (
<div key={req.label} className="flex items-center gap-2">
<span
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full ${
met ? "bg-primary text-primary-foreground" : "bg-gray-200 text-gray-500"
}`}
>
{met ? <Check className="h-2.5 w-2.5" /> : <X className="h-2.5 w-2.5" />}
</span>
<span className={`text-xs ${met ? "text-primary" : "text-gray-500"}`}>
{req.label}
</span>
</div>
);
})}
</div>
) : null}
</div>
<PasswordInput
label="Confirm password"
placeholder="Re-enter your password" placeholder="Re-enter your password"
disabled={loading} required
className={`${fieldClass} pr-11`} disabled={sending}
error={errors.confirmPassword?.message}
{...register("confirmPassword")} {...register("confirmPassword")}
/> />
<button
type="button" {error ? (
onClick={() => setShowConfirm((current) => !current)} <Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-gray-600" {error}
aria-label={showConfirm ? "Hide password" : "Show password"} </Alert>
) : null}
<Button
type="submit"
color="edr-green"
fullWidth
loading={sending}
rightSection={!sending ? <ArrowRight size={16} /> : undefined}
> >
{showConfirm ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />} Continue
</button> </Button>
<p className="text-center text-sm text-gray-500">
Already have an account?{" "}
<button
type="button"
onClick={() => navigate("/login")}
className="font-semibold text-primary hover:underline"
>
Sign In
</button>
</p>
</Stack>
</form>
) : (
<Stack gap="md">
<div className="mb-1 flex justify-center">
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
<ShieldCheck size={22} />
</span>
</div> </div>
{errorText(errors.confirmPassword?.message)} <div className="space-y-1.5 text-center">
</div> <h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
Verify your {otpChannel === "email" ? "email" : "phone"}
{error ? ( </h1>
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2.5 text-sm text-red-700"> <p className="text-sm leading-relaxed text-gray-500">
{error} We sent a 6-digit code to{" "}
<span className="font-medium text-gray-700">
{otpChannel === "email"
? maskEmail(pendingData?.email ?? "")
: maskPhone(pendingData?.phone ?? "")}
</span>
. Enter it to finish creating your account.
</p>
</div> </div>
) : null}
<button {otpError ? (
type="submit" <Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
disabled={loading} {otpError}
className={`${primaryButtonClass} flex items-center justify-center gap-2`} </Alert>
> ) : null}
{loading ? "Creating account..." : "Create Account"}
{!loading ? <ArrowRight className="h-4 w-4" /> : null}
</button>
<p className="text-center text-sm text-gray-500"> <Stack gap={6} align="center">
Already have an account?{" "} <Text size="sm" fw={500} c="edr-text">
<button Verification code
type="button" </Text>
onClick={() => navigate("/login")} <PinInput
className="font-semibold text-primary hover:underline" length={6}
type="number"
oneTimeCode
value={otpCode}
placeholder="0"
disabled={verifying}
styles={{ input: { textAlign: "center" } }}
onChange={setOtpCode}
/>
</Stack>
<Button
color="edr-green"
fullWidth
loading={verifying}
disabled={verifying || otpCode.trim().length !== 6}
onClick={confirmOtp}
> >
Sign In Verify &amp; create account
</button> </Button>
</p>
</div> <div className="flex items-center justify-between">
</form> <Button
variant="subtle"
color="gray"
leftSection={<ArrowLeft size={14} />}
disabled={sending || verifying}
onClick={() => {
setStage("form");
setOtpError(null);
}}
>
Back
</Button>
<Button
variant="subtle"
color="edr-green"
leftSection={<RotateCw size={14} />}
disabled={resendIn > 0 || sending || verifying}
onClick={resendOtp}
>
{resendIn > 0 ? `Resend in ${resendIn}s` : "Resend code"}
</Button>
</div>
</Stack>
)}
</div>
</AuthShell> </AuthShell>
); );
} }

View File

@@ -6,7 +6,6 @@ export type CompanyStep =
| "company" | "company"
| "personnel" | "personnel"
| "contact" | "contact"
| "verify"
| "poa" | "poa"
| "documents" | "documents"
| "additional"; | "additional";
@@ -103,7 +102,6 @@ export const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
"contactPersonEmail", "contactPersonEmail",
"contactPersonPhone", "contactPersonPhone",
], ],
verify: [],
poa: [], poa: [],
documents: [], documents: [],
additional: [], additional: [],

View File

@@ -11,17 +11,27 @@ import {
Loader, Loader,
Modal, Modal,
Paper, Paper,
PinInput,
Stack, Stack,
Text, Text,
TextInput, TextInput,
} from "@mantine/core"; } 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 toast from "react-hot-toast";
import { ContractSignSuccessModal } from "@/components/contracts/ContractSignSuccessModal"; import { ContractSignSuccessModal } from "@/components/contracts/ContractSignSuccessModal";
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad"; import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
import { contractsService } from "@/services/contracts.service"; import { contractsService } from "@/services/contracts.service";
import { api } from "@/services/api"; import { api } from "@/services/api";
import useAuth from "@/hooks/useAuth";
import { extractApiError } from "@/utils/result";
const CONSENT_TEXT = const CONSENT_TEXT =
"I have read the entire contract and agree to its terms."; "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 { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const qc = useQueryClient(); const qc = useQueryClient();
const { user } = useAuth();
const iframeRef = useRef<HTMLIFrameElement>(null); const iframeRef = useRef<HTMLIFrameElement>(null);
const [signOpen, setSignOpen] = useState(false); const [signOpen, setSignOpen] = useState(false);
const [otpOpen, setOtpOpen] = useState(false);
const [otpCode, setOtpCode] = useState("");
const [otpError, setOtpError] = useState<string | null>(null);
const [successOpen, setSuccessOpen] = useState(false); const [successOpen, setSuccessOpen] = useState(false);
const [signerName, setSignerName] = useState(""); const [signerName, setSignerName] = useState("");
const [signatureData, setSignatureData] = useState<string | null>(null); const [signatureData, setSignatureData] = useState<string | null>(null);
@@ -44,6 +58,15 @@ export default function ContractViewPage() {
const [hasScrolledToBottom, setHasScrolledToBottom] = useState(false); const [hasScrolledToBottom, setHasScrolledToBottom] = useState(false);
const [agreedToTerms, setAgreedToTerms] = 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({ const { data, isLoading, isError, refetch } = useQuery({
queryKey: ["contract-view", id], queryKey: ["contract-view", id],
queryFn: () => contractsService.getContractView(id!), queryFn: () => contractsService.getContractView(id!),
@@ -95,6 +118,18 @@ export default function ContractViewPage() {
}; };
}, [checkScrollBottom]); }, [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({ const signMutation = useMutation({
mutationFn: () => mutationFn: () =>
contractsService.signContract(id!, { contractsService.signContract(id!, {
@@ -104,16 +139,22 @@ export default function ContractViewPage() {
: (signatureData as string), : (signatureData as string),
signerDisplayName: signerName.trim(), signerDisplayName: signerName.trim(),
consentText: CONSENT_TEXT, consentText: CONSENT_TEXT,
otp: otpCode.trim(),
otpPhone: customerPhone,
}), }),
onSuccess: () => { onSuccess: () => {
setSignOpen(false); setOtpOpen(false);
setOtpCode("");
setSuccessOpen(true); setSuccessOpen(true);
void refetch(); void refetch();
void qc.invalidateQueries({ void qc.invalidateQueries({
queryKey: api.contracts.get.queryKey({ id: id! }), 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 = () => { const openSign = () => {
@@ -128,6 +169,17 @@ export default function ContractViewPage() {
if (!signerName.trim()) return; if (!signerName.trim()) return;
const image = usingSaved ? savedSignatureImage : signatureData; const image = usingSaved ? savedSignatureImage : signatureData;
if (!image) return; 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(); signMutation.mutate();
}; };
@@ -315,20 +367,114 @@ export default function ContractViewPage() {
</Button> </Button>
<Button <Button
color="edr-green" color="edr-green"
loading={signMutation.isPending} loading={sendOtpMutation.isPending}
disabled={ disabled={
signMutation.isPending || sendOtpMutation.isPending ||
!signerName.trim() || !signerName.trim() ||
(!usingSaved && !signatureData) (!usingSaved && !signatureData)
} }
onClick={confirmSign} onClick={confirmSign}
> >
{usingSaved ? "Approve & sign" : "Confirm signature"} Continue to verification
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
</Modal> </Modal>
<Modal
opened={otpOpen}
onClose={() => setOtpOpen(false)}
title="Verify it's you"
centered
radius="lg"
>
<Stack gap="md">
<Group gap="sm" wrap="nowrap">
<Box
w={40}
h={40}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 12,
background: "var(--mantine-color-edr-green-0)",
flexShrink: 0,
}}
>
<ShieldCheck
size={20}
color="var(--mantine-color-edr-green-6)"
/>
</Box>
<Text size="sm" c="dimmed">
For security, enter the 6-digit code we sent by SMS to{" "}
<Text span fw={600} c="edr-text">
{maskedPhone}
</Text>{" "}
to confirm and apply your signature.
</Text>
</Group>
{otpError && (
<Alert color="red" variant="light" radius="md">
{otpError}
</Alert>
)}
<Stack gap={6}>
<Text size="sm" fw={500} c="edr-text">
Verification code
</Text>
<PinInput
length={6}
type="number"
oneTimeCode
value={otpCode}
placeholder="0"
disabled={signMutation.isPending}
styles={{ input: { textAlign: "center" } }}
onChange={setOtpCode}
/>
</Stack>
<Group justify="space-between" gap="sm">
<Button
variant="subtle"
color="gray"
size="compact-sm"
leftSection={<RotateCw size={14} />}
loading={sendOtpMutation.isPending}
disabled={sendOtpMutation.isPending || signMutation.isPending}
onClick={() => {
setOtpError(null);
sendOtpMutation.mutate();
}}
>
Resend code
</Button>
<Group gap="sm">
<Button
variant="default"
onClick={() => setOtpOpen(false)}
disabled={signMutation.isPending}
>
Cancel
</Button>
<Button
color="edr-green"
leftSection={<FileSignature size={16} />}
loading={signMutation.isPending}
disabled={signMutation.isPending || otpCode.trim().length !== 6}
onClick={confirmOtp}
>
Verify &amp; sign
</Button>
</Group>
</Group>
</Stack>
</Modal>
<ContractSignSuccessModal <ContractSignSuccessModal
opened={successOpen} opened={successOpen}
reference={data.reference} reference={data.reference}

View File

@@ -89,6 +89,10 @@ export interface SignContractPayload {
signatureImageBase64: string; signatureImageBase64: string;
signerDisplayName: string; signerDisplayName: string;
consentText?: string; consentText?: string;
/** Sudo-mode OTP challenge; required when role=CUSTOMER. */
otp?: string;
/** Phone the OTP was sent to; required when role=CUSTOMER. */
otpPhone?: string;
} }
export interface ApproveDeliveryResponse { export interface ApproveDeliveryResponse {

View File

@@ -33,7 +33,9 @@ export interface SignupResponse {
} }
export interface OtpPayload { export interface OtpPayload {
phone: string; /** Exactly one of phone/email — the channel the code is sent through. */
phone?: string;
email?: string;
/** Required on verify; omitted on send (the server generates the code). */ /** Required on verify; omitted on send (the server generates the code). */
otp?: string; otp?: string;
} }