mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #419 from Tria-plc/freight/feat/fixes-v1
Freight/feat/fixes v1
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -31,6 +31,7 @@ import { NotificationsModule } from "../notifications/notifications.module";
|
||||
|
||||
exports: [
|
||||
OtpRepository,
|
||||
OtpService,
|
||||
],
|
||||
})
|
||||
export class OtpModule {}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,80 +1,80 @@
|
||||
// otp.service.ts
|
||||
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
} from "@nestjs/common";
|
||||
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
|
||||
|
||||
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 {
|
||||
logger = new Logger(OtpService.name);
|
||||
constructor(
|
||||
private readonly otpRepository: OtpRepository,
|
||||
private readonly smsClient: SmsClientService
|
||||
) {}
|
||||
private readonly smsClient: SmsClientService,
|
||||
private readonly emailClient: EmailClientService,
|
||||
) { }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generate OTP
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
generateOtp(): string {
|
||||
return Math.floor(
|
||||
100000 + Math.random() * 900000
|
||||
).toString();
|
||||
return Math.floor(100000 + Math.random() * 900000).toString();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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) {
|
||||
await this.otpRepository.updateOtp(
|
||||
existingPhone,
|
||||
otp
|
||||
);
|
||||
if (existing) {
|
||||
await this.otpRepository.updateOtp(existing, otp);
|
||||
} else {
|
||||
// create new otp
|
||||
await this.otpRepository.createOtp(
|
||||
phone,
|
||||
otp
|
||||
);
|
||||
await this.otpRepository.createOtp(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}`,
|
||||
});
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,40 +82,70 @@ export class OtpService {
|
||||
// Verify OTP
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async verifyOtp(
|
||||
phone: string,
|
||||
otp: string
|
||||
) {
|
||||
// find phone
|
||||
const otpData =
|
||||
await this.otpRepository.findByPhone(
|
||||
phone
|
||||
);
|
||||
async verifyOtp(target: OtpTarget, otp: string) {
|
||||
// find the channel's row
|
||||
const otpData = 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",
|
||||
);
|
||||
}
|
||||
|
||||
// invalid otp
|
||||
if (otpData.otp !== otp) {
|
||||
throw new BadRequestException(
|
||||
"Invalid OTP"
|
||||
);
|
||||
throw new BadRequestException("Invalid OTP");
|
||||
}
|
||||
|
||||
// verify phone
|
||||
await this.otpRepository.verifyPhone(
|
||||
otpData
|
||||
);
|
||||
// mark verified
|
||||
await this.otpRepository.markVerified(otpData);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
||||
message:
|
||||
"Phone verified successfully",
|
||||
message: 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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<MarkPaidResponseDto> {
|
||||
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<MarkPaidResponseDto> {
|
||||
this.logger.log(`Marking payment ${event} as PAID`);
|
||||
return this.paymentService.handlePaymentEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,44 +40,29 @@ import type { UpdateProfilePayload } from "@/types/profile";
|
||||
import { extractApiError } from "@/utils/result";
|
||||
|
||||
/** Form steps rendered by CompanyProfileForm. */
|
||||
type FormStep =
|
||||
| "company"
|
||||
| "personnel"
|
||||
| "contact"
|
||||
| "verify"
|
||||
| "poa"
|
||||
| "documents"
|
||||
| "additional";
|
||||
type FormStep = "company" | "personnel" | "contact" | "poa" | "documents";
|
||||
const FORM_STEPS: FormStep[] = [
|
||||
"company",
|
||||
"personnel",
|
||||
"contact",
|
||||
"verify",
|
||||
"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: <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.",
|
||||
},
|
||||
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: {
|
||||
icon: <Building2 size={20} />,
|
||||
title: "Company Information",
|
||||
@@ -95,11 +78,6 @@ const STEP_META: Record<
|
||||
title: "Contact Person",
|
||||
description: "Who should we reach out to about this account?",
|
||||
},
|
||||
verify: {
|
||||
icon: <ShieldCheck size={20} />,
|
||||
title: "Verify Contact Person",
|
||||
description: "Confirm the contact phone with a one-time SMS code.",
|
||||
},
|
||||
poa: {
|
||||
icon: <FileText size={20} />,
|
||||
title: "Power of Attorney",
|
||||
@@ -110,11 +88,6 @@ const STEP_META: Record<
|
||||
title: "Upload 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 {
|
||||
@@ -172,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<CompanyNationality | null>(
|
||||
savedNationality,
|
||||
@@ -302,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({
|
||||
@@ -394,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
|
||||
@@ -425,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;
|
||||
|
||||
@@ -497,26 +463,19 @@ export default function OnboardingWizardDialog({
|
||||
<OnboardingCompletePanel onClose={handleClose} />
|
||||
) : (
|
||||
<Stack gap="xl">
|
||||
{phase === "nationality" ? (
|
||||
{phase === "nationality-role" ? (
|
||||
<Stack gap="lg">
|
||||
<Text fw={600} size="lg" c="edr-text">
|
||||
Where is your company registered?
|
||||
</Text>
|
||||
<NationalitySelect
|
||||
value={nationality}
|
||||
onChange={setNationality}
|
||||
embedded
|
||||
/>
|
||||
<Group justify="flex-end" pt="xs">
|
||||
<Button
|
||||
color="edr-green"
|
||||
onClick={handleNationalityContinue}
|
||||
disabled={!nationality}
|
||||
rightSection={<ArrowRight size={16} />}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
) : phase === "role" ? (
|
||||
<Stack gap="lg">
|
||||
<Text fw={600} size="lg" c="edr-text">
|
||||
What does your company do?(multiple)
|
||||
</Text>
|
||||
<OnboardingRoleSelect
|
||||
value={roles}
|
||||
onChange={setRoles}
|
||||
@@ -527,14 +486,7 @@ export default function OnboardingWizardDialog({
|
||||
{startError}
|
||||
</Text>
|
||||
)}
|
||||
<Group justify="space-between" pt="xs">
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
onClick={() => setPhase("nationality")}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Group justify="flex-end" pt="xs">
|
||||
<Button
|
||||
color="edr-green"
|
||||
onClick={handleRolesContinue}
|
||||
@@ -616,7 +568,7 @@ function OnboardingCompletePanel({ onClose }: { onClose: () => void }) {
|
||||
</Stack>
|
||||
|
||||
<Button color="edr-green" size="md" onClick={onClose} mt="xs">
|
||||
Go to my dashboard
|
||||
Continue to Dashboard
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
PinInput,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
@@ -12,15 +11,7 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
RotateCw,
|
||||
Smartphone,
|
||||
UserCheck,
|
||||
} from "lucide-react";
|
||||
import { AlertCircle, ArrowLeft, ArrowRight, UserCheck } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
@@ -35,7 +26,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 +34,6 @@ import {
|
||||
} from "./companyProfileForm/schema";
|
||||
import {
|
||||
buildPayload,
|
||||
maskPhone,
|
||||
samePhone,
|
||||
stepPayload,
|
||||
toFormValues,
|
||||
} from "./companyProfileForm/helpers";
|
||||
@@ -295,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,
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
// The registration/license details come straight from the eTrade lookup and
|
||||
@@ -451,10 +361,8 @@ export default function CompanyProfileForm({
|
||||
"company",
|
||||
"personnel",
|
||||
"contact",
|
||||
"verify",
|
||||
"poa",
|
||||
"documents",
|
||||
"additional",
|
||||
];
|
||||
const currentIdx = stepOrder.indexOf(step);
|
||||
|
||||
@@ -485,30 +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;
|
||||
}
|
||||
// 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.
|
||||
@@ -525,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.
|
||||
@@ -551,10 +442,7 @@ export default function CompanyProfileForm({
|
||||
<form onSubmit={(e) => e.preventDefault()}>
|
||||
<Stack gap="md">
|
||||
{step === "company" && (
|
||||
<>
|
||||
<Text fw={600} size="sm" c="edr-text">
|
||||
Enter your TIN to auto-fill company information from eTrade
|
||||
</Text>
|
||||
<Stack gap="sm">
|
||||
<ETradeInfo
|
||||
tin={watch("tinNumber")}
|
||||
register={register("tinNumber")}
|
||||
@@ -693,7 +581,7 @@ export default function CompanyProfileForm({
|
||||
{...register("houseNo")}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{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" && (
|
||||
<>
|
||||
<Text size="sm" c="edr-muted">
|
||||
@@ -954,15 +741,13 @@ export default function CompanyProfileForm({
|
||||
onChange={setDocumentFiles}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "additional" && (
|
||||
<RoleLicenseStep
|
||||
profiles={roleProfiles ?? []}
|
||||
value={licenseFiles ?? {}}
|
||||
onChange={onLicenseChange ?? (() => { })}
|
||||
/>
|
||||
<RoleLicenseStep
|
||||
profiles={roleProfiles ?? []}
|
||||
value={licenseFiles ?? {}}
|
||||
onChange={onLicenseChange ?? (() => { })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{saveError && (
|
||||
@@ -970,11 +755,7 @@ export default function CompanyProfileForm({
|
||||
color="red"
|
||||
variant="light"
|
||||
icon={<AlertCircle size={18} />}
|
||||
title={
|
||||
step === "additional"
|
||||
? "Business license required"
|
||||
: "Couldn't save this step"
|
||||
}
|
||||
title={"Couldn't save this step"}
|
||||
>
|
||||
{saveError}
|
||||
</Alert>
|
||||
@@ -998,7 +779,7 @@ export default function CompanyProfileForm({
|
||||
onClick={prevStep}
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
>
|
||||
{step === "additional" ? "Back to Documents" : "Back"}
|
||||
Back
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
@@ -1009,17 +790,14 @@ export default function CompanyProfileForm({
|
||||
disabled={
|
||||
isPending ||
|
||||
saving ||
|
||||
(step === "documents" && !hasDocuments && loadingDocuments) ||
|
||||
(step === "verify" && !phoneVerified)
|
||||
(step === "documents" && !hasDocuments && loadingDocuments)
|
||||
}
|
||||
loading={isPending || saving}
|
||||
rightSection={
|
||||
!isPending && !saving && step !== "additional" ? (
|
||||
<ArrowRight size={16} />
|
||||
) : undefined
|
||||
!isPending && !saving ? <ArrowRight size={16} /> : undefined
|
||||
}
|
||||
>
|
||||
{step === "additional" ? "Submit for review" : "Continue"}
|
||||
{step === "documents" ? "Submit for review" : "Continue"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -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<typeof userSchema>;
|
||||
|
||||
const errorText = (msg?: string) =>
|
||||
msg ? <p className="mt-1 text-xs text-red-600">{msg}</p> : 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<string | null>(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<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 {
|
||||
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 (
|
||||
<AuthShell
|
||||
tagline="Smart Freight Operations"
|
||||
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">
|
||||
<img src={EDR_LOGO} alt="EDR Freight" className="h-9 w-auto sm:h-11" />
|
||||
</div>
|
||||
|
||||
<div className="mb-4 space-y-1.5 text-center sm:mb-5">
|
||||
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
||||
Create account
|
||||
</h1>
|
||||
<p className="text-sm leading-relaxed text-gray-500">
|
||||
Register to access EDR Freight services.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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)}
|
||||
{stage === "form" ? (
|
||||
<form onSubmit={handleSubmit(requestOtp)} className="flex w-full flex-col">
|
||||
<div className="mb-4 space-y-1.5 text-center sm:mb-5">
|
||||
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
||||
Create account
|
||||
</h1>
|
||||
<p className="text-sm leading-relaxed text-gray-500">
|
||||
Register to access EDR Freight services.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-gray-800">
|
||||
Last name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
placeholder="Doe"
|
||||
disabled={loading}
|
||||
className={fieldClass}
|
||||
{...register("lastName.en")}
|
||||
|
||||
<Stack gap="md">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<TextInput
|
||||
label="First name"
|
||||
placeholder="John"
|
||||
required
|
||||
disabled={sending}
|
||||
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">
|
||||
<label className="text-sm font-medium text-gray-800">
|
||||
Email <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
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")}
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="phone"
|
||||
label="Phone"
|
||||
required
|
||||
disabled={sending}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((current) => !current)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-gray-600"
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
{errorText(errors.password?.message)}
|
||||
{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 className="space-y-1.5">
|
||||
<Text size="sm" fw={500} c="edr-text">
|
||||
Send verification code via
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
disabled={sending}
|
||||
value={channel}
|
||||
onChange={(v) => setChannel(v as OtpChannel)}
|
||||
data={[
|
||||
{
|
||||
value: "phone",
|
||||
label: (
|
||||
<span className="flex items-center justify-center gap-1.5">
|
||||
<Smartphone size={14} /> Phone
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "email",
|
||||
label: (
|
||||
<span className="flex items-center justify-center gap-1.5">
|
||||
<Mail size={14} /> Email
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-gray-800">
|
||||
Confirm password <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showConfirm ? "text" : "password"}
|
||||
<div>
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
placeholder="Create a strong password"
|
||||
required
|
||||
disabled={sending}
|
||||
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"
|
||||
disabled={loading}
|
||||
className={`${fieldClass} pr-11`}
|
||||
required
|
||||
disabled={sending}
|
||||
error={errors.confirmPassword?.message}
|
||||
{...register("confirmPassword")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirm((current) => !current)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-gray-600"
|
||||
aria-label={showConfirm ? "Hide password" : "Show password"}
|
||||
|
||||
{error ? (
|
||||
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
|
||||
{error}
|
||||
</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" />}
|
||||
</button>
|
||||
Continue
|
||||
</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>
|
||||
{errorText(errors.confirmPassword?.message)}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2.5 text-sm text-red-700">
|
||||
{error}
|
||||
<div className="space-y-1.5 text-center">
|
||||
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
||||
Verify your {otpChannel === "email" ? "email" : "phone"}
|
||||
</h1>
|
||||
<p className="text-sm leading-relaxed text-gray-500">
|
||||
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>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className={`${primaryButtonClass} flex items-center justify-center gap-2`}
|
||||
>
|
||||
{loading ? "Creating account..." : "Create Account"}
|
||||
{!loading ? <ArrowRight className="h-4 w-4" /> : null}
|
||||
</button>
|
||||
{otpError ? (
|
||||
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
|
||||
{otpError}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<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"
|
||||
<Stack gap={6} align="center">
|
||||
<Text size="sm" fw={500} c="edr-text">
|
||||
Verification code
|
||||
</Text>
|
||||
<PinInput
|
||||
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
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
Verify & create account
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ export type CompanyStep =
|
||||
| "company"
|
||||
| "personnel"
|
||||
| "contact"
|
||||
| "verify"
|
||||
| "poa"
|
||||
| "documents"
|
||||
| "additional";
|
||||
@@ -103,7 +102,6 @@ export const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
|
||||
"contactPersonEmail",
|
||||
"contactPersonPhone",
|
||||
],
|
||||
verify: [],
|
||||
poa: [],
|
||||
documents: [],
|
||||
additional: [],
|
||||
|
||||
@@ -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<HTMLIFrameElement>(null);
|
||||
|
||||
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 [signerName, setSignerName] = useState("");
|
||||
const [signatureData, setSignatureData] = useState<string | null>(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() {
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={signMutation.isPending}
|
||||
loading={sendOtpMutation.isPending}
|
||||
disabled={
|
||||
signMutation.isPending ||
|
||||
sendOtpMutation.isPending ||
|
||||
!signerName.trim() ||
|
||||
(!usingSaved && !signatureData)
|
||||
}
|
||||
onClick={confirmSign}
|
||||
>
|
||||
{usingSaved ? "Approve & sign" : "Confirm signature"}
|
||||
Continue to verification
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</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 & sign
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<ContractSignSuccessModal
|
||||
opened={successOpen}
|
||||
reference={data.reference}
|
||||
|
||||
@@ -89,6 +89,10 @@ export interface SignContractPayload {
|
||||
signatureImageBase64: string;
|
||||
signerDisplayName: 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 {
|
||||
|
||||
@@ -33,7 +33,9 @@ export interface SignupResponse {
|
||||
}
|
||||
|
||||
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). */
|
||||
otp?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user