From aa656eed7ed3934b8e31317f5e0c3462b197fac9 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 9 Jul 2026 06:40:45 +0000 Subject: [PATCH 01/11] fix: profile id and licence to registration --- .../companies/company-profile.repository.ts | 22 ++++- .../entities/company-profile.entity.ts | 2 +- .../src/seed/file-upload-settings.seeder.ts | 83 ++++++++++--------- .../pages/bookings/resubmit/resubmitDocs.ts | 2 +- .../src/pages/settings/NationalitySelect.tsx | 2 +- 5 files changed, 68 insertions(+), 43 deletions(-) diff --git a/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts b/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts index 15aec5ac6..9bf14cd61 100644 --- a/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts @@ -1,4 +1,4 @@ -import { Injectable } from "@nestjs/common"; +import { Injectable, InternalServerErrorException } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; import { Repository } from "typeorm"; import { BaseRepository } from "@edr/api-common"; @@ -20,6 +20,11 @@ const PREFIX_MAP: Record = { [ProfileType.transporter]: "TR", }; +const SERIES_LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + +/** Numbers per series letter: A00001..A99999, then B00001. */ +const SERIES_SIZE = 99_999; + @Injectable() export class CompanyProfileRepository extends BaseRepository { constructor( @@ -38,9 +43,20 @@ export class CompanyProfileRepository extends BaseRepository { const result = await this.repository.query( `SELECT nextval('${seqName}') AS next_id`, ); - const nextId = result[0].next_id as number; + const nextId = Number(result[0].next_id); + const offset = nextId - 1; + const seriesIndex = Math.floor(offset / SERIES_SIZE); + + if (seriesIndex >= SERIES_LETTERS.length) { + throw new InternalServerErrorException( + `Company profile reference series exhausted for type "${type}"`, + ); + } + + const letter = SERIES_LETTERS[seriesIndex]; + const number = (offset % SERIES_SIZE) + 1; const prefix = PREFIX_MAP[type]; - return `${prefix}-${String(nextId).padStart(5, "0")}`; + return `${prefix}-${letter}${String(number).padStart(5, "0")}`; } async findByCompanyId(companyId: string): Promise { diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts index 72696766f..2266b0c65 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts @@ -60,7 +60,7 @@ export class CompanyProfile extends BaseEntity { type!: ProfileType; /** - * Official profile reference (e.g. "EX-00001"). Minted only when the profile + * Official profile reference (e.g. "EX-A00001"). Minted only when the profile * is approved (status → Active); pending/unapproved profiles carry NULL. * The unique index tolerates this because Postgres treats NULLs as distinct. * API responses surface it as "" when absent — see ResponseCompanyProfileDto. diff --git a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts index 1aef33801..99dae5974 100644 --- a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts +++ b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts @@ -33,8 +33,9 @@ const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [ }, { fileKey: "commercial_license", - fileLabel: "Commercial License", - helpText: "Verified against the government trade system during registration.", + fileLabel: "Commercial Registration", + helpText: + "Verified against the government trade system during registration.", isRequired: true, isMultiple: false, maxFiles: 1, @@ -108,7 +109,8 @@ const LEGACY_ONBOARDING_FIELDS: OnboardingField[] = [ { fileKey: "business_license", fileLabel: "Business License / Trade License", - helpText: "Verified against the government trade system during registration.", + helpText: + "Verified against the government trade system during registration.", isRequired: true, isMultiple: false, maxFiles: 1, @@ -489,9 +491,14 @@ const SELF_CLEARANCE_SETTINGS: OnboardingDocumentSetting[] = [ const CONTRACT_INTAKE_ENTITY = "contract_intake"; const CONTRACT_INTAKE_FIELDS: OnboardingField[] = [ - clearanceField("commercial_framework", "Commercial Framework / Agreement", 1, { - required: false, - }), + clearanceField( + "commercial_framework", + "Commercial Framework / Agreement", + 1, + { + required: false, + }, + ), clearanceField("onboarding_attachment", "Onboarding Attachment", 2, { required: false, }), @@ -542,7 +549,7 @@ const DRIVER_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [ export class FileUploadSettingsSeeder { private readonly logger = new Logger(FileUploadSettingsSeeder.name); - constructor(private readonly dataSource: DataSource) {} + constructor(private readonly dataSource: DataSource) { } async run() { await this.dataSource.transaction(async (manager) => { @@ -552,35 +559,35 @@ export class FileUploadSettingsSeeder { const allSettings: Array< OnboardingDocumentSetting & { description: string } > = [ - ...COMPANY_ONBOARDING_DOCUMENTS.map((s) => ({ - ...s, - description: COMPANY_ONBOARDING_DESCRIPTION, - })), - ...CLEARANCE_DOCUMENT_SETTINGS.map((s) => ({ - ...s, - description: CLEARANCE_DESCRIPTION, - })), - ...CONTRACT_CLEARANCE_SETTINGS.map((s) => ({ - ...s, - description: - "Pre-booking clearance documents collected on the contract (Path B), by operation and freight type.", - })), - ...SELF_CLEARANCE_SETTINGS.map((s) => ({ - ...s, - description: - "Customer self-clearance documents (Path A, no EDR customs service), reviewed by Operations.", - })), - ...CONTRACT_INTAKE_SETTINGS.map((s) => ({ - ...s, - description: - "Commercial/framework documents attached at contract submission.", - })), - ...DRIVER_DOCUMENT_SETTINGS.map((s) => ({ - ...s, - description: - "Documents uploaded against a driver profile (license, ID, contracts, etc.).", - })), - ]; + ...COMPANY_ONBOARDING_DOCUMENTS.map((s) => ({ + ...s, + description: COMPANY_ONBOARDING_DESCRIPTION, + })), + ...CLEARANCE_DOCUMENT_SETTINGS.map((s) => ({ + ...s, + description: CLEARANCE_DESCRIPTION, + })), + ...CONTRACT_CLEARANCE_SETTINGS.map((s) => ({ + ...s, + description: + "Pre-booking clearance documents collected on the contract (Path B), by operation and freight type.", + })), + ...SELF_CLEARANCE_SETTINGS.map((s) => ({ + ...s, + description: + "Customer self-clearance documents (Path A, no EDR customs service), reviewed by Operations.", + })), + ...CONTRACT_INTAKE_SETTINGS.map((s) => ({ + ...s, + description: + "Commercial/framework documents attached at contract submission.", + })), + ...DRIVER_DOCUMENT_SETTINGS.map((s) => ({ + ...s, + description: + "Documents uploaded against a driver profile (license, ID, contracts, etc.).", + })), + ]; for (const documentSetting of allSettings) { await settingRepository.upsert( @@ -601,7 +608,9 @@ export class FileUploadSettingsSeeder { }); if (!setting) { - throw new Error(`file_upload_setting_seed_failed:${documentSetting.code}`); + throw new Error( + `file_upload_setting_seed_failed:${documentSetting.code}`, + ); } await fieldRepository.delete({ settingId: setting.id }); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/resubmit/resubmitDocs.ts b/apps/edr-freight-web/portal/src/pages/bookings/resubmit/resubmitDocs.ts index d8b660413..0a2f1a30f 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/resubmit/resubmitDocs.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/resubmit/resubmitDocs.ts @@ -10,7 +10,7 @@ const LABEL_BY_CODE = new Map([ ...REQUIRED_DOC_FIELDS.map((d) => [d.key, d.label] as const), // Company onboarding document codes (see file-upload-settings seeder). ["tin_certificate", "TIN Certificate"], - ["commercial_license", "Commercial License"], + ["commercial_license", "Commercial Registration"], ["business_license", "Business License / Trade License"], ["investment_license", "Investment License"], ["national_id", "National ID"], diff --git a/apps/edr-freight-web/portal/src/pages/settings/NationalitySelect.tsx b/apps/edr-freight-web/portal/src/pages/settings/NationalitySelect.tsx index 5b779847a..2115ec083 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/NationalitySelect.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/NationalitySelect.tsx @@ -26,7 +26,7 @@ export default function NationalitySelect({ } selected={value === "ethiopian"} onClick={() => onChange("ethiopian")} From d1652c1b96d0d12af405ecefb3ce61b8eedcc33d Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 9 Jul 2026 07:04:31 +0000 Subject: [PATCH 02/11] fix: sidebar scrollbar --- .../backoffice/src/components/layout/FreightSidebar.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx index 391a8157d..698b0fc2c 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx @@ -250,7 +250,10 @@ const FreightSidebar = ({ From e04b513b8f0ebb129bf2615436ef2f21c9ba6f8a Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 9 Jul 2026 08:50:08 +0000 Subject: [PATCH 03/11] feat: password reset flow --- .../modules/auth/customer-reset.controller.ts | 48 +++ .../modules/auth/customer-reset.service.ts | 59 ++++ .../modules/auth/dto/forgot-password.dto.ts | 35 ++ .../auth/forgot-password.controller.ts | 69 ++++ .../modules/auth/forgot-password.service.ts | 169 ++++++++++ .../src/modules/auth/freight-auth.module.ts | 26 +- .../contracts/contract-transition.service.ts | 2 +- .../src/modules/otp/otp.service.ts | 50 ++- .../src/seed/freight-permissions.registry.ts | 2 + .../customers/ResetPasswordAction.tsx | 105 ++++++ .../src/components/customers/index.ts | 4 + .../backoffice/src/constants/URLS.ts | 2 + .../backoffice/src/lib/permissions.ts | 1 + .../pages/customers/CustomerDetailPage.tsx | 2 + .../backoffice/src/services/api.ts | 12 + .../src/services/customers.service.ts | 18 + .../backoffice/src/types/customer.ts | 9 + apps/edr-freight-web/portal/src/App.tsx | 2 + .../src/components/auth/OtpChannelStep.tsx | 179 ++++++++++ .../src/components/auth/PasswordChecklist.tsx | 41 +++ .../portal/src/constants/URLS.ts | 2 + .../portal/src/hooks/useResendCooldown.ts | 24 ++ .../src/pages/accounts/ForgotPasswordPage.tsx | 312 ++++++++++++++++++ .../portal/src/pages/accounts/LoginPage.tsx | 14 +- .../src/pages/accounts/SetPasswordPage.tsx | 30 +- .../portal/src/pages/accounts/SignupPage.tsx | 260 +++------------ .../portal/src/services/api.ts | 18 + .../portal/src/services/auth.service.ts | 28 ++ apps/edr-freight-web/portal/src/types/auth.ts | 19 ++ .../portal/src/utils/identifier.ts | 22 ++ .../portal/src/utils/passwordSchema.ts | 36 ++ 31 files changed, 1350 insertions(+), 250 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/auth/customer-reset.controller.ts create mode 100644 apps/edr-freight-api/src/modules/auth/customer-reset.service.ts create mode 100644 apps/edr-freight-api/src/modules/auth/dto/forgot-password.dto.ts create mode 100644 apps/edr-freight-api/src/modules/auth/forgot-password.controller.ts create mode 100644 apps/edr-freight-api/src/modules/auth/forgot-password.service.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/customers/ResetPasswordAction.tsx create mode 100644 apps/edr-freight-web/portal/src/components/auth/OtpChannelStep.tsx create mode 100644 apps/edr-freight-web/portal/src/components/auth/PasswordChecklist.tsx create mode 100644 apps/edr-freight-web/portal/src/hooks/useResendCooldown.ts create mode 100644 apps/edr-freight-web/portal/src/pages/accounts/ForgotPasswordPage.tsx create mode 100644 apps/edr-freight-web/portal/src/utils/identifier.ts create mode 100644 apps/edr-freight-web/portal/src/utils/passwordSchema.ts diff --git a/apps/edr-freight-api/src/modules/auth/customer-reset.controller.ts b/apps/edr-freight-api/src/modules/auth/customer-reset.controller.ts new file mode 100644 index 000000000..2bf9f82fd --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/customer-reset.controller.ts @@ -0,0 +1,48 @@ +import { + Body, + Controller, + NotFoundException, + Param, + ParseUUIDPipe, + Post, +} from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; + +import { BookingStaff } from "../../common/booking-guards"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; +import { BackofficeResetPasswordDto } from "./dto/forgot-password.dto"; +import { CustomerResetService } from "./customer-reset.service"; + +/** + * Staff-triggered password reset. The customer receives the code and sets their + * own password — staff never see or handle a credential. + */ +@ApiTags("backoffice") +@Controller("backoffice/customers") +@ApiBearerAuth() +export class CustomerResetController { + constructor(private readonly customerResetService: CustomerResetService) {} + + @Post(":companyId/reset-password") + @BookingStaff(FREIGHT_PERMS.customers.resetPassword) + @ApiOperation({ + summary: "Send a password-reset code to a customer's primary contact", + }) + async resetPassword( + @Param("companyId", ParseUUIDPipe) companyId: string, + @Body() dto: BackofficeResetPasswordDto, + ) { + const maskedTarget = await this.customerResetService.sendResetToCustomer( + companyId, + dto.channel, + ); + + if (!maskedTarget) { + throw new NotFoundException( + `No active primary contact with ${dto.channel === "email" ? "an email address" : "a phone number"} for this customer`, + ); + } + + return { channel: dto.channel, maskedTarget }; + } +} diff --git a/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts b/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts new file mode 100644 index 000000000..4c8f67599 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts @@ -0,0 +1,59 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { ExternalProfile } from "../companies/entities/external-profile.entity"; +import { ResetChannel } from "./dto/forgot-password.dto"; +import { ForgotPasswordService } from "./forgot-password.service"; + +@Injectable() +export class CustomerResetService { + private readonly logger = new Logger(CustomerResetService.name); + + constructor( + @InjectRepository(ExternalProfile) + private readonly externalProfileRepository: Repository, + private readonly forgotPasswordService: ForgotPasswordService, + ) {} + + /** + * Send a reset code to the company's primary contact. Returns the masked + * destination, or null when there is no eligible account for that channel. + * + * Unlike the public flow this reports failure honestly — the caller is an + * authenticated staff member, so there is nothing to enumerate. + */ + async sendResetToCustomer( + companyId: string, + channel: ResetChannel, + ): Promise { + const profile = await this.externalProfileRepository.findOne({ + where: { companyId, isPrimaryContact: true }, + }); + + if (!profile) { + this.logger.warn(`Company ${companyId} has no primary contact profile`); + return null; + } + + // Resolve through the same active-account gate the public flow uses, so a + // suspended customer cannot be reactivated by a staff-triggered reset. + const user = await this.forgotPasswordService.resolveActiveUserById( + profile.userId, + ); + if (!user) { + this.logger.warn( + `Primary contact ${profile.userId} of company ${companyId} is not an active account`, + ); + return null; + } + + const target = await this.forgotPasswordService.requestReset(user, channel); + if (!target) return null; + + this.logger.log( + `Staff-triggered ${channel} reset sent to user ${user.id} (company ${companyId})`, + ); + return this.forgotPasswordService.maskTarget(target); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/dto/forgot-password.dto.ts b/apps/edr-freight-api/src/modules/auth/dto/forgot-password.dto.ts new file mode 100644 index 000000000..be2f9bdac --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/dto/forgot-password.dto.ts @@ -0,0 +1,35 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsEnum, IsNotEmpty, IsString } from "class-validator"; + +/** The channel the reset code is delivered over. */ +export enum ResetChannel { + Email = "email", + Phone = "phone", +} + +export class ForgotPasswordRequestDto { + @ApiProperty({ + description: "Email, username, or phone number of the account to reset", + example: "name@company.com", + }) + @IsString() + @IsNotEmpty() + identifier!: string; + + @ApiProperty({ enum: ResetChannel }) + @IsEnum(ResetChannel) + channel!: ResetChannel; +} + +export class ForgotPasswordVerifyDto extends ForgotPasswordRequestDto { + @ApiProperty({ description: "The 6-digit code sent to the chosen channel" }) + @IsString() + @IsNotEmpty() + otp!: string; +} + +export class BackofficeResetPasswordDto { + @ApiProperty({ enum: ResetChannel }) + @IsEnum(ResetChannel) + channel!: ResetChannel; +} diff --git a/apps/edr-freight-api/src/modules/auth/forgot-password.controller.ts b/apps/edr-freight-api/src/modules/auth/forgot-password.controller.ts new file mode 100644 index 000000000..da49982d2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/forgot-password.controller.ts @@ -0,0 +1,69 @@ +import { Body, Controller, Logger, Post } from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { Public } from "@edr/api-common"; + +import { + ForgotPasswordRequestDto, + ForgotPasswordVerifyDto, +} from "./dto/forgot-password.dto"; +import { ForgotPasswordService, ResetTicket } from "./forgot-password.service"; + +/** + * Freight-owned reset flow. IAM ships a `forgot-password` route, but it only + * ever SMSes a magic link (no email channel, and it needs `FE_BASE_URL`, which + * this API does not set). These routes drive freight's own email-or-phone OTP + * service instead, then hand back a ticket for IAM's public `set-password`. + */ +@ApiTags("auth") +@Controller("auth") +@Public() +export class ForgotPasswordController { + private readonly logger = new Logger(ForgotPasswordController.name); + + constructor(private readonly forgotPasswordService: ForgotPasswordService) {} + + @Post("forgot-password/request") + @ApiOperation({ + summary: "Send a password-reset code over email or SMS", + description: + "Always reports success. An unknown, inactive, or channel-less account is " + + "indistinguishable from a real one, so this cannot be used to enumerate accounts.", + }) + async request(@Body() dto: ForgotPasswordRequestDto): Promise<{ success: true }> { + const user = await this.forgotPasswordService.resolveActiveUser(dto.identifier); + + if (user) { + try { + await this.forgotPasswordService.requestReset(user, dto.channel); + } catch (error) { + // A delivery failure must not change the response shape either — log it + // and let the caller sit on the OTP screen. + this.logger.error( + `Reset code delivery failed for user ${user.id}: ${ + error instanceof Error ? error.message : String(error) + }`, + error instanceof Error ? error.stack : undefined, + ); + } + } else { + this.logger.log("Reset requested for an unknown or inactive account"); + } + + return { success: true }; + } + + @Post("forgot-password/verify") + @ApiOperation({ + summary: "Exchange a valid reset code for a single-use set-password ticket", + description: + "The returned { userId, verificationCode } is the body for PATCH /api/auth/set-password, " + + "alongside the same identifier and the new password.", + }) + verify(@Body() dto: ForgotPasswordVerifyDto): Promise { + return this.forgotPasswordService.verifyAndMintTicket( + dto.identifier, + dto.channel, + dto.otp, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts b/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts new file mode 100644 index 000000000..42dc723d5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts @@ -0,0 +1,169 @@ +import { randomBytes } from "node:crypto"; + +import { BadRequestException, Injectable, Logger } from "@nestjs/common"; +import { InjectDataSource, InjectRepository } from "@nestjs/typeorm"; +import { DataSource, Repository } from "typeorm"; + +import { hashPassword } from "@tria-plc/api-common/utils/argon"; +import { EOtpType } from "@tria-plc/iamapi-common/enums/otp.enum"; +import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; +import { UserVerification } from "@tria-plc/iamapi-common/entities/iam/user/user-verification.entity"; + +import { OtpService, OtpTarget } from "../otp/otp.service"; +import { ResetChannel } from "./dto/forgot-password.dto"; + +/** + * How long the reset ticket minted for `PATCH /api/auth/set-password` stays + * valid. The IAM `setPassword` handler enforces this via `expiresAt`. + */ +const RESET_TICKET_TTL_MS = 10 * 60 * 1000; + +/** How long the emailed/SMS'd OTP stays valid before it must be re-requested. */ +const RESET_OTP_TTL_MS = 10 * 60 * 1000; + +export interface ResetTicket { + userId: string; + verificationCode: string; +} + +@Injectable() +export class ForgotPasswordService { + private readonly logger = new Logger(ForgotPasswordService.name); + + constructor( + @InjectRepository(User) + private readonly userRepository: Repository, + @InjectDataSource() + private readonly dataSource: DataSource, + private readonly otpService: OtpService, + ) {} + + /** + * Resolve an account that is actually eligible for a password reset. + * + * IAM's `set-password` handler flips `isActive: true` on the user as a side + * effect, so a reset on a deactivated account would silently resurrect it. + * Gating here — rather than at the set-password call — is what keeps that + * from being reachable. Mirrors IAM's own login lookup: match on any of + * email / username / phone, and require an active credential row. + */ + async resolveActiveUser(identifier: string): Promise { + const id = identifier.trim(); + if (!id) return null; + + return await this.activeUserQuery() + .andWhere( + "(LOWER(u.email) = LOWER(:id) OR u.username = :id OR u.phoneNumber = :id)", + { id }, + ) + .getOne(); + } + + /** Same eligibility gate as {@link resolveActiveUser}, keyed by IAM user id. */ + async resolveActiveUserById(userId: string): Promise { + if (!userId) return null; + return await this.activeUserQuery() + .andWhere("u.id = :userId", { userId }) + .getOne(); + } + + /** + * Base query for accounts eligible to reset. `.where()` is claimed here so + * callers must use `.andWhere()` — TypeORM's `.where()` resets the clause, + * which would silently drop the `isActive` gate. + */ + private activeUserQuery() { + return this.userRepository + .createQueryBuilder("u") + .innerJoin("u.userCredentials", "uc", "uc.isActive = true") + .where("u.isActive = true") + .orderBy("u.createdAt", "DESC"); + } + + /** The address the code goes to, taken from the account — never from input. */ + private targetFor(user: User, channel: ResetChannel): OtpTarget | null { + if (channel === ResetChannel.Email) { + return user.email ? { email: user.email } : null; + } + return user.phoneNumber ? { phone: user.phoneNumber } : null; + } + + /** + * Send a reset code to the account's own email/phone. Returns the target so + * authenticated (backoffice) callers can echo a masked version; unauthenticated + * callers must discard it. + * + * Note: `otp_verifications` keys rows by a unique phone/email, and `sendOtp` + * upserts. A reset request therefore overwrites any pending signup code for + * the same address — last code sent wins. That is the pre-existing behaviour + * between any two flows sharing this table. + */ + async requestReset( + user: User, + channel: ResetChannel, + ): Promise { + const target = this.targetFor(user, channel); + if (!target) return null; + + await this.otpService.sendOtp(target); + return target; + } + + /** + * Prove possession of the OTP, then mint an IAM reset ticket the caller can + * spend on the public `PATCH /api/auth/set-password`. + * + * Minting a `UserVerification` row rather than writing `UserCredential` + * ourselves keeps IAM as the single owner of the password write path (old + * credential deactivation, argon hashing, changed-at bookkeeping). + */ + async verifyAndMintTicket( + identifier: string, + channel: ResetChannel, + otp: string, + ): Promise { + const user = await this.resolveActiveUser(identifier); + const target = user && this.targetFor(user, channel); + + if (!user?.id || !target) { + // Same shape as a wrong code: a caller probing for accounts learns nothing + // beyond what the request step already (deliberately) refuses to tell them. + throw new BadRequestException("Invalid verification code"); + } + + await this.otpService.verifyOtpForAction(target, otp, RESET_OTP_TTL_MS); + + const code = randomBytes(24).toString("base64url"); + const verificationCode = await hashPassword(code); + const userId = user.id; + + await this.dataSource.transaction(async (manager) => { + const repo = manager.getRepository(UserVerification); + // Retire any outstanding codes so only the ticket we just minted can be + // spent — `findVerificationForPrimaryReset` reads the newest row. + await repo.update({ userId }, { isUsed: true }); + await repo.insert({ + userId, + otpType: EOtpType.RESET_PASSWORD, + verificationCode, + expiresAt: new Date(Date.now() + RESET_TICKET_TTL_MS), + isUsed: false, + attemptCount: 0, + }); + }); + + this.logger.log(`Reset ticket minted for user ${userId}`); + return { userId, verificationCode: code }; + } + + /** `+251911234567` -> `+251•••••4567`; `ab@x.com` -> `a•@x.com`. */ + maskTarget(target: OtpTarget): string { + if (target.email) { + const [local, domain] = target.email.split("@"); + const head = local.slice(0, 1); + return `${head}${"•".repeat(Math.max(local.length - 1, 1))}@${domain}`; + } + const phone = target.phone ?? ""; + return `${phone.slice(0, 4)}${"•".repeat(Math.max(phone.length - 8, 1))}${phone.slice(-4)}`; + } +} diff --git a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts index 16fbeffda..6415cf4c1 100644 --- a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts +++ b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts @@ -2,15 +2,35 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity'; +import { UserVerification } from '@tria-plc/iamapi-common/entities/iam/user/user-verification.entity'; +import { ExternalProfile } from '../companies/entities/external-profile.entity'; +import { OtpModule } from '../otp/otp.module'; import { CheckAvailabilityController } from './check-availability.controller'; import { CheckAvailabilityService } from './check-availability.service'; +import { CustomerResetController } from './customer-reset.controller'; +import { CustomerResetService } from './customer-reset.service'; +import { ForgotPasswordController } from './forgot-password.controller'; +import { ForgotPasswordService } from './forgot-password.service'; import { FreightMeController } from './freight-me.controller'; import { FreightMeService } from './freight-me.service'; @Module({ - imports: [TypeOrmModule.forFeature([User])], - controllers: [FreightMeController, CheckAvailabilityController], - providers: [FreightMeService, CheckAvailabilityService], + imports: [ + TypeOrmModule.forFeature([User, UserVerification, ExternalProfile]), + OtpModule, + ], + controllers: [ + FreightMeController, + CheckAvailabilityController, + ForgotPasswordController, + CustomerResetController, + ], + providers: [ + FreightMeService, + CheckAvailabilityService, + ForgotPasswordService, + CustomerResetService, + ], }) export class FreightAuthModule {} diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index 9f12b937d..1634034bf 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -582,7 +582,7 @@ export class ContractTransitionService { 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.otpService.verifyOtpForAction({ phone: dto.otpPhone }, dto.otp); await this.applySignature(contract, dto, options); await this.contractsRepository.update(contractId, { status: 'SIGNED_CUSTOMER', diff --git a/apps/edr-freight-api/src/modules/otp/otp.service.ts b/apps/edr-freight-api/src/modules/otp/otp.service.ts index e26ed35c9..b98b41d8d 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.service.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.service.ts @@ -50,6 +50,9 @@ export class OtpService { await this.otpRepository.createOtp(target, otp); } + // A freshly issued code gets a fresh guess budget. + this.actionAttempts.delete(this.targetKey(target)); + if (target.email) { // send email (queued to RabbitMQ via the shared Email service) await this.emailClient.sendEmail({ @@ -121,24 +124,44 @@ export class OtpService { // --------------------------------------------------------------------------- // 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. + // contract signature, resetting a forgotten password). Unlike verifyOtp above + // — which marks a target verified and leaves the code in place — this enforces + // a 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); + // Without a cap, a 6-digit code guarding a password reset is brute-forceable + // within its own TTL. `otp_verifications` has no attempt column, so the + // counter lives here and the code is burned once the budget is spent. + // Per-process: it resets on restart and is not shared across replicas — a + // persisted counter needs a migration on OtpVerification. + private readonly MAX_ACTION_ATTEMPTS = 5; + private readonly actionAttempts = new Map(); + + private targetKey(target: OtpTarget): string { + return target.email ? `email:${target.email}` : `phone:${target.phone}`; + } + + async verifyOtpForAction( + target: OtpTarget, + otp: string, + ttlMs: number = this.ACTION_OTP_TTL_MS, + ) { + const otpData = await this.otpRepository.findByTarget(target); + const key = this.targetKey(target); if (!otpData) { throw new BadRequestException( - "No verification code was requested for this phone", + target.email + ? "No verification code was requested for this email" + : "No verification code was requested for this phone", ); } const ageMs = Date.now() - new Date(otpData.updatedAt).getTime(); - if (ageMs > this.ACTION_OTP_TTL_MS) { + if (ageMs > ttlMs) { await this.otpRepository.deleteOtp(otpData); + this.actionAttempts.delete(key); throw new BadRequestException( "Verification code has expired. Request a new one.", @@ -146,11 +169,24 @@ export class OtpService { } if (otpData.otp !== otp) { + const attempts = (this.actionAttempts.get(key) ?? 0) + 1; + + if (attempts >= this.MAX_ACTION_ATTEMPTS) { + await this.otpRepository.deleteOtp(otpData); + this.actionAttempts.delete(key); + + throw new BadRequestException( + "Too many incorrect attempts. Request a new code.", + ); + } + + this.actionAttempts.set(key, attempts); throw new BadRequestException("Invalid verification code"); } // single-use: consume on success await this.otpRepository.deleteOtp(otpData); + this.actionAttempts.delete(key); return { success: true }; } diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index d4d85e84c..62f531d5f 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -131,6 +131,7 @@ export const CUSTOMER_PERMISSIONS: FreightPermissionSeed[] = [ perm('d1a00001-0001-4000-8000-000000000003', 'edr_freight_app:customers:update', 'Update customer'), perm('d1a00001-0001-4000-8000-000000000004', 'edr_freight_app:customers:deactivate', 'Deactivate customer'), perm('d1a00001-0001-4000-8000-000000000005', 'edr_freight_app:customers:verify', 'Verify customer (KYC/Fayda)'), + perm('d1a00001-0001-4000-8000-000000000006', 'edr_freight_app:customers:reset-password', 'Trigger customer password reset'), ]; // D. Finance — payments + invoices @@ -394,6 +395,7 @@ export const FREIGHT_PERMS = { update: 'edr_freight_app:customers:update', deactivate: 'edr_freight_app:customers:deactivate', verify: 'edr_freight_app:customers:verify', + resetPassword: 'edr_freight_app:customers:reset-password', }, payments: { view: 'edr_freight_app:payments:view', diff --git a/apps/edr-freight-web/backoffice/src/components/customers/ResetPasswordAction.tsx b/apps/edr-freight-web/backoffice/src/components/customers/ResetPasswordAction.tsx new file mode 100644 index 000000000..bc505a21f --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/customers/ResetPasswordAction.tsx @@ -0,0 +1,105 @@ +import { Button, Modal, Radio, Stack, Text } from "@mantine/core"; +import { useMutation } from "@tanstack/react-query"; +import { KeyRound } from "lucide-react"; +import { useState } from "react"; + +import { useAuth } from "@/auth/useAuth"; +import { useToast } from "@/hooks/use-toast"; +import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; +import { api } from "@/services/api"; +import type { Company, ResetChannel } from "@/types/customer"; + +export interface ResetPasswordActionProps { + company: Pick; +} + +/** + * Staff-triggered password reset. Sends a one-time code to the customer's + * primary contact; the customer picks their own new password. No credential is + * ever shown to or handled by staff. + */ +export default function ResetPasswordAction({ company }: ResetPasswordActionProps) { + const { user } = useAuth(); + const { toast } = useToast(); + const [opened, setOpened] = useState(false); + const [channel, setChannel] = useState("phone"); + + const { mutate, isPending } = useMutation( + api.customers.resetPassword.mutationOptions({ + onSuccess: (result) => { + setOpened(false); + toast({ + title: "Reset code sent", + description: `The customer can now reset their password using the code sent to ${result.maskedTarget}.`, + }); + }, + onError: (error) => { + toast({ + title: "Could not send reset code", + description: error.message, + variant: "destructive", + }); + }, + }), + ); + + if (!hasPermission(user, FREIGHT_PERMS.customers.resetPassword)) return null; + + return ( + <> + + + setOpened(false)} + title="Send a password-reset code" + centered + > + + + We'll send a one-time code to this customer's primary contact. + They choose their own new password — you will not see it. + + + setChannel(v as ResetChannel)} + label="Send the code via" + > + + + + + + + + The code goes to the primary contact's own email or phone, which + may differ from the company contact details shown above. + + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/customers/index.ts b/apps/edr-freight-web/backoffice/src/components/customers/index.ts index 2a87e3b0c..d42673d2b 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/customers/index.ts @@ -13,5 +13,9 @@ export { ChangeRequestReview, ChangeRequestPendingBadge, } from "./ChangeRequestReview"; +export { + default as ResetPasswordAction, + type ResetPasswordActionProps, +} from "./ResetPasswordAction"; export { formatBytes, formatDate, formatMoney, humanize } from "./format"; export { TableCard, type TableCardProps } from "./TableCard"; diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 77a3bc95e..c6f98c8a2 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -86,6 +86,8 @@ export const URL_CONSTANTS = { `/bookings/by-company/${id}/customer-view`, PAYMENTS_CUSTOMER_VIEW: (id: string) => `/payments/by-company/${id}/customer-view`, + RESET_PASSWORD: (companyId: string) => + `/backoffice/customers/${companyId}/reset-password`, }, BILLING: { diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index c8bfa7c84..a7d8c16c1 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -62,6 +62,7 @@ export const FREIGHT_PERMS = { update: "edr_freight_app:customers:update", deactivate: "edr_freight_app:customers:deactivate", verify: "edr_freight_app:customers:verify", + resetPassword: "edr_freight_app:customers:reset-password", }, payments: { view: "edr_freight_app:payments:view", diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx index c68a4ca25..317f915fb 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -43,6 +43,7 @@ import { ProfileChips, ProfileStatusBadge, ProfileTypeBadge, + ResetPasswordAction, TableCard, formatBytes, formatDate, @@ -573,6 +574,7 @@ export default function CustomerDetailPage() { } + action={} /> diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index 7710e077f..4f04e3358 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -12,6 +12,8 @@ import type { CustomerPayment, PaginatedCompanies, ProfileStatus, + ResetChannel, + ResetPasswordResult, } from "@/types/customer"; import { CreateDropdownOptionDto, @@ -2261,6 +2263,16 @@ export const api = { ({ id }) => QUERY_KEYS.CUSTOMERS.payments(id), ), + resetPassword: endpoint< + { companyId: string; channel: ResetChannel }, + ResetPasswordResult + >( + "customers", + "resetPassword", + ({ companyId, channel }) => + customersService.resetPassword(companyId, channel), + ), + setProfileStatus: endpoint< { profileId: string; status: ProfileStatus; note?: string }, CompanyProfile diff --git a/apps/edr-freight-web/backoffice/src/services/customers.service.ts b/apps/edr-freight-web/backoffice/src/services/customers.service.ts index cafe0aece..0476d41bb 100644 --- a/apps/edr-freight-web/backoffice/src/services/customers.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/customers.service.ts @@ -11,6 +11,8 @@ import type { CustomerPayment, PaginatedCompanies, ProfileStatus, + ResetChannel, + ResetPasswordResult, } from "@/types/customer"; const cleanParams = (params: object) => @@ -81,6 +83,22 @@ export const customersService = { .then((r) => r.data); }, + /** + * Send a password-reset code to the company's primary contact. Staff never + * receive a credential — the customer sets their own password from the code. + */ + resetPassword( + companyId: string, + channel: ResetChannel, + ): Promise { + return apiClient + .post( + URL_CONSTANTS.COMPANIES.RESET_PASSWORD(companyId), + { channel }, + ) + .then((r) => r.data); + }, + setProfileStatus( profileId: string, status: ProfileStatus, diff --git a/apps/edr-freight-web/backoffice/src/types/customer.ts b/apps/edr-freight-web/backoffice/src/types/customer.ts index 8d76571d4..7328decf0 100644 --- a/apps/edr-freight-web/backoffice/src/types/customer.ts +++ b/apps/edr-freight-web/backoffice/src/types/customer.ts @@ -99,6 +99,15 @@ export interface CompanyChangeRequest { updatedAt: string; } +/** The channel a customer's password-reset code is delivered over. */ +export type ResetChannel = "email" | "phone"; + +export interface ResetPasswordResult { + channel: ResetChannel; + /** Where the code went, e.g. `+251•••4821` — safe to show to staff. */ + maskedTarget: string; +} + /** Mirrors backend `Company` (+ its `companyProfiles`). */ export interface Company { id: string; diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 697c2f89c..e4c5685fd 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -32,6 +32,7 @@ import EDRFreightLandingPage from "./pages/EDRFreightLandingPage"; import MyPortalPage from "./pages/MyPortalPage"; import MySignaturePage from "./pages/MySignaturePage"; import SettingsPage from "./pages/SettingsPage"; +import ForgotPasswordPage from "./pages/accounts/ForgotPasswordPage"; import LoginPage from "./pages/accounts/LoginPage"; import SetPasswordPage from "./pages/accounts/SetPasswordPage"; import SignupPage from "./pages/accounts/SignupPage"; @@ -252,6 +253,7 @@ const App = () => { }> } /> } /> + } /> {/* Signup-flow pages; reached while a session already exists */} diff --git a/apps/edr-freight-web/portal/src/components/auth/OtpChannelStep.tsx b/apps/edr-freight-web/portal/src/components/auth/OtpChannelStep.tsx new file mode 100644 index 000000000..b61bb2eab --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/auth/OtpChannelStep.tsx @@ -0,0 +1,179 @@ +import { Alert, Button, PinInput, SegmentedControl, Stack, Text } from "@mantine/core"; +import { + AlertCircle, + ArrowLeft, + Mail, + RotateCw, + ShieldCheck, + Smartphone, +} from "lucide-react"; + +import { maskEmail, maskPhone } from "@/utils/identifier"; + +export type OtpChannel = "phone" | "email"; + +export const OTP_LENGTH = 6; + +export interface OtpChannelSelectProps { + value: OtpChannel; + onChange: (channel: OtpChannel) => void; + disabled?: boolean; + label?: string; +} + +/** Phone/email toggle deciding where the verification code is sent. */ +export function OtpChannelSelect({ + value, + onChange, + disabled, + label = "Send verification code via", +}: OtpChannelSelectProps) { + return ( +
+ + {label} + + onChange(v as OtpChannel)} + data={[ + { + value: "phone", + label: ( + + Phone + + ), + }, + { + value: "email", + label: ( + + Email + + ), + }, + ]} + /> +
+ ); +} + +export interface OtpChannelStepProps { + channel: OtpChannel; + /** Raw email or phone the code went to; masked before display. */ + target: string; + value: string; + onChange: (otp: string) => void; + onVerify: () => void; + onBack: () => void; + onResend: () => void; + /** Seconds until resend is allowed; 0 enables the button. */ + resendIn: number; + sending: boolean; + verifying: boolean; + error: string | null; + title?: string; + description?: string; + submitLabel: string; +} + +/** + * The "enter the code we sent you" stage. Shared by signup and the + * forgot-password flow — both send through the same `/api/otp/*` service. + */ +export default function OtpChannelStep({ + channel, + target, + value, + onChange, + onVerify, + onBack, + onResend, + resendIn, + sending, + verifying, + error, + title, + description, + submitLabel, +}: OtpChannelStepProps) { + const maskedTarget = channel === "email" ? maskEmail(target) : maskPhone(target); + const busy = sending || verifying; + + return ( + +
+ + + +
+ +
+

+ {title ?? `Verify your ${channel === "email" ? "email" : "phone"}`} +

+

+ We sent a {OTP_LENGTH}-digit code to{" "} + {maskedTarget}.{" "} + {description ?? "Enter it to continue."} +

+
+ + {error ? ( + }> + {error} + + ) : null} + + + + Verification code + + + + + + +
+ + +
+
+ ); +} diff --git a/apps/edr-freight-web/portal/src/components/auth/PasswordChecklist.tsx b/apps/edr-freight-web/portal/src/components/auth/PasswordChecklist.tsx new file mode 100644 index 000000000..89cea7a0e --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/auth/PasswordChecklist.tsx @@ -0,0 +1,41 @@ +import { Check, X } from "lucide-react"; + +import { passwordRequirements } from "@/utils/passwordSchema"; + +export interface PasswordChecklistProps { + /** The current password value; the checklist hides itself when empty. */ + value: string; +} + +/** Live pass/fail list of the password rules, shown under a password field. */ +export default function PasswordChecklist({ value }: PasswordChecklistProps) { + if (!value) return null; + + return ( +
+ {passwordRequirements.map((req) => { + const met = req.test(value); + return ( +
+ + {met ? ( + + ) : ( + + )} + + + {req.label} + +
+ ); + })} +
+ ); +} diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index 89a3d57e7..5dfda7e58 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -5,6 +5,8 @@ export const URL_CONSTANTS = { REFRESH_TOKEN: "/api/auth/refresh-token", LOGOUT: "/api/auth/logout", PROFILE: "/auth/profile", + FORGOT_PASSWORD_REQUEST: "/api/auth/forgot-password/request", + FORGOT_PASSWORD_VERIFY: "/api/auth/forgot-password/verify", }, USERS: { diff --git a/apps/edr-freight-web/portal/src/hooks/useResendCooldown.ts b/apps/edr-freight-web/portal/src/hooks/useResendCooldown.ts new file mode 100644 index 000000000..deeca8125 --- /dev/null +++ b/apps/edr-freight-web/portal/src/hooks/useResendCooldown.ts @@ -0,0 +1,24 @@ +import { useEffect, useState } from "react"; + +/** Seconds a user must wait before another OTP can be requested. */ +const DEFAULT_COOLDOWN_SECONDS = 60; + +/** + * Countdown that gates the "Resend code" button. Ticks with setTimeout rather + * than wall-clock arithmetic, so it needs no Date.now(). + */ +export function useResendCooldown(seconds: number = DEFAULT_COOLDOWN_SECONDS) { + const [secondsLeft, setSecondsLeft] = useState(0); + + useEffect(() => { + if (secondsLeft <= 0) return; + const t = setTimeout(() => setSecondsLeft((s) => s - 1), 1000); + return () => clearTimeout(t); + }, [secondsLeft]); + + return { + secondsLeft, + start: () => setSecondsLeft(seconds), + reset: () => setSecondsLeft(0), + }; +} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/ForgotPasswordPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/ForgotPasswordPage.tsx new file mode 100644 index 000000000..58cfeab43 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/accounts/ForgotPasswordPage.tsx @@ -0,0 +1,312 @@ +import { type FormEvent, useState } from "react"; +import { Alert, Button, PasswordInput, Stack, TextInput } from "@mantine/core"; +import { AlertCircle, ArrowLeft, ArrowRight, KeyRound } from "lucide-react"; +import { Link, useNavigate } from "react-router-dom"; + +import { useResendCooldown } from "@/hooks/useResendCooldown"; +import AuthShell from "@/components/auth/AuthShell"; +import OtpChannelStep, { + OTP_LENGTH, + OtpChannelSelect, + type OtpChannel, +} from "@/components/auth/OtpChannelStep"; +import PasswordChecklist from "@/components/auth/PasswordChecklist"; +import { api } from "@/services/api"; +import type { ResetTicket } from "@/types/auth"; +import { normaliseIdentifier } from "@/utils/identifier"; +import { meetsAllRequirements } from "@/utils/passwordSchema"; +import { extractApiError } from "@/utils/result"; + +type Stage = "identify" | "otp" | "password"; + +export default function ForgotPasswordPage() { + const navigate = useNavigate(); + + const [stage, setStage] = useState("identify"); + const [identifier, setIdentifier] = useState(""); + const [channel, setChannel] = useState("phone"); + const [otpCode, setOtpCode] = useState(""); + // The reset ticket lives in memory only — persisting it would leave a + // password-change credential sitting in localStorage. + const [ticket, setTicket] = useState(null); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + + const [sending, setSending] = useState(false); + const [verifying, setVerifying] = useState(false); + const [error, setError] = useState(null); + const resendCooldown = useResendCooldown(); + + /** The identifier as the API will see it — normalised once, reused everywhere. */ + const normalised = normaliseIdentifier(identifier); + + const sendCode = async () => { + await api.auth.requestPasswordReset.call({ identifier: normalised, channel }); + setOtpCode(""); + resendCooldown.start(); + }; + + // Stage 1 — ask for a code. The API answers identically for unknown accounts, + // so we always advance; a non-existent identifier simply never receives a code. + const handleIdentify = async (event: FormEvent) => { + event.preventDefault(); + setError(null); + setSending(true); + try { + await sendCode(); + setStage("otp"); + } catch (err) { + setError(extractApiError(err).message); + } finally { + setSending(false); + } + }; + + const handleResend = async () => { + setError(null); + setSending(true); + try { + await sendCode(); + } catch (err) { + setError(extractApiError(err).message); + } finally { + setSending(false); + } + }; + + // Stage 2 — trade the code for a single-use ticket. + const handleVerify = async () => { + setError(null); + if (otpCode.trim().length !== OTP_LENGTH) { + setError(`Enter the ${OTP_LENGTH}-digit code we sent you.`); + return; + } + setVerifying(true); + try { + const result = await api.auth.verifyPasswordResetOtp.call({ + identifier: normalised, + channel, + otp: otpCode.trim(), + }); + setTicket(result); + setStage("password"); + } catch (err) { + setError(extractApiError(err).message); + } finally { + setVerifying(false); + } + }; + + // Stage 3 — spend the ticket on IAM's set-password. + const handleReset = async (event: FormEvent) => { + event.preventDefault(); + setError(null); + + if (!ticket) { + setError("Your reset session expired. Start again."); + setStage("identify"); + return; + } + if (password !== confirmPassword) { + setError("Passwords do not match."); + return; + } + + setVerifying(true); + try { + await api.auth.resetPassword.call({ + userId: ticket.userId, + // The API matches this against email / username / phone, so the typed + // identifier works regardless of which one it is. + email: normalised, + verificationCode: ticket.verificationCode, + newPassword: password, + confirmPassword, + }); + navigate("/login", { + replace: true, + state: { passwordReset: true }, + }); + } catch (err) { + setError(extractApiError(err).message); + } finally { + setVerifying(false); + } + }; + + const identifierLabel = + channel === "email" ? "the email on your account" : "the phone on your account"; + + return ( + +
+ {stage === "identify" ? ( +
+
+ + + +
+ +
+

+ Forgot your password? +

+

+ Enter your email or phone number and we'll send you a code to + reset it. +

+
+ + + setIdentifier(event.target.value)} + /> + + + +

+ The code goes to {identifierLabel}, which may differ from what you + typed above. +

+ + {error ? ( + }> + {error} + + ) : null} + + + +

+ Remembered it?{" "} + + Back to sign in + +

+
+
+ ) : null} + + {stage === "otp" ? ( + { + setStage("identify"); + setError(null); + }} + onResend={handleResend} + resendIn={resendCooldown.secondsLeft} + sending={sending} + verifying={verifying} + error={error} + title="Enter your reset code" + description="Enter it to choose a new password." + submitLabel="Verify code" + /> + ) : null} + + {stage === "password" ? ( +
+
+

+ Choose a new password +

+

+ Pick something strong you haven't used before. +

+
+ + +
+ setPassword(event.target.value)} + /> + +
+ + setConfirmPassword(event.target.value)} + /> + + {error ? ( + }> + {error} + + ) : null} + + + + +
+
+ ) : null} +
+
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx index 885888a05..7e8e512dd 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx @@ -5,21 +5,11 @@ import { Link, useLocation, useNavigate } from "react-router-dom"; import useAuth from "@/hooks/useAuth"; import AuthShell from "@/components/auth/AuthShell"; +import { normaliseIdentifier } from "@/utils/identifier"; import { extractApiError } from "@/utils/result"; const EDR_LOGO = "/assets/edr-logo.png"; -/** Normalise Ethiopian local phone (09…/07…) to E.164; pass email through unchanged. */ -function normaliseIdentifier(raw: string): string { - const v = raw.trim(); - const digits = v.replace(/\D/g, ""); - if (digits.length >= 9 && (v.startsWith("0") || v.startsWith("+251"))) { - const local = digits.startsWith("251") ? digits.slice(3) : digits.replace(/^0/, ""); - return `+251${local}`; - } - return v.toLowerCase(); -} - export default function LoginPage() { const navigate = useNavigate(); const location = useLocation(); @@ -80,7 +70,7 @@ export default function LoginPage() {
Password Forgot password? diff --git a/apps/edr-freight-web/portal/src/pages/accounts/SetPasswordPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/SetPasswordPage.tsx index 87702bdd8..ee8dd1922 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/SetPasswordPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/SetPasswordPage.tsx @@ -8,30 +8,20 @@ import { z } from "zod"; import useAuth from "@/hooks/useAuth"; import AuthLayout from "@/components/auth/AuthLayout"; - -const passwordRequirements = [ - { label: "At least 8 characters", test: (v: string) => v.length >= 8 }, - { label: "One uppercase letter", test: (v: string) => /[A-Z]/.test(v) }, - { label: "One lowercase letter", test: (v: string) => /[a-z]/.test(v) }, - { label: "One number", test: (v: string) => /\d/.test(v) }, - { label: "One special character", test: (v: string) => /[^A-Za-z0-9]/.test(v) }, -] as const; +import { + PASSWORD_MISMATCH, + confirmPasswordField, + passwordField, + passwordRequirements, + samePassword, +} from "@/utils/passwordSchema"; const passwordSchema = z .object({ - password: z - .string() - .min(8, "Password must be at least 8 characters") - .regex(/[A-Z]/, "Password must include an uppercase letter") - .regex(/[a-z]/, "Password must include a lowercase letter") - .regex(/\d/, "Password must include a number") - .regex(/[^A-Za-z0-9]/, "Password must include a special character"), - confirmPassword: z.string().min(1, "Please confirm your password"), + password: passwordField, + confirmPassword: confirmPasswordField, }) - .refine((data) => data.password === data.confirmPassword, { - message: "Passwords do not match", - path: ["confirmPassword"], - }); + .refine(samePassword, PASSWORD_MISMATCH); type FormData = z.infer; diff --git a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx index 5746eefd5..fe2fb09cd 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx @@ -1,50 +1,39 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import { zodResolver } from "@hookform/resolvers/zod"; 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 { AlertCircle, ArrowRight } from "lucide-react"; import { useForm } from "react-hook-form"; import { useNavigate } from "react-router-dom"; import { z } from "zod"; import { userType } from "@/enums/userType"; import useAuth from "@/hooks/useAuth"; +import { useResendCooldown } from "@/hooks/useResendCooldown"; import type { SignupPayload } from "@/types/auth"; import AuthShell from "@/components/auth/AuthShell"; +import OtpChannelStep, { + OTP_LENGTH, + OtpChannelSelect, + type OtpChannel, +} from "@/components/auth/OtpChannelStep"; +import PasswordChecklist from "@/components/auth/PasswordChecklist"; import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; import { api } from "@/services/api"; +import { + PASSWORD_MISMATCH, + confirmPasswordField, + passwordField, + samePassword, +} from "@/utils/passwordSchema"; import { extractApiError } from "@/utils/result"; -const passwordRequirements = [ - { label: "At least 8 characters", test: (v: string) => v.length >= 8 }, - { label: "One uppercase letter", test: (v: string) => /[A-Z]/.test(v) }, - { label: "One lowercase letter", test: (v: string) => /[a-z]/.test(v) }, - { label: "One number", test: (v: string) => /\d/.test(v) }, - { - label: "One special character", - test: (v: string) => /[^A-Za-z0-9]/.test(v), - }, -] as const; - const userSchema = z .object({ email: z.string().email("Invalid email address"), @@ -61,43 +50,20 @@ const userSchema = z en: z.string().min(2, "Name is required"), am: z.string().nullable(), }), - password: z - .string() - .min(8, "Password must be at least 8 characters") - .regex(/[A-Z]/, "Password must include an uppercase letter") - .regex(/[a-z]/, "Password must include a lowercase letter") - .regex(/\d/, "Password must include a number") - .regex(/[^A-Za-z0-9]/, "Password must include a special character"), - confirmPassword: z.string().min(1, "Please confirm your password"), + password: passwordField, + confirmPassword: confirmPasswordField, }) - .refine((data) => data.password === data.confirmPassword, { - message: "Passwords do not match", - path: ["confirmPassword"], - }); + .refine(samePassword, PASSWORD_MISMATCH); type FormData = z.infer; -/** Mask all but the first 7 chars of an E.164 phone for display. */ -const maskPhone = (p: string) => - p.length > 4 ? `${p.slice(0, 7)}${"*".repeat(Math.max(0, p.length - 7))}` : p; - -/** Mask the local part of an email for display (j***e@example.com). */ -const maskEmail = (email: string) => { - const [local, domain] = email.split("@"); - if (!local || !domain) return email; - if (local.length <= 2) return `${local[0] ?? ""}***@${domain}`; - return `${local[0]}***${local[local.length - 1]}@${domain}`; -}; - -type OtpChannel = "phone" | "email"; - export default function SignupPage() { const navigate = useNavigate(); const { signup } = useAuth(); const [error, setError] = useState(null); - // 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 + // Two-stage signup: fill the form, then a mandatory OTP challenge on the + // chosen channel before the account is actually created. The account is only // created after the code is verified — the OTP is a hard requirement. const [stage, setStage] = useState<"form" | "otp">("form"); const [pendingData, setPendingData] = useState(null); @@ -109,14 +75,7 @@ export default function SignupPage() { const [verifying, setVerifying] = useState(false); const [otpCode, setOtpCode] = useState(""); const [otpError, setOtpError] = useState(null); - const [resendIn, setResendIn] = useState(0); - - // Resend cooldown countdown (pure setTimeout ticks — no Date.now needed). - useEffect(() => { - if (resendIn <= 0) return; - const t = setTimeout(() => setResendIn((s) => s - 1), 1000); - return () => clearTimeout(t); - }, [resendIn]); + const resendCooldown = useResendCooldown(); const { register, @@ -170,7 +129,7 @@ export default function SignupPage() { setOtpChannel(channel); setOtpCode(""); setOtpError(null); - setResendIn(60); + resendCooldown.start(); setStage("otp"); } catch (err) { setError(extractApiError(err).message); @@ -190,7 +149,7 @@ export default function SignupPage() { : { phone: pendingData.phone }, ); setOtpCode(""); - setResendIn(60); + resendCooldown.start(); } catch (err) { setOtpError(extractApiError(err).message); } finally { @@ -202,8 +161,8 @@ export default function SignupPage() { const confirmOtp = async () => { if (!pendingData) return; setOtpError(null); - if (otpCode.trim().length !== 6) { - setOtpError("Enter the 6-digit code we sent you."); + if (otpCode.trim().length !== OTP_LENGTH) { + setOtpError(`Enter the ${OTP_LENGTH}-digit code we sent you.`); return; } setVerifying(true); @@ -298,35 +257,11 @@ export default function SignupPage() { disabled={sending} /> -
- - Send verification code via - - setChannel(v as OtpChannel)} - data={[ - { - value: "phone", - label: ( - - Phone - - ), - }, - { - value: "email", - label: ( - - Email - - ), - }, - ]} - /> -
+
- {passwordValue.length > 0 ? ( -
- {passwordRequirements.map((req) => { - const met = req.test(passwordValue); - return ( -
- - {met ? ( - - ) : ( - - )} - - - {req.label} - -
- ); - })} -
- ) : null} +
) : ( - -
- - - -
-
-

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

-

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

-
- - {otpError ? ( - } - > - {otpError} - - ) : null} - - - - Verification code - - - - - - -
- - -
-
+ { + setStage("form"); + setOtpError(null); + }} + onResend={resendOtp} + resendIn={resendCooldown.secondsLeft} + sending={sending} + verifying={verifying} + error={otpError} + description="Enter it to finish creating your account." + submitLabel="Verify & create account" + /> )}
diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index c5af03f65..b9937501f 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -77,6 +77,9 @@ import type { SetPasswordPayload, SignupPayload, SignupResponse, + ForgotPasswordRequestPayload, + ForgotPasswordVerifyPayload, + ResetTicket, } from "@/types/auth"; // --------------------------------------------------------------------------- @@ -110,6 +113,21 @@ export const api = { "setPassword", authService.setPassword, ), + requestPasswordReset: endpoint( + "auth", + "requestPasswordReset", + authService.requestPasswordReset, + ), + verifyPasswordResetOtp: endpoint( + "auth", + "verifyPasswordResetOtp", + authService.verifyPasswordResetOtp, + ), + resetPassword: endpoint( + "auth", + "resetPassword", + authService.resetPassword, + ), checkAvailability: endpoint( "auth", "checkAvailability", diff --git a/apps/edr-freight-web/portal/src/services/auth.service.ts b/apps/edr-freight-web/portal/src/services/auth.service.ts index 3f9ef4e53..3b113878d 100644 --- a/apps/edr-freight-web/portal/src/services/auth.service.ts +++ b/apps/edr-freight-web/portal/src/services/auth.service.ts @@ -3,11 +3,14 @@ import type { AuthUser, CheckAvailabilityPayload, CheckAvailabilityResponse, + ForgotPasswordRequestPayload, + ForgotPasswordVerifyPayload, GenerateVerificationCodePayload, LoginPayload, LoginResponse, OtpPayload, OtpResponse, + ResetTicket, SetPasswordPayload, SignupPayload, SignupResponse, @@ -53,6 +56,31 @@ export const authService = { return res.data.data; }, + // The three calls below drive the unauthenticated forgot-password flow. + // Responses under /api/auth are *flattened* by the API's response + // interceptor ({ success, ...payload }), so there is no `.data.data` here. + + requestPasswordReset: async (body: ForgotPasswordRequestPayload) => { + await client.post(URL_CONSTANTS.AUTH.FORGOT_PASSWORD_REQUEST, body); + }, + + verifyPasswordResetOtp: async (body: ForgotPasswordVerifyPayload) => { + const res = await client.post( + URL_CONSTANTS.AUTH.FORGOT_PASSWORD_VERIFY, + body, + ); + return { userId: res.data.userId, verificationCode: res.data.verificationCode }; + }, + + /** + * Spend the reset ticket. Distinct from `setPassword` above, which the + * authenticated post-signup flow drives through `useAuth` — this one carries + * its own userId/verificationCode and never touches the session. + */ + resetPassword: async (body: SetPasswordPayload) => { + await client.patch(URL_CONSTANTS.USERS.SET_PASSWORD, body); + }, + checkAvailability: async (params: CheckAvailabilityPayload) => { const res = await client.get( URL_CONSTANTS.USERS.CHECK_AVAILABILITY, diff --git a/apps/edr-freight-web/portal/src/types/auth.ts b/apps/edr-freight-web/portal/src/types/auth.ts index 04357a9ca..2e9a0b611 100644 --- a/apps/edr-freight-web/portal/src/types/auth.ts +++ b/apps/edr-freight-web/portal/src/types/auth.ts @@ -63,6 +63,25 @@ export interface SetPasswordPayload { verificationCode: string; } +/** The channel a password-reset code is delivered over. */ +export type ResetChannel = "email" | "phone"; + +export interface ForgotPasswordRequestPayload { + /** Email, username, or E.164 phone — whatever the user typed, normalised. */ + identifier: string; + channel: ResetChannel; +} + +export interface ForgotPasswordVerifyPayload extends ForgotPasswordRequestPayload { + otp: string; +} + +/** Single-use ticket to spend on `PATCH /api/auth/set-password`. */ +export interface ResetTicket { + userId: string; + verificationCode: string; +} + export interface GenerateVerificationCodePayload { email: string; phoneNumber: string; diff --git a/apps/edr-freight-web/portal/src/utils/identifier.ts b/apps/edr-freight-web/portal/src/utils/identifier.ts new file mode 100644 index 000000000..72677f73c --- /dev/null +++ b/apps/edr-freight-web/portal/src/utils/identifier.ts @@ -0,0 +1,22 @@ +/** Normalise Ethiopian local phone (09…/07…) to E.164; pass email through unchanged. */ +export function normaliseIdentifier(raw: string): string { + const v = raw.trim(); + const digits = v.replace(/\D/g, ""); + if (digits.length >= 9 && (v.startsWith("0") || v.startsWith("+251"))) { + const local = digits.startsWith("251") ? digits.slice(3) : digits.replace(/^0/, ""); + return `+251${local}`; + } + return v.toLowerCase(); +} + +/** Mask all but the first 7 chars of an E.164 phone for display. */ +export 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). */ +export 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}`; +}; diff --git a/apps/edr-freight-web/portal/src/utils/passwordSchema.ts b/apps/edr-freight-web/portal/src/utils/passwordSchema.ts new file mode 100644 index 000000000..d21d3f83e --- /dev/null +++ b/apps/edr-freight-web/portal/src/utils/passwordSchema.ts @@ -0,0 +1,36 @@ +import { z } from "zod"; + +/** Live checklist shown under the password field. Mirrors {@link passwordField}. */ +export const passwordRequirements = [ + { label: "At least 8 characters", test: (v: string) => v.length >= 8 }, + { label: "One uppercase letter", test: (v: string) => /[A-Z]/.test(v) }, + { label: "One lowercase letter", test: (v: string) => /[a-z]/.test(v) }, + { label: "One number", test: (v: string) => /\d/.test(v) }, + { label: "One special character", test: (v: string) => /[^A-Za-z0-9]/.test(v) }, +] as const; + +/** + * Must stay in step with IAM's `@IsStrongPassword()` on `InitialResetPasswordDto` + * — a password this accepts but the API rejects surfaces as an opaque 400. + */ +export const passwordField = z + .string() + .min(8, "Password must be at least 8 characters") + .regex(/[A-Z]/, "Password must include an uppercase letter") + .regex(/[a-z]/, "Password must include a lowercase letter") + .regex(/\d/, "Password must include a number") + .regex(/[^A-Za-z0-9]/, "Password must include a special character"); + +export const confirmPasswordField = z.string().min(1, "Please confirm your password"); + +export const samePassword = (data: { password: string; confirmPassword: string }) => + data.password === data.confirmPassword; + +export const PASSWORD_MISMATCH = { + message: "Passwords do not match", + path: ["confirmPassword"], +} as const; + +/** Every requirement in {@link passwordRequirements} is satisfied. */ +export const meetsAllRequirements = (value: string) => + passwordRequirements.every((r) => r.test(value)); From 3f7734fe1607d9a03986ffe7d573b6fe670bf206 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 9 Jul 2026 08:55:06 +0000 Subject: [PATCH 04/11] fixes --- .../src/pages/accounts/SetPasswordPage.tsx | 34 +++++++++++++++---- .../portal/src/pages/accounts/SignupPage.tsx | 6 ++-- .../portal/src/utils/passwordSchema.ts | 20 ++++++----- 3 files changed, 43 insertions(+), 17 deletions(-) diff --git a/apps/edr-freight-web/portal/src/pages/accounts/SetPasswordPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/SetPasswordPage.tsx index ee8dd1922..04625f2cd 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/SetPasswordPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/SetPasswordPage.tsx @@ -1,4 +1,13 @@ -import { Alert, Box, Button, Group, PasswordInput, Stack, Text, ThemeIcon } from "@mantine/core"; +import { + Alert, + Box, + Button, + Group, + PasswordInput, + Stack, + Text, + ThemeIcon, +} from "@mantine/core"; import { zodResolver } from "@hookform/resolvers/zod"; import { ArrowRight, Check, LockKeyhole, X } from "lucide-react"; import { useMemo, useState } from "react"; @@ -9,7 +18,6 @@ import { z } from "zod"; import useAuth from "@/hooks/useAuth"; import AuthLayout from "@/components/auth/AuthLayout"; import { - PASSWORD_MISMATCH, confirmPasswordField, passwordField, passwordRequirements, @@ -21,7 +29,10 @@ const passwordSchema = z password: passwordField, confirmPassword: confirmPasswordField, }) - .refine(samePassword, PASSWORD_MISMATCH); + .refine(samePassword, { + message: "Passwords do not match", + path: ["confirmPassword"], + }); type FormData = z.infer; @@ -44,7 +55,8 @@ export default function SetPasswordPage() { const password = watch("password"); const requirements = useMemo( - () => passwordRequirements.map((r) => ({ ...r, met: r.test(password || "") })), + () => + passwordRequirements.map((r) => ({ ...r, met: r.test(password || "") })), [password], ); @@ -83,11 +95,21 @@ export default function SetPasswordPage() { "Secure freight operations", "Advanced authentication system", ], - stats: { label: "Security Protection", value: "256-bit", footer: "Encrypted", progress: "w-[98%]" }, + stats: { + label: "Security Protection", + value: "256-bit", + footer: "Encrypted", + progress: "w-[98%]", + }, }} > - + diff --git a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx index fe2fb09cd..72dafe9f7 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx @@ -27,7 +27,6 @@ import PasswordChecklist from "@/components/auth/PasswordChecklist"; import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; import { api } from "@/services/api"; import { - PASSWORD_MISMATCH, confirmPasswordField, passwordField, samePassword, @@ -53,7 +52,10 @@ const userSchema = z password: passwordField, confirmPassword: confirmPasswordField, }) - .refine(samePassword, PASSWORD_MISMATCH); + .refine(samePassword, { + message: "Passwords do not match", + path: ["confirmPassword"], + }); type FormData = z.infer; diff --git a/apps/edr-freight-web/portal/src/utils/passwordSchema.ts b/apps/edr-freight-web/portal/src/utils/passwordSchema.ts index d21d3f83e..207d9dc2b 100644 --- a/apps/edr-freight-web/portal/src/utils/passwordSchema.ts +++ b/apps/edr-freight-web/portal/src/utils/passwordSchema.ts @@ -6,7 +6,10 @@ export const passwordRequirements = [ { label: "One uppercase letter", test: (v: string) => /[A-Z]/.test(v) }, { label: "One lowercase letter", test: (v: string) => /[a-z]/.test(v) }, { label: "One number", test: (v: string) => /\d/.test(v) }, - { label: "One special character", test: (v: string) => /[^A-Za-z0-9]/.test(v) }, + { + label: "One special character", + test: (v: string) => /[^A-Za-z0-9]/.test(v), + }, ] as const; /** @@ -21,15 +24,14 @@ export const passwordField = z .regex(/\d/, "Password must include a number") .regex(/[^A-Za-z0-9]/, "Password must include a special character"); -export const confirmPasswordField = z.string().min(1, "Please confirm your password"); +export const confirmPasswordField = z + .string() + .min(1, "Please confirm your password"); -export const samePassword = (data: { password: string; confirmPassword: string }) => - data.password === data.confirmPassword; - -export const PASSWORD_MISMATCH = { - message: "Passwords do not match", - path: ["confirmPassword"], -} as const; +export const samePassword = (data: { + password: string; + confirmPassword: string; +}) => data.password === data.confirmPassword; /** Every requirement in {@link passwordRequirements} is satisfied. */ export const meetsAllRequirements = (value: string) => From a5bc9d843594ed10b9b838e4465f144a1edfddee Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 9 Jul 2026 11:27:33 +0000 Subject: [PATCH 05/11] fix: the company rejection on the approved ones --- .../company-change-request.repository.spec.ts | 88 +++++++++++++++++++ .../company-change-request.repository.ts | 13 ++- 2 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/companies/company-change-request.repository.spec.ts diff --git a/apps/edr-freight-api/src/modules/companies/company-change-request.repository.spec.ts b/apps/edr-freight-api/src/modules/companies/company-change-request.repository.spec.ts new file mode 100644 index 000000000..73271e0af --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/company-change-request.repository.spec.ts @@ -0,0 +1,88 @@ +import { Repository } from "typeorm"; + +import { CompanyChangeRequestRepository } from "./company-change-request.repository"; +import { + ChangeRequestStatus, + CompanyChangeRequest, +} from "./entities/company-change-request.entity"; + +type Row = Pick & { createdAt: Date }; + +const COMPANY_ID = "company-1"; + +/** + * Stands in for the TypeORM repository over a fixed set of rows, honouring the + * `where.status` filter and the `createdAt DESC` ordering findOne relies on. + */ +function mockRepositoryOver(rows: Row[]) { + return { + findOne: jest.fn( + ({ where }: { where: Partial & { companyId: string } }) => + Promise.resolve( + rows + .filter( + (row) => + where.companyId === COMPANY_ID && + (where.status === undefined || row.status === where.status), + ) + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0] ?? + null, + ), + ), + } as unknown as Repository; +} + +function subject(rows: Row[]) { + return new CompanyChangeRequestRepository(mockRepositoryOver(rows)); +} + +describe("CompanyChangeRequestRepository.findLatestOpenByCompanyId", () => { + const rejected: Row = { + id: "rejected", + status: ChangeRequestStatus.Rejected, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + }; + + it("returns the pending request when one is open", async () => { + const pending: Row = { + id: "pending", + status: ChangeRequestStatus.Pending, + createdAt: new Date("2026-01-02T00:00:00.000Z"), + }; + + const result = await subject([rejected, pending]).findLatestOpenByCompanyId( + COMPANY_ID, + ); + + expect(result?.id).toBe("pending"); + }); + + it("returns the latest rejected request when nothing is pending", async () => { + const result = await subject([rejected]).findLatestOpenByCompanyId( + COMPANY_ID, + ); + + expect(result?.id).toBe("rejected"); + }); + + it("returns null once a resubmit of a rejected request is approved", async () => { + const approved: Row = { + id: "approved", + status: ChangeRequestStatus.Approved, + createdAt: new Date("2026-01-02T00:00:00.000Z"), + }; + + const result = await subject([ + rejected, + approved, + ]).findLatestOpenByCompanyId(COMPANY_ID); + + expect(result).toBeNull(); + }); + + it("returns null when the company has no requests", async () => { + const result = await subject([]).findLatestOpenByCompanyId(COMPANY_ID); + + expect(result).toBeNull(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts b/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts index 24d988452..eb44d56cb 100644 --- a/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts @@ -28,18 +28,23 @@ export class CompanyChangeRequestRepository extends BaseRepository { const pending = await this.findPendingByCompanyId(companyId); if (pending) return pending; - return this.repository.findOne({ - where: { companyId, status: ChangeRequestStatus.Rejected }, + const latest = await this.repository.findOne({ + where: { companyId }, order: { createdAt: "DESC" }, }); + return latest?.status === ChangeRequestStatus.Rejected ? latest : null; } async findById(id: string): Promise { From 3f9dca5244dfa07bf838089737cbb208d2143ca6 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 9 Jul 2026 11:27:56 +0000 Subject: [PATCH 06/11] fix: notification mark as read --- .../notification-inbox/notification-inbox.controller.ts | 6 +++++- .../modules/notification-inbox/notifications.gateway.ts | 7 ++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.controller.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.controller.ts index 1d7fd27fc..80fecf1bf 100644 --- a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.controller.ts +++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.controller.ts @@ -13,8 +13,10 @@ import { Patch, Post, Query, + UseGuards, } from "@nestjs/common"; -import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard"; import { AuthUserPayload, @@ -24,6 +26,8 @@ import { ListNotificationsQueryDto } from "./dto/list-notifications-query.dto"; import { NotificationInboxService } from "./notification-inbox.service"; @ApiTags("notifications") +@ApiBearerAuth() +@UseGuards(JwtGuard) @Controller("notifications") export class NotificationInboxController { constructor(private readonly service: NotificationInboxService) {} diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.ts b/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.ts index c14dcbaa5..0c4704170 100644 --- a/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.ts +++ b/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.ts @@ -15,9 +15,10 @@ import { WsAuthService } from "./ws-auth.service"; /** * Server → client push for in-app notifications. Clients only *listen* (no - * `@SubscribeMessage` handlers), so the global HTTP JwtGuard never applies here; - * the handshake is authenticated in `handleConnection` and each socket joins a - * private `user:` room the service targets. + * `@SubscribeMessage` handlers), and `@UseGuards(JwtGuard)` on the REST + * controller does not cover WebSockets; the handshake is authenticated in + * `handleConnection` and each socket joins a private `user:` room the + * service targets. */ @WebSocketGateway({ namespace: NOTIFICATION_WS_NAMESPACE, From 80ef15b9dc56146d63e725fbc4d60b34e286a84d Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 9 Jul 2026 12:36:51 +0000 Subject: [PATCH 07/11] fix: onboarding back and contract blocking --- .../onboarding/OnboardingWizardDialog.tsx | 12 +- .../src/pages/accounts/CompanyProfileForm.tsx | 25 +- .../src/pages/contracts/ContractsList.tsx | 389 +++++++++++------- 3 files changed, 245 insertions(+), 181 deletions(-) diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx index ab00965fa..849ba50c5 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -286,9 +286,13 @@ export default function OnboardingWizardDialog({ }); }, [roles, nationality, startMutation]); - // Note: no "back to role selection" — once the draft is created the role(s) - // are fixed; the form's first-step Back is a no-op so progress never resets. - const handleBackToRoles = useCallback(() => { }, []); + // Back from the form's first step returns to nationality/role selection. + // Safe to re-enter: startOnboarding is idempotent — it reuses the existing + // draft, refreshes the nationality and creates only roles that don't exist yet. + const handleBackToRoles = useCallback(() => { + setStartError(null); + setPhase("nationality-role"); + }, []); // Save the current step's fields to the draft (PATCH /profile). Returns the // server error message on failure so the form can show it (e.g. duplicate TIN). @@ -359,7 +363,6 @@ 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 @@ -403,7 +406,6 @@ export default function OnboardingWizardDialog({ onSubmit: handleSubmit, isPending: finishMutation.isPending, onBack: handleBackToRoles, - hideFirstStepBack: true, initialStep: effectiveResumeStep, resyncOpen: opened, onStepChange: handleStepChange, diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index cc9f81a29..abfb61551 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -51,7 +51,6 @@ export default function CompanyProfileForm({ onBack, initialStep, resyncOpen, - hideFirstStepBack, onStepChange, onSaveStep, rehydrate, @@ -73,8 +72,6 @@ export default function CompanyProfileForm({ initialStep?: CompanyStep; /** When this flips true (dialog reopened), jump back to initialStep (furthest reached). */ resyncOpen?: boolean; - /** Hide the Back button on the first step (onboarding can't go back to role pick). */ - hideFirstStepBack?: boolean; /** Reports the active step so the parent can persist resume progress. */ onStepChange?: (step: CompanyStep) => void; /** Persist the current step's data before advancing; returns an error to show. */ @@ -514,10 +511,6 @@ export default function CompanyProfileForm({ else setStep(stepOrder[currentIdx - 1]); }; - // Back is hidden on the first step during onboarding (can't return to role - // selection); otherwise always available. - const showBack = !(hideFirstStepBack && step === "company"); - return ( <>
e.preventDefault()}> @@ -851,17 +844,13 @@ export default function CompanyProfileForm({ )} - {showBack ? ( - - ) : ( - - )} + + + + + + + + + + None of your profiles are active yet + + + + + Contracts can only be created under a profile EDR has + approved. You can continue, but every operation stays locked + until at least one profile is approved. + + + + {/* Summary strip */} @@ -378,7 +440,11 @@ export default function ContractsList() { - + No contracts yet. Create one from New Contract. @@ -400,154 +466,159 @@ export default function ContractsList() { const isOpen = expanded.has(c.id); return ( - navigate(`/contracts/${c.id}`)} - > - - { - e.stopPropagation(); - toggleExpanded(c.id); - }} - style={{ - display: "flex", - alignItems: "center", - justifyContent: "center", - width: 28, - height: 28, - borderRadius: 8, - border: `1px solid ${BORDER}`, - background: isOpen ? GREEN : "#FFFFFF", - color: isOpen ? "#FFFFFF" : MUTED, - cursor: "pointer", - transition: "all 140ms ease", - }} - > - navigate(`/contracts/${c.id}`)} + > + + { + e.stopPropagation(); + toggleExpanded(c.id); }} - /> - - - - - {c.reference} - - - {isContainer ? "Containerised" : "Bulk"} - - - - - {isGeneral ? "General" : "One-Time"} - - - - - {isContainer ? ( - - ) : ( - - )} - - {isContainer ? "Container" : "Bulk"} + style={{ + display: "flex", + alignItems: "center", + justifyContent: "center", + width: 28, + height: 28, + borderRadius: 8, + border: `1px solid ${BORDER}`, + background: isOpen ? GREEN : "#FFFFFF", + color: isOpen ? "#FFFFFF" : MUTED, + cursor: "pointer", + transition: "all 140ms ease", + }} + > + + + + + + {c.reference} - - - - - {origin}{" "} - - → - {" "} - {destination} - {count > 1 && ( - - {" "} - +{count - 1} + + {isContainer ? "Containerised" : "Bulk"} + + + + + {isGeneral ? "General" : "One-Time"} + + + + + {isContainer ? ( + + ) : ( + + )} + + {isContainer ? "Container" : "Bulk"} - )} - - - - - {tradeLabel} - - - - - {c.paymentCurrency ?? "—"} - - - - - {c.createdAt - ? new Date(c.createdAt).toLocaleDateString() - : "—"} - - - - - {c.contractValidUntil - ? new Date( + + + + + {origin}{" "} + + → + {" "} + {destination} + {count > 1 && ( + + {" "} + +{count - 1} + + )} + + + + + {tradeLabel} + + + + + {c.paymentCurrency ?? "—"} + + + + + {c.createdAt + ? new Date(c.createdAt).toLocaleDateString() + : "—"} + + + + + {c.contractValidUntil + ? new Date( c.contractValidUntil, ).toLocaleDateString() - : "—"} - - - - - - - - e.stopPropagation()} - /> - - - - - {isOpen && ( - - - + : "—"} + + + + + + + + e.stopPropagation()} + /> + + - )} + {isOpen && ( + + + + + + )} ); })} @@ -564,7 +635,10 @@ export default function ContractsList() { gap="md" px={20} py={14} - style={{ borderTop: `1px solid ${BORDER}`, background: "#FCFDFE" }} + style={{ + borderTop: `1px solid ${BORDER}`, + background: "#FCFDFE", + }} > @@ -574,8 +648,7 @@ export default function ContractsList() { data={["10", "25", "50"]} value={String(pagination.pageSize)} onChange={(v) => - v && - setPagination({ pageIndex: 0, pageSize: Number(v) }) + v && setPagination({ pageIndex: 0, pageSize: Number(v) }) } radius="md" size="xs" From eb819d66a1a3993c182ddcdf5c82fe49a652eafa Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 9 Jul 2026 13:10:32 +0000 Subject: [PATCH 08/11] Enhance wagon capacity handling and validation in booking service - Added default capacities for container and bulk wagons. - Updated wagonsFor method to consider weight and length for wagon calculations. - Improved handling of overweight bookings with appropriate warnings. - Adjusted tests to reflect changes in wagon capacity and validation logic. --- .../booking-batch.constants.ts | 10 +++ .../booking-batch.service.spec.ts | 73 +++++++++++++++++ .../train-scheduling/booking-batch.service.ts | 78 ++++++++++++++----- .../dto/assign-bookings.dto.ts | 2 +- .../train-scheduling.service.spec.ts | 10 +-- .../train-scheduling.service.ts | 8 +- 6 files changed, 155 insertions(+), 26 deletions(-) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts index 1ff1880b7..62ed50c0a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts @@ -34,3 +34,13 @@ export const DEFAULT_CONTAINER_WAGON_TARE_TONS = 22.4; /** Default CW3 gondola tare for bulk bookings (T). */ export const DEFAULT_BULK_WAGON_TARE_TONS = 23.4; + +/** + * Fallback rated payloads (T) matching the tare fallbacks above. A bulk booking's + * wagon count is its cargo divided by this, so a zero here would make the count + * infinite — callers must floor it at a positive number. + */ +export const DEFAULT_CONTAINER_WAGON_CAPACITY_TONS = 70; + +/** Default CW3 gondola rated payload for bulk bookings (T). */ +export const DEFAULT_BULK_WAGON_CAPACITY_TONS = 60; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index b7156880f..a20ef600d 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -731,3 +731,76 @@ describe('BookingBatchService — PAID reconcile', () => { }); }); }); + +describe('BookingBatchService — wagonsFor', () => { + // wagonsFor is pure arithmetic over its two arguments and touches no injected + // dependency, so the service can be built with none. + const service = new BookingBatchService( + null as never, + null as never, + null as never, + null as never, + null as never, + null as never, + null as never, + null as never, + null as never, + ) as unknown as { + wagonsFor(booking: unknown, dims: unknown): number; + }; + + // PW2 box wagon: 70T rated payload, 25.2T tare, 17.066m. + const dims = { + container: { lengthMeters: 13.966, tareWeightTons: 22.4, capacityTons: 70 }, + bulk: { lengthMeters: 17.066, tareWeightTons: 25.2, capacityTons: 70 }, + }; + + const bulk = (cargoTons: number, over: Record = {}) => ({ + freightType: 'BULK', + cargoTotalWeightVgm: cargoTons, + bookingContainers: [], + ...over, + }); + + it('sizes a bulk booking by cargo ÷ rated payload, not a flat 1 wagon', () => { + // 37 × 1400 fertilizer packages × 50kg = 2590T of cargo. + expect(service.wagonsFor(bulk(2590), dims)).toBe(37); + }); + + it('rounds a partial wagon up', () => { + expect(service.wagonsFor(bulk(70.1), dims)).toBe(2); + expect(service.wagonsFor(bulk(70), dims)).toBe(1); + }); + + it('still floors at one wagon when a bulk booking has no recorded cargo', () => { + expect(service.wagonsFor(bulk(0), dims)).toBe(1); + }); + + it('honours an explicit wagonsRequired override', () => { + expect(service.wagonsFor(bulk(2590, { wagonsRequired: 40 }), dims)).toBe(40); + }); + + it('takes the binding axis for containers: weight can exceed TEU geometry', () => { + // Two 40ft units => 2 wagons by TEU geometry, but 210T needs 3 at 70T each. + const booking = { + freightType: 'CONTAINER', + cargoTotalWeightVgm: 210, + bookingContainers: [ + { quantity: 2, wagonsRequired: 2, containerType: { wagonsPerUnit: 1, sizeFt: 40 } }, + ], + }; + expect(service.wagonsFor(booking, dims)).toBe(3); + }); + + it('keeps TEU geometry when it binds before weight', () => { + // Four 20ft units => 2 wagons by geometry; 40T of cargo needs only 1 by weight. + const booking = { + freightType: 'CONTAINER', + cargoTotalWeightVgm: 40, + bookingContainers: [ + { quantity: 4, wagonsRequired: 2, containerType: { wagonsPerUnit: 0.5, sizeFt: 20 } }, + ], + }; + expect(service.wagonsFor(booking, dims)).toBe(2); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index d043fed32..93227f3ab 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -29,8 +29,10 @@ import { BillingService } from "../billing/billing.service"; import { + DEFAULT_BULK_WAGON_CAPACITY_TONS, DEFAULT_BULK_WAGON_LENGTH_METERS, DEFAULT_BULK_WAGON_TARE_TONS, + DEFAULT_CONTAINER_WAGON_CAPACITY_TONS, DEFAULT_CONTAINER_WAGON_LENGTH_METERS, DEFAULT_CONTAINER_WAGON_TARE_TONS, DEFAULT_WAGONS_PER_BOOKING, @@ -73,8 +75,8 @@ interface RouteDayGroup { * its length on the train and the tare it adds to the locomotive's gross load. */ type WagonDims = { - container: { lengthMeters: number; tareWeightTons: number }; - bulk: { lengthMeters: number; tareWeightTons: number }; + container: { lengthMeters: number; tareWeightTons: number; capacityTons: number }; + bulk: { lengthMeters: number; tareWeightTons: number; capacityTons: number }; }; export type BatchBoardBookingState = @@ -1917,8 +1919,9 @@ export class BookingBatchService implements OnModuleInit { "PREPAID", ); await this.notifier.payNow(booking, deadline); + const reservedWagons = this.wagonsFor(booking, await this.loadWagonDims()); this.logger.log( - `[BATCH] RESERVED ${booking.reference} (${this.wagonsFor(booking)}w, ` + + `[BATCH] RESERVED ${booking.reference} (${reservedWagons}w, ` + `priority ${booking.priorityScore ?? 0}) on schedule ${scheduleId} — ` + `pay by ${deadline.toISOString()}`, ); @@ -2235,12 +2238,21 @@ export class BookingBatchService implements OnModuleInit { const containers = (b: Booking): number => (b.bookingContainers ?? []).reduce((sum, c) => sum + Number(c.quantity ?? 0), 0); const totalContainers = containers(primary) + containers(partner); - const sharedWagons = - totalContainers > 0 - ? Math.ceil(totalContainers / MAX_TEU_SLOTS_PER_WAGON) - : this.wagonsFor(primary) + this.wagonsFor(partner); const cargoTons = Number(primary.cargoTotalWeightVgm ?? 0) + Number(partner.cargoTotalWeightVgm ?? 0); + + // Consolidation shares TEU slots, never rated payload: the pair still needs + // enough wagons to carry its combined cargo, so the weight axis bounds the + // shared count exactly as it bounds an individual booking's. + const capacityTons = this.capacityFor(primary.freightType, wagonDims); + const byWeight = + cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0; + const byLength = + totalContainers > 0 + ? Math.ceil(totalContainers / MAX_TEU_SLOTS_PER_WAGON) + : this.wagonsFor(primary, wagonDims) + this.wagonsFor(partner, wagonDims); + const sharedWagons = Math.max(byLength, byWeight); + return { wagons: sharedWagons, // Consolidation saves tare as well as slots: the pair rides `sharedWagons` @@ -2272,19 +2284,43 @@ export class BookingBatchService implements OnModuleInit { }; } - private wagonsFor(booking: Booking): number { + /** + * Wagons a booking occupies. Two axes bind independently and the booking needs + * enough wagons to satisfy BOTH, so the count is the larger of: + * + * weight — ceil(cargoTons / wagonType.capacityTons), the rated payload + * length — TEU geometry, two 20ft to a wagon (container bookings only) + * + * The weight axis was missing entirely. A BULK booking carries no container + * lines, so `containerWagonsForLines` returned 0 and every bulk booking + * collapsed to a single wagon no matter its tonnage — a 2590T fertilizer + * booking counted as 1 wagon, and `needFor` then charged 1 tare instead of 37. + * That under-reported the board and let the fill loop overbook the train. + */ + private wagonsFor(booking: Booking, wagonDims: WagonDims): number { if (booking.wagonsRequired && booking.wagonsRequired > 0) { return Math.ceil(booking.wagonsRequired); } - // booking.wagonsRequired is NULL for most rows (only set on certain - // scheduling paths). Derive from the container lines, TEU-aware: two 20ft - // share one wagon (wagonsPerUnit = 0.5). The old fallback summed raw - // container QUANTITY, so 20×20ft counted as 20 wagons instead of 10 and - // wrongly filled the train. - const fromContainers = containerWagonsForLines( - booking.bookingContainers ?? [], - ); - return Math.max(DEFAULT_WAGONS_PER_BOOKING, fromContainers); + + // TEU-aware: two 20ft share one wagon (wagonsPerUnit = 0.5). The old fallback + // summed raw container QUANTITY, so 20×20ft counted as 20 wagons, not 10. + const byLength = containerWagonsForLines(booking.bookingContainers ?? []); + + const capacityTons = this.capacityFor(booking.freightType, wagonDims); + const cargoTons = Number(booking.cargoTotalWeightVgm ?? 0); + const byWeight = + cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0; + + return Math.max(DEFAULT_WAGONS_PER_BOOKING, byLength, byWeight); + } + + private capacityFor( + freightType: string | null | undefined, + wagonDims: WagonDims, + ): number { + return freightType === "BULK" + ? wagonDims.bulk.capacityTons + : wagonDims.container.capacityTons; } /** @@ -2296,7 +2332,7 @@ export class BookingBatchService implements OnModuleInit { * 37-wagon box-wagon train read 2590T when it really weighed 3522T. */ private needFor(booking: Booking, wagonDims: WagonDims): Capacity { - const wagons = this.wagonsFor(booking); + const wagons = this.wagonsFor(booking, wagonDims); return { wagons, weightTons: bookingGrossWeightTons( @@ -2402,14 +2438,20 @@ export class BookingBatchService implements OnModuleInit { ); const nw5 = byCode.get("NW5"); const cw3 = byCode.get("CW3"); + // capacityTons divides a bulk booking's cargo, so a 0 or missing rated payload + // must fall back rather than yield an infinite wagon count. + const payload = (value: number | undefined, fallback: number): number => + value && value > 0 ? value : fallback; return { container: { lengthMeters: nw5?.lengthMeters ?? DEFAULT_CONTAINER_WAGON_LENGTH_METERS, tareWeightTons: nw5?.tareWeightTons ?? DEFAULT_CONTAINER_WAGON_TARE_TONS, + capacityTons: payload(nw5?.capacityTons, DEFAULT_CONTAINER_WAGON_CAPACITY_TONS), }, bulk: { lengthMeters: cw3?.lengthMeters ?? DEFAULT_BULK_WAGON_LENGTH_METERS, tareWeightTons: cw3?.tareWeightTons ?? DEFAULT_BULK_WAGON_TARE_TONS, + capacityTons: payload(cw3?.capacityTons, DEFAULT_BULK_WAGON_CAPACITY_TONS), }, }; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/assign-bookings.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/assign-bookings.dto.ts index b5e93f5da..1fd1e9a08 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/assign-bookings.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/assign-bookings.dto.ts @@ -51,7 +51,7 @@ export class AssignBookingsDto { @IsUUID('4', { each: true }) bookingIds!: string[]; - @ApiPropertyOptional({ description: 'Bypass soft hold and overweight warnings' }) + @ApiPropertyOptional({ description: 'Suppress soft hold and overweight warnings' }) @IsOptional() @IsBoolean() forceAssign?: boolean; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts index b7938857f..e97b3de56 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -72,7 +72,7 @@ const makeBooking = ( wagonsRequired, vgmPerUnitTons: weight / quantity, isOverweight: false, - containerType: { code: containerCode, label: containerCode }, + containerType: { code: containerCode, label: containerCode, wagonTypeId: nw5.id }, }, ], ...extra, @@ -282,7 +282,7 @@ describe('TrainSchedulingService', () => { expect(result.warnings[0]).toContain('soft hold window'); }); - it('flags the overweight booking as invalid', async () => { + it('warns on the overweight booking but still allows scheduling', async () => { const bookings = [ makeBooking('b6', 'BKG-CONT-006', 3600, 80, '40FT', 80, undefined, undefined, undefined, { bookingContainers: [ @@ -293,7 +293,7 @@ describe('TrainSchedulingService', () => { wagonsRequired: 80, vgmPerUnitTons: 45, isOverweight: true, - containerType: { code: '40FT', label: '40FT' }, + containerType: { code: '40FT', label: '40FT', wagonTypeId: nw5.id }, }, ], }), @@ -311,8 +311,8 @@ describe('TrainSchedulingService', () => { destinationStationId: 'yard-destination', }); - expect(result.valid).toBe(false); - expect(result.violations.some((v) => v.includes('overweight'))).toBe(true); + expect(result.violations.some((v) => v.includes('overweight'))).toBe(false); + expect(result.warnings.some((w) => w.includes('overweight'))).toBe(true); }); it('allows preview when bookings are already on the target schedule', async () => { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index d96c8062e..00b1bf02a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -2802,10 +2802,14 @@ export class TrainSchedulingService { `Booking ${booking.reference} is within the soft hold window (expires ${booking.holdExpiresAt?.toISOString()})`, ); } + // Overweight is the soft threshold (maxVgmTons): the customer already + // paid the overweight surcharge at booking. The hard ceiling + // (maxCapacityTons) blocks booking creation, so anything reaching + // scheduling is shippable — warn the planner, never block allocation. const overweightLines = (booking.bookingContainers ?? []).filter((c) => c.isOverweight); if (overweightLines.length) { - violations.push( - `Booking ${booking.reference} has overweight container lines; use forceAssign to override`, + warnings.push( + `Booking ${booking.reference} has ${overweightLines.length} overweight container line(s); overweight surcharge applied`, ); } } From 3afadcd9f2253d30361ebdf36ab02e90f8780d8a Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 9 Jul 2026 13:36:36 +0000 Subject: [PATCH 09/11] small fix --- .../modules/train-scheduling/train-scheduling.service.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 00b1bf02a..b72194299 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -1073,11 +1073,16 @@ export class TrainSchedulingService { containerPlacements ?? [], ); + // The link above puts these bookings on the train: they are SCHEDULED, not + // ELIGIBLE. Leaving them ELIGIBLE re-offers an allocated booking to the next + // batch fill, which unlinks it and frees its wagons on the next window cycle. + const scheduledAt = new Date(); for (const booking of bookings) { await this.bookingsRepository.updateSchedulingFields( booking.id, { - schedulingStatus: SchedulingStatus.Eligible, + schedulingStatus: SchedulingStatus.Scheduled, + scheduledAt, wagonsRequired: sumWagonsRequired(booking), }, manager, From 70a974263104b6f5c6838f7edde88abb29573a6d Mon Sep 17 00:00:00 2001 From: natib21 Date: Thu, 9 Jul 2026 14:01:59 +0000 Subject: [PATCH 10/11] add gps readme --- .../src/modules/gps-tracking/README.md | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 apps/edr-freight-api/src/modules/gps-tracking/README.md diff --git a/apps/edr-freight-api/src/modules/gps-tracking/README.md b/apps/edr-freight-api/src/modules/gps-tracking/README.md new file mode 100644 index 000000000..668b581b8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/gps-tracking/README.md @@ -0,0 +1,150 @@ +# GPS Tracking (GT06) — Operations & Device Configuration + +GT06 trackers speak a **raw TCP binary protocol**, not HTTP/HTTPS. This shapes +everything about how the service is deployed and how devices are pointed at it. + +--- + +## 1. Why GPS needs its own dedicated TCP port + +- **Not HTTP.** GT06 devices send binary frames + (`0x78 0x78 | len | protocol | payload | serial | CRC16 | 0x0D 0x0A`). + An HTTP server receiving these answers `400 Bad Request` and closes. +- **Dedicated port required.** A listening socket is keyed on `(IP, port)`; two + listeners on the same pair collide (`EADDRINUSE`). The REST API already owns + its port, so GPS traffic needs a separate one. +- **No hostname routing.** GT06 frames carry no `Host` header and no TLS SNI, so + L7 proxies (Nginx `http`, AWS ALB, Cloudflare proxy) cannot route them by + domain. Routing must happen at **Layer 4 (TCP)** by port. +- **DNS carries no port.** An A record maps a name to an IP only. The tracker + config must state the port explicitly (e.g. `gps.example.com:5023`). + +### Operational requirements + +| Item | Value | +| --- | --- | +| Protocol | Raw TCP (not HTTP, not TLS) | +| Default port | `5023` (configurable via `GT06_TCP_PORT`) | +| Listener bind | `0.0.0.0` inside the `freight-gps` container | +| Edge terminator | **L4** — AWS NLB or Nginx `stream {}`. **Not** ALB / Cloudflare proxy. | + +--- + +## 2. Port configuration + +`5023` is only this project's default — **not** a GT06 protocol requirement. The +listener binds whatever `GT06_TCP_PORT` says, as long as trackers are configured +with the same number. + +Host and container ports are decoupled in `docker-compose.yaml`: + +```yaml +freight-gps: + ports: + - "${GT06_TCP_PORT:-5023}:5023" # host is configurable; container fixed + environment: + GT06_TCP_PORT: "5023" # pinned inside the container +``` + +- The **container** always listens on `5023`. +- The **host/public** port is configurable (443, 5023, 9000, …) via the root + `.env`'s `GT06_TCP_PORT`. +- This split is required because the image runs as a **non-root** user + (`nestjs`, uid 1001), which cannot bind ports `<1024`. Docker (root) binds the + host port and forwards to `5023` inside. +- Running **outside Docker** (`pnpm dev:gps`, systemd), `GT06_TCP_PORT` is the + actual bind port, so `<1024` needs root or `CAP_NET_BIND_SERVICE`. +- **443 is allowed but risky:** GT06 stays raw TCP, not TLS. Middleboxes that + expect a TLS handshake on 443 may drop the connection. + +--- + +## 3. Deployment topology + +The GT06 listener runs as its own process (`dist/main.gps.js`, module +`GpsIngestModule`) — DB + GPS only, no HTTP server. It shares the `edr_freight` +DB with the API; the DB is the seam (ingester writes `gps_devices` / +`gps_positions`, API reads them). + +``` +freight-api HTTP :3001 GT06_TCP_PORT=0 (listener off, applies migrations) +freight-gps TCP :5023 DB_MIGRATIONS_RUN=false (owns the tracker socket) +``` + +`DB_MIGRATIONS_RUN=false` keeps the second process from racing migrations. + +Horizontal scale: each tracker holds one long-lived TCP connection with +per-socket session state, so N `freight-gps` replicas can run behind an L4 LB — +each device sticks to one replica. `ensureDevice` is safe under concurrency +(unique IMEI). + +--- + +## 4. Device configuration (GT06 side) + +Config is done by **SMS to the tracker's SIM**. Commands below are the canonical +Concox/GT06 set — **verify against your unit's sheet**, syntax varies by firmware. +Default command password is usually `123456`. + +Prep: data-enabled SIM, SMS on, **SIM PIN off**, know your carrier APN. + +``` +STATUS# # 1. sanity check — returns GSM/GPS/batt/GPRS +APN,# # 2. carrier data APN (add ,user,pass if needed) +SERVER,1,gps.example.com,5023,0# # 3. point at server (1=domain). Port MUST match GT06_TCP_PORT +GPRSON,1# # 4. enable data +GPSON,1# # enable GPS +TIMER,10# # 5. upload interval, seconds (some use UPLOAD,10#) +RESET# # 6. reboot so it reconnects (many cache DNS until reboot) +``` + +Raw-IP variant of step 3: `SERVER,0,203.0.113.50,5023,0#` +Custom host port (e.g. 443): `SERVER,1,gps.example.com,443,0#` + +### Verify from the server + +```bash +docker compose logs -f freight-gps | grep -Ei "login|Auto-registering|ingester up" +nc -vz gps.example.com 5023 +curl -H "Authorization: Bearer " https://api.example.com/api/gps/positions/latest +``` + +First login packet **auto-registers** the IMEI (no manual step). `online:true` +only when `lastSeenAt` < 5 min (computed at read time). + +### Link a tracker to a vehicle (optional) + +Auto-register leaves `vehicleId` null. Attach it (needs `tracking.manage`): + +``` +PATCH /api/gps/devices/:id { "vehicleId": "", "name": "Truck 03-ET" } +``` + +### Failure map + +| Symptom | Cause | +| --- | --- | +| No SMS reply | SIM PIN on / no signal / wrong number | +| Replies but never connects | APN wrong, or `SERVER` port ≠ `GT06_TCP_PORT` | +| Connects then drops | server not ACKing, or middlebox on 443 expecting TLS | +| Registered but `online:false` | packets blocked by firewall — open inbound TCP | +| Wrong location / `positioned:false` | no GPS fix yet — open sky, cold start ~1–2 min | + +--- + +## 5. Security + +- GT06 authenticates with **IMEI only**, which is **spoofable**. Anyone who can + reach the port can inject fake positions. +- **Do not** expose the port to `0.0.0.0/0`. Restrict at the firewall / security + group to the SIM provider's **APN / IP range**. +- Trackers must use the same host+port as the server: + `SERVER,1,gps.example.com,,0#`. + +--- + +## 6. Edge (L4) termination + +See [`infrastructure/nginx/gps-stream.conf`](../../../../../infrastructure/nginx/gps-stream.conf) +for an Nginx `stream {}` example, and the AWS NLB notes in the same file. +Reminder: **L4 only** — an HTTP proxy cannot route GT06. From 3259bd76676747a9b76abea148bc84302ad1e84b Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 9 Jul 2026 14:10:46 +0000 Subject: [PATCH 11/11] fix ui issue --- .../trainScheduling/PriorityTrackingTab.tsx | 4 ++-- .../BatchScheduleDetailPage.tsx | 22 +++++++++---------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/PriorityTrackingTab.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/PriorityTrackingTab.tsx index 84367fad7..00b0abd87 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/PriorityTrackingTab.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/PriorityTrackingTab.tsx @@ -207,9 +207,9 @@ function RankedCard({ {/* Wagons */} - + {/* {booking.wagons}w - + */} {/* State chip / pay countdown */} diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx index 3684dd1e7..a150978d4 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx @@ -20,7 +20,7 @@ import { AlertTriangle, ArrowLeft, ArrowLeftRight, - Boxes, + // Boxes, CalendarDays, CheckCircle2, ClipboardCheck, @@ -348,9 +348,9 @@ const BOOKING_COLUMNS: ColumnDef[] = [ const b = row.original; return ( - + {/* {b.wagons}w - + */} {fmtTons(b.weightTons)} @@ -890,14 +890,14 @@ export default function BatchScheduleDetailPage() {