mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 09:35:44 +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
|
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
|
||||||
|
|||||||
@@ -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 { 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',
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { 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 {}
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { NotificationsModule } from "../notifications/notifications.module";
|
|||||||
|
|
||||||
exports: [
|
exports: [
|
||||||
OtpRepository,
|
OtpRepository,
|
||||||
|
OtpService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class OtpModule {}
|
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
|
// 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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 & 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: [],
|
||||||
|
|||||||
@@ -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 & sign
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<ContractSignSuccessModal
|
<ContractSignSuccessModal
|
||||||
opened={successOpen}
|
opened={successOpen}
|
||||||
reference={data.reference}
|
reference={data.reference}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user