mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 14:15:44 +00:00
@@ -0,0 +1,36 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attaches an eTrade business licence to each operational profile.
|
||||||
|
*
|
||||||
|
* A TIN routinely holds a dozen or more licences, split by activity ("Export
|
||||||
|
* trade in coffee", "Freight Forwarders"), and until now the company picked one
|
||||||
|
* for the whole record — every role shared it. Each profile now names the
|
||||||
|
* business it actually operates as.
|
||||||
|
*
|
||||||
|
* Stored as a snapshot ({@link ETradeBusinessOption}: licenceNumber, tradeName,
|
||||||
|
* activity, renewedTo) rather than a bare licence number, so the portal and the
|
||||||
|
* backoffice can show which business is attached without an eTrade round-trip —
|
||||||
|
* eTrade is slow, serves a broken TLS chain, and is regularly down.
|
||||||
|
*
|
||||||
|
* Nullable: existing profiles have none until the customer attaches one, and a
|
||||||
|
* co-operative or investor-licence company has no eTrade record at all.
|
||||||
|
* Deliberately NOT unique — one business can back several profiles.
|
||||||
|
*/
|
||||||
|
export class CompanyProfileEtradeBusiness3760000000000 implements MigrationInterface {
|
||||||
|
name = 'CompanyProfileEtradeBusiness3760000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.company_profiles
|
||||||
|
ADD COLUMN IF NOT EXISTS etrade_business jsonb
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.company_profiles
|
||||||
|
DROP COLUMN IF EXISTS etrade_business
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
|
import { In, Repository } from "typeorm";
|
||||||
|
|
||||||
|
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||||
|
|
||||||
|
import { ExternalProfile } from "../companies/entities/external-profile.entity";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One portal login belonging to a customer company: the company-side profile
|
||||||
|
* joined to the IAM account that actually signs in.
|
||||||
|
*
|
||||||
|
* The two halves drift apart routinely — `company.email` is business contact
|
||||||
|
* detail, while `email` here is the credential a reset link goes to — which is
|
||||||
|
* exactly why staff need to see the IAM side rather than the company row.
|
||||||
|
*/
|
||||||
|
export interface CustomerAccount {
|
||||||
|
/** external_profiles.id */
|
||||||
|
profileId: string;
|
||||||
|
userId: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
jobTitle: string | null;
|
||||||
|
isPrimaryContact: boolean;
|
||||||
|
onboardingStep: string | null;
|
||||||
|
onboardingCompleted: boolean;
|
||||||
|
/** Null when the profile points at a user row that no longer exists. */
|
||||||
|
username: string | null;
|
||||||
|
email: string | null;
|
||||||
|
phoneNumber: string | null;
|
||||||
|
phoneVerified: boolean | null;
|
||||||
|
/** IAM account status (`EUserStatus`), surfaced as-is. */
|
||||||
|
status: string | null;
|
||||||
|
isActive: boolean | null;
|
||||||
|
/** False means the account was created but never activated by its owner. */
|
||||||
|
hasSetPassword: boolean | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CustomerAccountsService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(ExternalProfile)
|
||||||
|
private readonly profiles: Repository<ExternalProfile>,
|
||||||
|
@InjectRepository(User)
|
||||||
|
private readonly users: Repository<User>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every portal account for a company, primary contact first.
|
||||||
|
*
|
||||||
|
* Deliberately NOT filtered to active accounts: a suspended or never-activated
|
||||||
|
* login is the case staff are usually looking into, and hiding it would leave
|
||||||
|
* "the customer says they can't log in" unanswerable from this screen.
|
||||||
|
*/
|
||||||
|
async listForCompany(companyId: string): Promise<CustomerAccount[]> {
|
||||||
|
const profiles = await this.profiles.find({ where: { companyId } });
|
||||||
|
if (profiles.length === 0) return [];
|
||||||
|
|
||||||
|
const userIds = profiles.map((p) => p.userId).filter(Boolean);
|
||||||
|
// Explicit select: the User entity's relations include credentials and
|
||||||
|
// sessions, and this response goes to a browser.
|
||||||
|
const users = userIds.length
|
||||||
|
? await this.users
|
||||||
|
.createQueryBuilder("user")
|
||||||
|
.select([
|
||||||
|
"user.id",
|
||||||
|
"user.username",
|
||||||
|
"user.email",
|
||||||
|
"user.phoneNumber",
|
||||||
|
"user.isPhoneNumberVerified",
|
||||||
|
"user.status",
|
||||||
|
"user.isActive",
|
||||||
|
"user.hasSetPassword",
|
||||||
|
])
|
||||||
|
.where({ id: In(userIds) })
|
||||||
|
.getMany()
|
||||||
|
: [];
|
||||||
|
const byId = new Map(users.map((u) => [u.id, u]));
|
||||||
|
|
||||||
|
return profiles
|
||||||
|
.map((p) => {
|
||||||
|
const user = byId.get(p.userId);
|
||||||
|
return {
|
||||||
|
profileId: p.id,
|
||||||
|
userId: p.userId,
|
||||||
|
firstName: p.firstName,
|
||||||
|
lastName: p.lastName,
|
||||||
|
jobTitle: p.jobTitle ?? null,
|
||||||
|
isPrimaryContact: p.isPrimaryContact,
|
||||||
|
onboardingStep: p.onboardingStep ?? null,
|
||||||
|
onboardingCompleted: p.onboardingCompleted ?? false,
|
||||||
|
username: user?.username ?? null,
|
||||||
|
email: user?.email ?? null,
|
||||||
|
phoneNumber: user?.phoneNumber ?? null,
|
||||||
|
phoneVerified: user?.isPhoneNumberVerified ?? null,
|
||||||
|
status: user?.status ?? null,
|
||||||
|
isActive: user?.isActive ?? null,
|
||||||
|
hasSetPassword: user?.hasSetPassword ?? null,
|
||||||
|
createdAt: p.createdAt,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
// Primary contact first — it is the account every staff action
|
||||||
|
// (password reset, notifications) actually targets.
|
||||||
|
if (a.isPrimaryContact !== b.isPrimaryContact) {
|
||||||
|
return a.isPrimaryContact ? -1 : 1;
|
||||||
|
}
|
||||||
|
return a.createdAt.getTime() - b.createdAt.getTime();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,10 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
|||||||
import { BookingStaff } from "../../common/booking-guards";
|
import { BookingStaff } from "../../common/booking-guards";
|
||||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||||
import { BackofficeResetPasswordDto } from "./dto/forgot-password.dto";
|
import { BackofficeResetPasswordDto } from "./dto/forgot-password.dto";
|
||||||
|
import {
|
||||||
|
CustomerAccount,
|
||||||
|
CustomerAccountsService,
|
||||||
|
} from "./customer-accounts.service";
|
||||||
import {
|
import {
|
||||||
CustomerResetService,
|
CustomerResetService,
|
||||||
CustomerResetTarget,
|
CustomerResetTarget,
|
||||||
@@ -25,7 +29,22 @@ import {
|
|||||||
@Controller("backoffice/customers")
|
@Controller("backoffice/customers")
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
export class CustomerResetController {
|
export class CustomerResetController {
|
||||||
constructor(private readonly customerResetService: CustomerResetService) {}
|
constructor(
|
||||||
|
private readonly customerResetService: CustomerResetService,
|
||||||
|
private readonly customerAccountsService: CustomerAccountsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get(":companyId/accounts")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.customers.view)
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"The portal login accounts belonging to a customer, primary contact first",
|
||||||
|
})
|
||||||
|
async accounts(
|
||||||
|
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||||
|
): Promise<CustomerAccount[]> {
|
||||||
|
return this.customerAccountsService.listForCompany(companyId);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(":companyId/reset-target")
|
@Get(":companyId/reset-target")
|
||||||
@BookingStaff(FREIGHT_PERMS.customers.resetPassword)
|
@BookingStaff(FREIGHT_PERMS.customers.resetPassword)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { AccountController } from './account.controller';
|
|||||||
import { AccountService } from './account.service';
|
import { AccountService } from './account.service';
|
||||||
import { CheckAvailabilityController } from './check-availability.controller';
|
import { CheckAvailabilityController } from './check-availability.controller';
|
||||||
import { CheckAvailabilityService } from './check-availability.service';
|
import { CheckAvailabilityService } from './check-availability.service';
|
||||||
|
import { CustomerAccountsService } from './customer-accounts.service';
|
||||||
import { CustomerResetController } from './customer-reset.controller';
|
import { CustomerResetController } from './customer-reset.controller';
|
||||||
import { CustomerResetService } from './customer-reset.service';
|
import { CustomerResetService } from './customer-reset.service';
|
||||||
import { ForgotPasswordController } from './forgot-password.controller';
|
import { ForgotPasswordController } from './forgot-password.controller';
|
||||||
@@ -50,6 +51,7 @@ import { ListUsersService } from './list-users.service';
|
|||||||
CheckAvailabilityService,
|
CheckAvailabilityService,
|
||||||
ForgotPasswordService,
|
ForgotPasswordService,
|
||||||
CustomerResetService,
|
CustomerResetService,
|
||||||
|
CustomerAccountsService,
|
||||||
],
|
],
|
||||||
// Shipping-line registration mints activation links through the same
|
// Shipping-line registration mints activation links through the same
|
||||||
// staff-triggered reset path customers use.
|
// staff-triggered reset path customers use.
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { PaymentService } from "../payment/payment.service";
|
|||||||
import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
|
import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
|
||||||
import {
|
import {
|
||||||
InvoiceDocumentModel,
|
InvoiceDocumentModel,
|
||||||
|
sameCompanyName,
|
||||||
InvoiceDocumentService,
|
InvoiceDocumentService,
|
||||||
pngDataUrl,
|
pngDataUrl,
|
||||||
} from "./documents/invoice-document.service";
|
} from "./documents/invoice-document.service";
|
||||||
@@ -875,10 +876,21 @@ export class BillingService {
|
|||||||
totals.push({ label: "Paid", amount: Number(invoice.paidAmount) });
|
totals.push({ label: "Paid", amount: Number(invoice.paidAmount) });
|
||||||
totals.push({ label: "Balance", amount: Number(invoice.balanceAmount) });
|
totals.push({ label: "Balance", amount: Number(invoice.balanceAmount) });
|
||||||
|
|
||||||
|
const tradeName = invoice.companyProfile?.etradeBusiness?.tradeName?.trim();
|
||||||
|
|
||||||
const summary: InvoiceDocumentModel["summary"] = [
|
const summary: InvoiceDocumentModel["summary"] = [
|
||||||
// Buyer identity — was missing entirely; a MoR-registered invoice must show who it was
|
// Buyer identity — was missing entirely; a MoR-registered invoice must show who it was
|
||||||
// filed against, not just the seller. VatNumber shown only when the company has one.
|
// filed against, not just the seller. VatNumber shown only when the company has one.
|
||||||
{ label: "Buyer", value: invoice.company?.name ?? null },
|
{ label: "Buyer", value: invoice.company?.name ?? null },
|
||||||
|
// The trade name of the eTrade licence THIS profile operates as. A TIN
|
||||||
|
// holds many licences and the invoiced role (importer/exporter/forwarder)
|
||||||
|
// is usually a different business from the one the company registered
|
||||||
|
// under, so the buyer's name alone doesn't say which one was billed.
|
||||||
|
// Suppressed when it just repeats the buyer name — most companies trade
|
||||||
|
// under their registered name and a duplicate row helps nobody.
|
||||||
|
...(tradeName && !sameCompanyName(tradeName, invoice.company?.name)
|
||||||
|
? [{ label: "Buyer trade name", value: tradeName }]
|
||||||
|
: []),
|
||||||
{ label: "Buyer TIN", value: invoice.company?.tin ?? null },
|
{ label: "Buyer TIN", value: invoice.company?.tin ?? null },
|
||||||
...(invoice.company?.vatNumber
|
...(invoice.company?.vatNumber
|
||||||
? [{ label: "Buyer VAT No.", value: invoice.company.vatNumber }]
|
? [{ label: "Buyer VAT No.", value: invoice.company.vatNumber }]
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { InvoiceDocumentModel, InvoiceDocumentService } from "./invoice-document.service";
|
import { InvoiceDocumentModel, InvoiceDocumentService, sameCompanyName } from "./invoice-document.service";
|
||||||
|
|
||||||
const model = (over: Partial<InvoiceDocumentModel> = {}): InvoiceDocumentModel => ({
|
const model = (over: Partial<InvoiceDocumentModel> = {}): InvoiceDocumentModel => ({
|
||||||
kind: "INVOICE",
|
kind: "INVOICE",
|
||||||
@@ -87,3 +87,38 @@ describe("InvoiceDocumentService.buildThermalHtml", () => {
|
|||||||
expect(html).not.toContain("right: 160px");
|
expect(html).not.toContain("right: 160px");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("sameCompanyName", () => {
|
||||||
|
it("treats eTrade's legal-suffix spellings as the same name", () => {
|
||||||
|
expect(sameCompanyName("ABIJOEL PLC", "ABIJOEL P L C")).toBe(true);
|
||||||
|
expect(
|
||||||
|
sameCompanyName(
|
||||||
|
"WISH TRADING PLC",
|
||||||
|
"WISH TRADING PRIVATE LIMITED COMPANY",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
sameCompanyName("TUTA TRADING PLC", "TUTA TRADING ONE MEMBER PLC"),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a genuinely different trade name distinct", () => {
|
||||||
|
// Real pairs from eTrade: the licence trades under a different name than
|
||||||
|
// the company registered under, which is exactly the row worth printing.
|
||||||
|
expect(
|
||||||
|
sameCompanyName("Cozy Coffee Grower and Exporter", "ABIJOEL P L C"),
|
||||||
|
).toBe(false);
|
||||||
|
expect(sameCompanyName("MENNA PRODUCTION", "ICOFFEE TRADING PLC")).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
sameCompanyName("YUNABEK TRADING PLC", "YUNABEK INVESTMENT PLC"),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false when either side is missing, so no row is printed", () => {
|
||||||
|
expect(sameCompanyName("", "ABIJOEL P L C")).toBe(false);
|
||||||
|
expect(sameCompanyName(null, null)).toBe(false);
|
||||||
|
expect(sameCompanyName("ABIJOEL P L C", undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -48,6 +48,34 @@ function formatDate(value: unknown): string {
|
|||||||
return value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-";
|
return value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is this trade name just the company name again?
|
||||||
|
*
|
||||||
|
* Compared loosely on purpose: eTrade spells the same legal suffix as "PLC",
|
||||||
|
* "P L C" and "PRIVATE LIMITED COMPANY", and pads names with double spaces, so
|
||||||
|
* an exact comparison would call two spellings of one name different and print
|
||||||
|
* a redundant row. Used only to decide whether a trade-name row is worth
|
||||||
|
* showing — never to decide that two businesses ARE the same.
|
||||||
|
*/
|
||||||
|
export function sameCompanyName(
|
||||||
|
a: string | null | undefined,
|
||||||
|
b: string | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
const norm = (v: string | null | undefined) =>
|
||||||
|
(v ?? "")
|
||||||
|
.toUpperCase()
|
||||||
|
.replace(/[.,]/g, "")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim()
|
||||||
|
.replace(/\bPRIVATE LIMITED COMPANY\b/g, "PLC")
|
||||||
|
.replace(/\bP L C\b/g, "PLC")
|
||||||
|
.replace(/\bONE (MEMBER|PERSON) PLC\b/g, "PLC")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
const left = norm(a);
|
||||||
|
return left !== "" && left === norm(b);
|
||||||
|
}
|
||||||
|
|
||||||
/** One billed line on the document (charge type / fee type agnostic). */
|
/** One billed line on the document (charge type / fee type agnostic). */
|
||||||
export interface InvoiceDocumentLine {
|
export interface InvoiceDocumentLine {
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ import { CreateCompanyDto } from "./dto/create-company.dto";
|
|||||||
import { UpdateCompanyDto } from "./dto/update-company.dto";
|
import { UpdateCompanyDto } from "./dto/update-company.dto";
|
||||||
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
|
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
|
||||||
import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto";
|
import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto";
|
||||||
|
import type { ETradeBusinessOption } from "@edr/types";
|
||||||
import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto";
|
import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto";
|
||||||
|
import { AttachEtradeBusinessDto } from "./dto/attach-etrade-business.dto";
|
||||||
import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto";
|
import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto";
|
||||||
import {
|
import {
|
||||||
CompanyIdentityStateDto,
|
CompanyIdentityStateDto,
|
||||||
@@ -260,11 +262,42 @@ export class CompaniesController {
|
|||||||
): Promise<ResponseCompanyProfileDto[]> {
|
): Promise<ResponseCompanyProfileDto[]> {
|
||||||
const profiles = await this.companiesService.addCompanyProfilesForUser(
|
const profiles = await this.companiesService.addCompanyProfilesForUser(
|
||||||
user.id,
|
user.id,
|
||||||
dto.types,
|
dto.profiles,
|
||||||
);
|
);
|
||||||
return profiles.map((p) => new ResponseCompanyProfileDto(p));
|
return profiles.map((p) => new ResponseCompanyProfileDto(p));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get("etrade-businesses")
|
||||||
|
@PortalCustomer()
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"The eTrade business licences under this company's TIN, for attaching to its operational profiles",
|
||||||
|
})
|
||||||
|
async listEtradeBusinesses(
|
||||||
|
@CurrentUser() user: CurrentIamUser,
|
||||||
|
): Promise<ETradeBusinessOption[]> {
|
||||||
|
return this.companiesService.listEtradeBusinessesForUser(user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch("company-profiles/:profileId/etrade-business")
|
||||||
|
@PortalCustomer()
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Attach one of the TIN's eTrade businesses to an operational profile (re-attaching refreshes the stored snapshot)",
|
||||||
|
})
|
||||||
|
async attachEtradeBusiness(
|
||||||
|
@CurrentUser() user: CurrentIamUser,
|
||||||
|
@Param("profileId") profileId: string,
|
||||||
|
@Body() dto: AttachEtradeBusinessDto,
|
||||||
|
): Promise<ResponseCompanyProfileDto> {
|
||||||
|
const profile = await this.companiesService.attachEtradeBusinessToProfile(
|
||||||
|
user.id,
|
||||||
|
profileId,
|
||||||
|
dto.licenceNumber,
|
||||||
|
);
|
||||||
|
return new ResponseCompanyProfileDto(profile);
|
||||||
|
}
|
||||||
|
|
||||||
@Post("onboarding/start")
|
@Post("onboarding/start")
|
||||||
@PortalCustomer()
|
@PortalCustomer()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
@@ -321,6 +354,7 @@ export class CompaniesController {
|
|||||||
user.id,
|
user.id,
|
||||||
dto.type,
|
dto.type,
|
||||||
dto.businessLicense,
|
dto.businessLicense,
|
||||||
|
dto.licenceNumber,
|
||||||
);
|
);
|
||||||
return new ResponseCompanyProfileDto(profile);
|
return new ResponseCompanyProfileDto(profile);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,7 +162,16 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
|||||||
{} as never,
|
{} as never,
|
||||||
deps.filesService as never,
|
deps.filesService as never,
|
||||||
deps.fileUploadSettings as never,
|
deps.fileUploadSettings as never,
|
||||||
{} as never,
|
// Only the business-licence lookup is exercised here: adding a role now
|
||||||
|
// resolves which eTrade business it operates as.
|
||||||
|
{
|
||||||
|
findBusinessOption: async (_tin: string, licenceNumber: string) => ({
|
||||||
|
licenceNumber,
|
||||||
|
tradeName: "Test Trade Name",
|
||||||
|
activity: "Freight Forwarders",
|
||||||
|
renewedTo: "7/7/2026",
|
||||||
|
}),
|
||||||
|
} as never,
|
||||||
deps.companyNotifier as never,
|
deps.companyNotifier as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
deps.verifayda as never,
|
deps.verifayda as never,
|
||||||
@@ -502,7 +511,9 @@ describe("the owner is checked against the eTrade licence", () => {
|
|||||||
|
|
||||||
describe("the freight-forwarder gate", () => {
|
describe("the freight-forwarder gate", () => {
|
||||||
const addForwarder = (service: CompaniesService) =>
|
const addForwarder = (service: CompaniesService) =>
|
||||||
service.addCompanyProfilesForUser("user-1", [ProfileType.freightForwarder]);
|
service.addCompanyProfilesForUser("user-1", [
|
||||||
|
{ type: ProfileType.freightForwarder, licenceNumber: "LIC-1" },
|
||||||
|
]);
|
||||||
|
|
||||||
it("blocks the role while the representative is unverified", async () => {
|
it("blocks the role while the representative is unverified", async () => {
|
||||||
const { service } = makeService({ attributes: { poaDeclared: "yes" } });
|
const { service } = makeService({ attributes: { poaDeclared: "yes" } });
|
||||||
|
|||||||
@@ -133,7 +133,16 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
|||||||
{} as never,
|
{} as never,
|
||||||
deps.filesService as never,
|
deps.filesService as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
// Only the business-licence lookup is exercised here: adding a role now
|
||||||
|
// resolves which eTrade business it operates as.
|
||||||
|
{
|
||||||
|
findBusinessOption: async (_tin: string, licenceNumber: string) => ({
|
||||||
|
licenceNumber,
|
||||||
|
tradeName: "Test Trade Name",
|
||||||
|
activity: "Freight Forwarders",
|
||||||
|
renewedTo: "7/7/2026",
|
||||||
|
}),
|
||||||
|
} as never,
|
||||||
deps.companyNotifier as never,
|
deps.companyNotifier as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
@@ -200,6 +209,8 @@ describe("PoA delegation paper is enforced wherever PoA state changes", () => {
|
|||||||
service.createCompanyProfileForUser(
|
service.createCompanyProfileForUser(
|
||||||
"user-1",
|
"user-1",
|
||||||
ProfileType.freightForwarder,
|
ProfileType.freightForwarder,
|
||||||
|
undefined,
|
||||||
|
"LIC-1",
|
||||||
),
|
),
|
||||||
).rejects.toBeInstanceOf(BadRequestException);
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
});
|
});
|
||||||
@@ -263,6 +274,8 @@ describe("PoA delegation paper is enforced wherever PoA state changes", () => {
|
|||||||
service.createCompanyProfileForUser(
|
service.createCompanyProfileForUser(
|
||||||
"user-1",
|
"user-1",
|
||||||
ProfileType.freightForwarder,
|
ProfileType.freightForwarder,
|
||||||
|
undefined,
|
||||||
|
"LIC-1",
|
||||||
),
|
),
|
||||||
).rejects.toBeInstanceOf(BadRequestException);
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
});
|
});
|
||||||
@@ -277,6 +290,8 @@ describe("PoA delegation paper is enforced wherever PoA state changes", () => {
|
|||||||
service.createCompanyProfileForUser(
|
service.createCompanyProfileForUser(
|
||||||
"user-1",
|
"user-1",
|
||||||
ProfileType.freightForwarder,
|
ProfileType.freightForwarder,
|
||||||
|
undefined,
|
||||||
|
"LIC-1",
|
||||||
),
|
),
|
||||||
).resolves.toBeDefined();
|
).resolves.toBeDefined();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { BadRequestException, NotFoundException } from "@nestjs/common";
|
||||||
|
import { CompaniesService } from "./companies.service";
|
||||||
|
import { ProfileType } from "./entities/company-profile.entity";
|
||||||
|
import { COOPERATIVE_KEY } from "./entities/company.entity";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A TIN holds many business licences; each operational profile names the one it
|
||||||
|
* trades as. What matters here is that the stored business is always eTrade's
|
||||||
|
* own record, looked up under the company's own TIN — never the client's word
|
||||||
|
* for it — and that the requirement lifts for a company eTrade knows nothing
|
||||||
|
* about.
|
||||||
|
*/
|
||||||
|
const BUSINESSES = [
|
||||||
|
{
|
||||||
|
licenceNumber: "MT/AA/14/670/128936/2007",
|
||||||
|
tradeName: "Pave Freight Forwarding",
|
||||||
|
activity: "Freight Forwarders",
|
||||||
|
renewedTo: "7/7/2026",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
licenceNumber: "MT/AA/14/670/11551235/2017",
|
||||||
|
tradeName: "Pave Minerals Export",
|
||||||
|
activity: "Export trade in minerals",
|
||||||
|
renewedTo: "7/7/2026",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function makeService(attributes: Record<string, unknown> = {}) {
|
||||||
|
const company = {
|
||||||
|
id: "company-1",
|
||||||
|
tin: "0045014036",
|
||||||
|
type: "customer",
|
||||||
|
attributes,
|
||||||
|
companyProfiles: [{ id: "profile-1", type: ProfileType.exporter }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const created: Record<string, unknown>[] = [];
|
||||||
|
const companyProfilesRepo = {
|
||||||
|
findByCompanyId: jest.fn(async () => created),
|
||||||
|
findByType: jest.fn(async () => null),
|
||||||
|
create: jest.fn(async (row: Record<string, unknown>) => {
|
||||||
|
created.push({ id: `profile-${created.length + 2}`, ...row });
|
||||||
|
return created[created.length - 1];
|
||||||
|
}),
|
||||||
|
update: jest.fn(async (id: string, data: Record<string, unknown>) => ({
|
||||||
|
id,
|
||||||
|
...data,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const etradeService = {
|
||||||
|
listBusinessOptions: jest.fn(async () => BUSINESSES),
|
||||||
|
findBusinessOption: jest.fn(async (_tin: string, licenceNumber: string) => {
|
||||||
|
const match = BUSINESSES.find((b) => b.licenceNumber === licenceNumber);
|
||||||
|
if (!match) throw new BadRequestException("no such licence");
|
||||||
|
return match;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = new CompaniesService(
|
||||||
|
{} as never,
|
||||||
|
companyProfilesRepo as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{ findByUserId: jest.fn(async () => ({ id: "ext-1", companyId: "company-1" })) } as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
etradeService as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
jest
|
||||||
|
.spyOn(service, "getCompanyInfoByUserId")
|
||||||
|
.mockImplementation(async () => ({ profile: {}, company }) as never);
|
||||||
|
// Private, but every add path goes through it; stubbing it keeps this spec on
|
||||||
|
// the business-attachment logic instead of the whole company lookup graph.
|
||||||
|
(service as unknown as Record<string, unknown>).findCompanyById = async () =>
|
||||||
|
company;
|
||||||
|
|
||||||
|
return { service, companyProfilesRepo, etradeService };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("attaching an eTrade business to a company profile", () => {
|
||||||
|
it("stores eTrade's own record for the chosen licence, not the client's", async () => {
|
||||||
|
const { service, companyProfilesRepo } = makeService();
|
||||||
|
const updated = await service.attachEtradeBusinessToProfile(
|
||||||
|
"user-1",
|
||||||
|
"profile-1",
|
||||||
|
"MT/AA/14/670/128936/2007",
|
||||||
|
);
|
||||||
|
expect(companyProfilesRepo.update).toHaveBeenCalledWith("profile-1", {
|
||||||
|
etradeBusiness: BUSINESSES[0],
|
||||||
|
});
|
||||||
|
expect(updated.etradeBusiness).toEqual(BUSINESSES[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a licence eTrade does not list under this TIN", async () => {
|
||||||
|
const { service } = makeService();
|
||||||
|
await expect(
|
||||||
|
service.attachEtradeBusinessToProfile("user-1", "profile-1", "SOMEONE/ELSES/LICENCE"),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a profile belonging to another company", async () => {
|
||||||
|
const { service } = makeService();
|
||||||
|
await expect(
|
||||||
|
service.attachEtradeBusinessToProfile("user-1", "not-mine", BUSINESSES[0].licenceNumber),
|
||||||
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the same business may back more than one profile", async () => {
|
||||||
|
const { service, etradeService } = makeService();
|
||||||
|
await service.addCompanyProfilesForUser("user-1", [
|
||||||
|
{ type: ProfileType.exporter, licenceNumber: BUSINESSES[0].licenceNumber },
|
||||||
|
{ type: ProfileType.importer, licenceNumber: BUSINESSES[0].licenceNumber },
|
||||||
|
]);
|
||||||
|
expect(etradeService.findBusinessOption).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("choosing a business is required when the company has one to choose", () => {
|
||||||
|
it("rejects a role added without a licence", async () => {
|
||||||
|
const { service } = makeService();
|
||||||
|
await expect(
|
||||||
|
service.addCompanyProfilesForUser("user-1", [{ type: ProfileType.exporter }]),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lifts the requirement for a co-operative, which has no eTrade record", async () => {
|
||||||
|
const { service, companyProfilesRepo, etradeService } = makeService({
|
||||||
|
[COOPERATIVE_KEY]: true,
|
||||||
|
});
|
||||||
|
await service.addCompanyProfilesForUser("user-1", [
|
||||||
|
{ type: ProfileType.exporter },
|
||||||
|
]);
|
||||||
|
expect(etradeService.findBusinessOption).not.toHaveBeenCalled();
|
||||||
|
expect(companyProfilesRepo.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ etradeBusiness: null }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers a co-operative no businesses to pick from", async () => {
|
||||||
|
const { service } = makeService({ [COOPERATIVE_KEY]: true });
|
||||||
|
await expect(service.listEtradeBusinessesForUser("user-1")).resolves.toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -84,6 +84,7 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
|||||||
createdTo,
|
createdTo,
|
||||||
onboardingCompleted,
|
onboardingCompleted,
|
||||||
hasPendingChangeRequest,
|
hasPendingChangeRequest,
|
||||||
|
profileType,
|
||||||
sortBy = 'review',
|
sortBy = 'review',
|
||||||
sortOrder = 'DESC',
|
sortOrder = 'DESC',
|
||||||
} = query;
|
} = query;
|
||||||
@@ -138,20 +139,46 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
|||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
const term = `%${search.trim()}%`;
|
const term = `%${search.trim()}%`;
|
||||||
|
// Staff search by whatever is in front of them: the company name, the
|
||||||
|
// TIN/email, a profile reference off a document — and, since a TIN holds
|
||||||
|
// many licences, the trade name or licence number of the specific
|
||||||
|
// business a role operates as. All the per-profile terms share one EXISTS
|
||||||
|
// so a match on any of them qualifies the company once.
|
||||||
qb.andWhere(
|
qb.andWhere(
|
||||||
`(company.name ILIKE :term
|
`(company.name ILIKE :term
|
||||||
OR company.tin ILIKE :term
|
OR company.tin ILIKE :term
|
||||||
OR company.email ILIKE :term
|
OR company.email ILIKE :term
|
||||||
|
OR company.licence_number ILIKE :term
|
||||||
OR EXISTS (
|
OR EXISTS (
|
||||||
SELECT 1 FROM freight.company_profiles cp
|
SELECT 1 FROM freight.company_profiles cp
|
||||||
WHERE cp.company_id = company.id
|
WHERE cp.company_id = company.id
|
||||||
AND cp.reference ILIKE :term
|
|
||||||
AND cp.deleted_at IS NULL
|
AND cp.deleted_at IS NULL
|
||||||
|
AND (
|
||||||
|
cp.reference ILIKE :term
|
||||||
|
OR cp.etrade_business->>'tradeName' ILIKE :term
|
||||||
|
OR cp.etrade_business->>'licenceNumber' ILIKE :term
|
||||||
|
)
|
||||||
))`,
|
))`,
|
||||||
{ term },
|
{ term },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Companies holding a given operational role. EXISTS rather than a filter
|
||||||
|
// on the joined `companyProfiles` alias: constraining the join would drop
|
||||||
|
// the company's OTHER profiles from the loaded entity, so the list would
|
||||||
|
// render an exporter-and-importer as importer-only.
|
||||||
|
if (profileType) {
|
||||||
|
qb.andWhere(
|
||||||
|
`EXISTS (
|
||||||
|
SELECT 1 FROM freight.company_profiles cp_type
|
||||||
|
WHERE cp_type.company_id = company.id
|
||||||
|
AND cp_type.deleted_at IS NULL
|
||||||
|
AND cp_type.type = :profileType
|
||||||
|
)`,
|
||||||
|
{ profileType },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// sortBy is whitelisted by @IsIn on the DTO, so it is safe to interpolate.
|
// sortBy is whitelisted by @IsIn on the DTO, so it is safe to interpolate.
|
||||||
if (sortBy === 'review') {
|
if (sortBy === 'review') {
|
||||||
// Queue ordering: actionable tiers first, newest first within each. The
|
// Queue ordering: actionable tiers first, newest first within each. The
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ import { ETradeService } from "./services/etrade.service";
|
|||||||
import { CompanyNotifierService } from "./company-notifier.service";
|
import { CompanyNotifierService } from "./company-notifier.service";
|
||||||
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
|
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
|
||||||
import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
|
import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
|
||||||
import type { CompanyRegistrationData } from "@edr/types";
|
import type { CompanyRegistrationData, ETradeBusinessOption } from "@edr/types";
|
||||||
import { CreateCompanyDto } from "./dto/create-company.dto";
|
import { CreateCompanyDto } from "./dto/create-company.dto";
|
||||||
import { UpdateCompanyDto } from "./dto/update-company.dto";
|
import { UpdateCompanyDto } from "./dto/update-company.dto";
|
||||||
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
|
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
|
||||||
@@ -343,6 +343,11 @@ export class CompaniesService {
|
|||||||
companyId: company.id,
|
companyId: company.id,
|
||||||
type: input.type,
|
type: input.type,
|
||||||
businessLicense: input.businessLicense ?? null,
|
businessLicense: input.businessLicense ?? null,
|
||||||
|
etradeBusiness: await this.resolveProfileBusiness(
|
||||||
|
company,
|
||||||
|
input.licenceNumber,
|
||||||
|
input.type,
|
||||||
|
),
|
||||||
status: ProfileStatus.Pending,
|
status: ProfileStatus.Pending,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2149,8 +2154,9 @@ export class CompaniesService {
|
|||||||
*/
|
*/
|
||||||
async addCompanyProfilesForUser(
|
async addCompanyProfilesForUser(
|
||||||
userId: string,
|
userId: string,
|
||||||
types: ProfileType[],
|
inputs: Array<{ type: ProfileType; licenceNumber?: string }>,
|
||||||
): Promise<CompanyProfile[]> {
|
): Promise<CompanyProfile[]> {
|
||||||
|
const types = inputs.map((i) => i.type);
|
||||||
const profile = await this.profilesRepo.findByUserId(userId);
|
const profile = await this.profilesRepo.findByUserId(userId);
|
||||||
if (!profile)
|
if (!profile)
|
||||||
throw new NotFoundException(`Profile for user ${userId} not found`);
|
throw new NotFoundException(`Profile for user ${userId} not found`);
|
||||||
@@ -2185,11 +2191,21 @@ export class CompaniesService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Which eTrade business this role operates as. Resolved (and rejected if
|
||||||
|
// absent) BEFORE the row is created, so a role never lands unattached on
|
||||||
|
// a company that has licences to pick from.
|
||||||
|
const etradeBusiness = await this.resolveProfileBusiness(
|
||||||
|
company,
|
||||||
|
inputs.find((i) => i.type === type)?.licenceNumber,
|
||||||
|
type,
|
||||||
|
);
|
||||||
|
|
||||||
// Self-service role adds start Pending and carry no reference — a reference
|
// Self-service role adds start Pending and carry no reference — a reference
|
||||||
// is minted only when a backoffice reviewer approves the role.
|
// is minted only when a backoffice reviewer approves the role.
|
||||||
await this.companyProfilesRepo.create({
|
await this.companyProfilesRepo.create({
|
||||||
companyId,
|
companyId,
|
||||||
type,
|
type,
|
||||||
|
etradeBusiness,
|
||||||
status: ProfileStatus.Pending,
|
status: ProfileStatus.Pending,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2207,6 +2223,7 @@ export class CompaniesService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
type: ProfileType,
|
type: ProfileType,
|
||||||
businessLicense?: string,
|
businessLicense?: string,
|
||||||
|
licenceNumber?: string,
|
||||||
): Promise<CompanyProfile> {
|
): Promise<CompanyProfile> {
|
||||||
const profile = await this.profilesRepo.findByUserId(userId);
|
const profile = await this.profilesRepo.findByUserId(userId);
|
||||||
if (!profile)
|
if (!profile)
|
||||||
@@ -2232,12 +2249,18 @@ export class CompaniesService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!created) {
|
if (!created) {
|
||||||
|
const etradeBusiness = await this.resolveProfileBusiness(
|
||||||
|
company,
|
||||||
|
licenceNumber,
|
||||||
|
type,
|
||||||
|
);
|
||||||
// New self-service roles start Pending (awaiting backoffice approval) and
|
// New self-service roles start Pending (awaiting backoffice approval) and
|
||||||
// carry no reference until approved.
|
// carry no reference until approved.
|
||||||
created = await this.companyProfilesRepo.create({
|
created = await this.companyProfilesRepo.create({
|
||||||
companyId,
|
companyId,
|
||||||
type,
|
type,
|
||||||
businessLicense: businessLicense ?? null,
|
businessLicense: businessLicense ?? null,
|
||||||
|
etradeBusiness,
|
||||||
status: ProfileStatus.Pending,
|
status: ProfileStatus.Pending,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2320,6 +2343,7 @@ export class CompaniesService {
|
|||||||
type: p.type,
|
type: p.type,
|
||||||
reference: p.reference ?? "",
|
reference: p.reference ?? "",
|
||||||
uploaded: records.some((r) => r.code === LICENSE_CODE),
|
uploaded: records.some((r) => r.code === LICENSE_CODE),
|
||||||
|
etradeBusiness: p.etradeBusiness ?? null,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -2331,6 +2355,19 @@ export class CompaniesService {
|
|||||||
? []
|
? []
|
||||||
: licenseProfiles.filter((p) => !p.uploaded);
|
: licenseProfiles.filter((p) => !p.uploaded);
|
||||||
|
|
||||||
|
// Which eTrade business each role operates as. Enforced here rather than at
|
||||||
|
// role creation because the wizard picks roles on its FIRST step, before a
|
||||||
|
// TIN has been entered — there is nothing to pick from yet. The customer
|
||||||
|
// attaches one on the documents step, alongside that role's licence file,
|
||||||
|
// and onboarding cannot be submitted until every role has one.
|
||||||
|
//
|
||||||
|
// Lifted for a company with no eTrade record at all: a co-operative or a
|
||||||
|
// foreign investor has no licence list, so the requirement would be
|
||||||
|
// unsatisfiable (see `usesManualRegistration`).
|
||||||
|
const missingBusinesses = usesManualRegistration(company)
|
||||||
|
? []
|
||||||
|
: licenseProfiles.filter((p) => !p.etradeBusiness);
|
||||||
|
|
||||||
// 4. Power of Attorney. Whether there is one at all is the company's own
|
// 4. Power of Attorney. Whether there is one at all is the company's own
|
||||||
// declaration — the question the wizard asks outright — and that answer is
|
// declaration — the question the wizard asks outright — and that answer is
|
||||||
// what decides whose identity gets verified, so an unanswered one is itself
|
// what decides whose identity gets verified, so an unanswered one is itself
|
||||||
@@ -2378,6 +2415,10 @@ export class CompaniesService {
|
|||||||
(p) =>
|
(p) =>
|
||||||
`Upload a business license for your ${p.type.replace(/_/g, " ")} profile`,
|
`Upload a business license for your ${p.type.replace(/_/g, " ")} profile`,
|
||||||
),
|
),
|
||||||
|
...missingBusinesses.map(
|
||||||
|
(p) =>
|
||||||
|
`Choose which eTrade business your ${p.type.replace(/_/g, " ")} profile operates as`,
|
||||||
|
),
|
||||||
...missingPoaFields.map((f) => `Add your ${f.label.toLowerCase()}`),
|
...missingPoaFields.map((f) => `Add your ${f.label.toLowerCase()}`),
|
||||||
...(missingDelegation
|
...(missingDelegation
|
||||||
? [`Upload the ${POA_DELEGATION_LABEL} for your Power of Attorney`]
|
? [`Upload the ${POA_DELEGATION_LABEL} for your Power of Attorney`]
|
||||||
@@ -2416,6 +2457,8 @@ export class CompaniesService {
|
|||||||
requiredInfo.length +
|
requiredInfo.length +
|
||||||
requiredDocCount +
|
requiredDocCount +
|
||||||
(cooperative ? 0 : licenseProfiles.length) +
|
(cooperative ? 0 : licenseProfiles.length) +
|
||||||
|
// One "which business?" item per role, on the same terms as the licences.
|
||||||
|
(usesManualRegistration(company) ? 0 : licenseProfiles.length) +
|
||||||
poaItemCount +
|
poaItemCount +
|
||||||
// The declaration and the verification it selects.
|
// The declaration and the verification it selects.
|
||||||
2;
|
2;
|
||||||
@@ -2424,6 +2467,7 @@ export class CompaniesService {
|
|||||||
(missingInfo.length +
|
(missingInfo.length +
|
||||||
missingDocs.length +
|
missingDocs.length +
|
||||||
missingLicenses.length +
|
missingLicenses.length +
|
||||||
|
missingBusinesses.length +
|
||||||
missingPoaFields.length +
|
missingPoaFields.length +
|
||||||
(missingDelegation || flaggedDelegation ? 1 : 0) +
|
(missingDelegation || flaggedDelegation ? 1 : 0) +
|
||||||
missingIdentityCount);
|
missingIdentityCount);
|
||||||
@@ -3747,6 +3791,86 @@ export class CompaniesService {
|
|||||||
return match?.id ?? null;
|
return match?.id ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the eTrade business a new/updated profile is being attached to.
|
||||||
|
*
|
||||||
|
* The client sends a licence number; what gets stored is eTrade's own record
|
||||||
|
* of it, looked up under THIS company's TIN. That is the whole check — a
|
||||||
|
* licence belonging to someone else's TIN simply is not in the list, so a
|
||||||
|
* client cannot attach a profile to a business the company does not hold.
|
||||||
|
*
|
||||||
|
* Returns null (rather than throwing) for a company that registered without
|
||||||
|
* eTrade: a co-operative union or farm holds no business licence, and a
|
||||||
|
* foreign investor's licence is the Investment Commission's, not the trade
|
||||||
|
* registry's. There is no list for them to pick from, so the role is theirs
|
||||||
|
* to hold unattached — the reviewer checks their uploaded documents instead.
|
||||||
|
*/
|
||||||
|
private async resolveProfileBusiness(
|
||||||
|
company: Company,
|
||||||
|
licenceNumber: string | undefined,
|
||||||
|
type: ProfileType,
|
||||||
|
): Promise<ETradeBusinessOption | null> {
|
||||||
|
if (usesManualRegistration(company)) return null;
|
||||||
|
if (!licenceNumber) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Choose which of your eTrade business licences the ${type.replace(/_/g, " ")} profile operates as.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.etradeService.findBusinessOption(company.tin, licenceNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The eTrade business licences the current user's company can attach to its
|
||||||
|
* operational profiles. Empty for a company that registered without eTrade.
|
||||||
|
*/
|
||||||
|
async listEtradeBusinessesForUser(
|
||||||
|
userId: string,
|
||||||
|
): Promise<ETradeBusinessOption[]> {
|
||||||
|
const { company } = await this.getCompanyInfoByUserId(userId);
|
||||||
|
if (usesManualRegistration(company)) return [];
|
||||||
|
return this.etradeService.listBusinessOptions(company.tin);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach (or re-attach) one of the TIN's eTrade businesses to a profile.
|
||||||
|
*
|
||||||
|
* Separate from role creation because the onboarding wizard picks roles
|
||||||
|
* before the TIN is known — the business is chosen later, on the step that
|
||||||
|
* already collects each role's licence document. Re-attaching also refreshes
|
||||||
|
* the stored snapshot, which is how a renewed licence's new expiry lands.
|
||||||
|
*/
|
||||||
|
async attachEtradeBusinessToProfile(
|
||||||
|
userId: string,
|
||||||
|
profileId: string,
|
||||||
|
licenceNumber: string,
|
||||||
|
): Promise<CompanyProfile> {
|
||||||
|
const { company } = await this.getCompanyInfoByUserId(userId);
|
||||||
|
const profile = (company.companyProfiles ?? []).find(
|
||||||
|
(p) => p.id === profileId,
|
||||||
|
);
|
||||||
|
if (!profile) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
`Company profile ${profileId} not found for this company`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (usesManualRegistration(company)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"This company is not registered with eTrade, so it has no business licences to attach.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const business = await this.etradeService.findBusinessOption(
|
||||||
|
company.tin,
|
||||||
|
licenceNumber,
|
||||||
|
);
|
||||||
|
const updated = await this.companyProfilesRepo.update(profile.id, {
|
||||||
|
etradeBusiness: business,
|
||||||
|
});
|
||||||
|
if (!updated) {
|
||||||
|
throw new NotFoundException(`Company profile ${profileId} not found`);
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
/** Resolve a TIN's live eTrade registration data. Throws when eTrade has no matching business licence. */
|
/** Resolve a TIN's live eTrade registration data. Throws when eTrade has no matching business licence. */
|
||||||
private async resolveEtradeRegistration(
|
private async resolveEtradeRegistration(
|
||||||
tin: string,
|
tin: string,
|
||||||
|
|||||||
@@ -1,9 +1,37 @@
|
|||||||
import { IsArray, IsEnum, ArrayMinSize } from "class-validator";
|
import { Type } from "class-transformer";
|
||||||
|
import {
|
||||||
|
ArrayMinSize,
|
||||||
|
IsArray,
|
||||||
|
IsEnum,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
MaxLength,
|
||||||
|
ValidateNested,
|
||||||
|
} from "class-validator";
|
||||||
import { ProfileType } from "../entities/company-profile.entity";
|
import { ProfileType } from "../entities/company-profile.entity";
|
||||||
|
|
||||||
|
export class AddCompanyProfileInputDto {
|
||||||
|
@IsEnum(ProfileType)
|
||||||
|
type!: ProfileType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which of the TIN's eTrade business licences this role operates as.
|
||||||
|
*
|
||||||
|
* Optional at the DTO layer, required by the service for any company that
|
||||||
|
* HAS an eTrade record — a co-operative or investor-licence company has none
|
||||||
|
* to pick from, and rejecting them here would be wrong. See
|
||||||
|
* `CompaniesService.resolveProfileBusiness`.
|
||||||
|
*/
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(120)
|
||||||
|
licenceNumber?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class AddCompanyProfilesDto {
|
export class AddCompanyProfilesDto {
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@ArrayMinSize(1)
|
@ArrayMinSize(1)
|
||||||
@IsEnum(ProfileType, { each: true })
|
@ValidateNested({ each: true })
|
||||||
types!: ProfileType[];
|
@Type(() => AddCompanyProfileInputDto)
|
||||||
|
profiles!: AddCompanyProfileInputDto[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { IsNotEmpty, IsString, MaxLength } from "class-validator";
|
||||||
|
|
||||||
|
export class AttachEtradeBusinessDto {
|
||||||
|
/**
|
||||||
|
* The eTrade licence number of the business this profile operates as. Checked
|
||||||
|
* against the licences eTrade lists under the company's own TIN, so an
|
||||||
|
* unknown or someone else's licence is rejected rather than stored.
|
||||||
|
*/
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(120)
|
||||||
|
licenceNumber!: string;
|
||||||
|
}
|
||||||
@@ -9,4 +9,14 @@ export class CreateCompanyProfileDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(100)
|
@MaxLength(100)
|
||||||
businessLicense?: string;
|
businessLicense?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which of the TIN's eTrade business licences this role operates as. Required
|
||||||
|
* by the service for any company that has an eTrade record; see
|
||||||
|
* `AddCompanyProfileInputDto.licenceNumber`.
|
||||||
|
*/
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(120)
|
||||||
|
licenceNumber?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,16 @@ export class CompanyProfileInputDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(100)
|
@MaxLength(100)
|
||||||
businessLicense?: string;
|
businessLicense?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which of the TIN's eTrade business licences this role operates as. Required
|
||||||
|
* by the service for any company that has an eTrade record; see
|
||||||
|
* `CompaniesService.resolveProfileBusiness`.
|
||||||
|
*/
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(120)
|
||||||
|
licenceNumber?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CreateCompanyWithProfileDto {
|
export class CreateCompanyWithProfileDto {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
CompanyStatus,
|
CompanyStatus,
|
||||||
CompanyType,
|
CompanyType,
|
||||||
} from "../entities/company.entity";
|
} from "../entities/company.entity";
|
||||||
|
import { ProfileType } from "../entities/company-profile.entity";
|
||||||
|
|
||||||
export class ListCompaniesQueryDto {
|
export class ListCompaniesQueryDto {
|
||||||
@ApiPropertyOptional({ default: 1 })
|
@ApiPropertyOptional({ default: 1 })
|
||||||
@@ -56,6 +57,16 @@ export class ListCompaniesQueryDto {
|
|||||||
@IsIn(Object.values(CompanyNationality))
|
@IsIn(Object.values(CompanyNationality))
|
||||||
nationality?: CompanyNationality;
|
nationality?: CompanyNationality;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
enum: ProfileType,
|
||||||
|
description:
|
||||||
|
"Only companies holding this operational role. A company may hold " +
|
||||||
|
"several; its other roles are still returned on the row.",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(Object.values(ProfileType))
|
||||||
|
profileType?: ProfileType;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: "Registered on or after this instant (ISO)." })
|
@ApiPropertyOptional({ description: "Registered on or after this instant (ISO)." })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
* truth the wizard uses to auto-finish.
|
* truth the wizard uses to auto-finish.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { ETradeBusinessOption } from "@edr/types";
|
||||||
import {
|
import {
|
||||||
CompanyIdentityStateDto,
|
CompanyIdentityStateDto,
|
||||||
PoaDeclaration,
|
PoaDeclaration,
|
||||||
@@ -38,6 +39,11 @@ export interface OnboardingLicenseProfile {
|
|||||||
reference: string;
|
reference: string;
|
||||||
/** True when at least one business-license file is stored on the profile. */
|
/** True when at least one business-license file is stored on the profile. */
|
||||||
uploaded: boolean;
|
uploaded: boolean;
|
||||||
|
/**
|
||||||
|
* The eTrade business this role operates as, once the customer has attached
|
||||||
|
* one. Null while outstanding — the wizard renders the picker off this.
|
||||||
|
*/
|
||||||
|
etradeBusiness: ETradeBusinessOption | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OnboardingPoaState {
|
export interface OnboardingPoaState {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
hasInvestorLicence,
|
hasInvestorLicence,
|
||||||
isCooperative,
|
isCooperative,
|
||||||
} from '../entities/company.entity';
|
} from '../entities/company.entity';
|
||||||
|
import type { ETradeBusinessOption } from '@edr/types';
|
||||||
import {
|
import {
|
||||||
CompanyProfile,
|
CompanyProfile,
|
||||||
ProfileLicenseFileView,
|
ProfileLicenseFileView,
|
||||||
@@ -31,6 +32,12 @@ export class ResponseCompanyProfileDto {
|
|||||||
*/
|
*/
|
||||||
licenseFiles: ProfileLicenseFileView[];
|
licenseFiles: ProfileLicenseFileView[];
|
||||||
attributes?: Record<string, any> | null;
|
attributes?: Record<string, any> | null;
|
||||||
|
/**
|
||||||
|
* The eTrade business licence this role operates as, or null when nothing is
|
||||||
|
* attached yet (or the company registered without eTrade). Snapshot — see
|
||||||
|
* `CompanyProfile.etradeBusiness`.
|
||||||
|
*/
|
||||||
|
etradeBusiness?: ETradeBusinessOption | null;
|
||||||
/** Reviewer note when the role is rejected (drives the reapply prompt). */
|
/** Reviewer note when the role is rejected (drives the reapply prompt). */
|
||||||
reviewNote?: string | null;
|
reviewNote?: string | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
@@ -45,6 +52,7 @@ export class ResponseCompanyProfileDto {
|
|||||||
this.businessLicense = profile.businessLicense;
|
this.businessLicense = profile.businessLicense;
|
||||||
this.licenseFiles = [];
|
this.licenseFiles = [];
|
||||||
this.attributes = profile.attributes;
|
this.attributes = profile.attributes;
|
||||||
|
this.etradeBusiness = profile.etradeBusiness ?? null;
|
||||||
this.reviewNote = profile.reviewNote ?? null;
|
this.reviewNote = profile.reviewNote ?? null;
|
||||||
this.createdAt = profile.createdAt;
|
this.createdAt = profile.createdAt;
|
||||||
this.updatedAt = profile.updatedAt;
|
this.updatedAt = profile.updatedAt;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { BaseEntity } from "@edr/api-common";
|
import { BaseEntity } from "@edr/api-common";
|
||||||
|
import type { ETradeBusinessOption } from "@edr/types";
|
||||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||||
import { Company } from "./company.entity";
|
import { Company } from "./company.entity";
|
||||||
|
|
||||||
@@ -126,6 +127,23 @@ export class CompanyProfile extends BaseEntity {
|
|||||||
@Column({ name: "business_license_files", type: "jsonb", nullable: true })
|
@Column({ name: "business_license_files", type: "jsonb", nullable: true })
|
||||||
businessLicenseFiles?: BusinessLicenseFile[] | null;
|
businessLicenseFiles?: BusinessLicenseFile[] | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which of the TIN's eTrade business licences this profile operates as.
|
||||||
|
*
|
||||||
|
* A TIN holds many licences split by activity, so "exporter" and "freight
|
||||||
|
* forwarder" are usually two different businesses under one company. Stored
|
||||||
|
* as a snapshot rather than a bare licence number so the trade name and
|
||||||
|
* activity render without an eTrade call — that API is slow and regularly
|
||||||
|
* down, and this is display data, not a source of truth. Re-attaching
|
||||||
|
* refreshes it.
|
||||||
|
*
|
||||||
|
* NULL when nothing is attached yet, or when the company registered without
|
||||||
|
* eTrade at all (co-operative / investor licence — see
|
||||||
|
* {@link usesManualRegistration}). One business may back several profiles.
|
||||||
|
*/
|
||||||
|
@Column({ name: "etrade_business", type: "jsonb", nullable: true })
|
||||||
|
etradeBusiness?: ETradeBusinessOption | null;
|
||||||
|
|
||||||
@Column({ name: "attributes", type: "jsonb", nullable: true })
|
@Column({ name: "attributes", type: "jsonb", nullable: true })
|
||||||
attributes?: Record<string, any> | null;
|
attributes?: Record<string, any> | null;
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,27 @@ describe('ETradeService business selection', () => {
|
|||||||
expect(data.businesses?.[0].activity).toBe('Export trade in minerals');
|
expect(data.businesses?.[0].activity).toBe('Export trade in minerals');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("takes the selected licence's trade name as the company name", () => {
|
||||||
|
const { service } = build();
|
||||||
|
const data = service.extractRegistrationData(
|
||||||
|
{
|
||||||
|
LicenceNumber: 'MT/AA/14/670/128936/2007',
|
||||||
|
TradeName: 'Pave Freight Forwarding',
|
||||||
|
} as ETradeBusinessInfo,
|
||||||
|
companyInfo(),
|
||||||
|
);
|
||||||
|
expect(data.companyName).toBe('Pave Freight Forwarding');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the registered name when the licence has no trade name', () => {
|
||||||
|
const { service } = build();
|
||||||
|
const data = service.extractRegistrationData(
|
||||||
|
{ LicenceNumber: 'x', TradeName: ' ' } as ETradeBusinessInfo,
|
||||||
|
companyInfo(),
|
||||||
|
);
|
||||||
|
expect(data.companyName).toBe('PAVE LOGISTICS AND TRADING P L C');
|
||||||
|
});
|
||||||
|
|
||||||
it('lists every licence for the picker, code prefixes stripped', () => {
|
it('lists every licence for the picker, code prefixes stripped', () => {
|
||||||
const { service } = build();
|
const { service } = build();
|
||||||
const data = service.extractRegistrationData(
|
const data = service.extractRegistrationData(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { firstValueFrom } from "rxjs";
|
|||||||
import {
|
import {
|
||||||
ETradeCompanyInfo,
|
ETradeCompanyInfo,
|
||||||
ETradeBusinessInfo,
|
ETradeBusinessInfo,
|
||||||
|
ETradeBusinessOption,
|
||||||
CompanyRegistrationData,
|
CompanyRegistrationData,
|
||||||
normalizeRegion,
|
normalizeRegion,
|
||||||
} from "@edr/types";
|
} from "@edr/types";
|
||||||
@@ -102,10 +103,16 @@ export class ETradeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `companyInfo` carries the registered organization name (`BusinessName`);
|
* `businessInfo` carries the selected licence's `TradeName`; `companyInfo`
|
||||||
* `businessInfo` only carries the licence's `TradeName`. Pass both so the
|
* carries the registered organization name (`BusinessName`). The company name
|
||||||
* company name resolves to the legal entity rather than the trade name — and
|
* resolves to the trade name of the licence the customer picked — a TIN
|
||||||
* never to `ManagerNameEng`, which is the manager's personal name.
|
* routinely trades under a name that is not its registered one, and the
|
||||||
|
* business they selected is the one they operate as here. `BusinessName` is
|
||||||
|
* the fallback, because eTrade leaves `TradeName` blank on plenty of licences.
|
||||||
|
* Never `ManagerNameEng`, which is the manager's personal name.
|
||||||
|
*
|
||||||
|
* Callers that need the legal entity (tax filings, EIMS seller details) must
|
||||||
|
* read `companyInfo.BusinessName` themselves — it is not this field.
|
||||||
*/
|
*/
|
||||||
extractRegistrationData(
|
extractRegistrationData(
|
||||||
businessInfo: ETradeBusinessInfo,
|
businessInfo: ETradeBusinessInfo,
|
||||||
@@ -115,7 +122,7 @@ export class ETradeService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
companyName:
|
companyName:
|
||||||
companyInfo?.BusinessName?.trim() || businessInfo.TradeName?.trim() || "",
|
businessInfo.TradeName?.trim() || companyInfo?.BusinessName?.trim() || "",
|
||||||
licenceNumber: businessInfo.LicenceNumber,
|
licenceNumber: businessInfo.LicenceNumber,
|
||||||
statusDescription: businessInfo.StatusDescription,
|
statusDescription: businessInfo.StatusDescription,
|
||||||
dateRegistered: businessInfo.DateRegistered,
|
dateRegistered: businessInfo.DateRegistered,
|
||||||
@@ -137,17 +144,57 @@ export class ETradeService {
|
|||||||
regularPhone: businessInfo.AddressInfo?.RegularPhone || "",
|
regularPhone: businessInfo.AddressInfo?.RegularPhone || "",
|
||||||
managerName: primaryManager?.ManagerNameEng || "",
|
managerName: primaryManager?.ManagerNameEng || "",
|
||||||
managerPhone: primaryManager?.RegularPhone || "",
|
managerPhone: primaryManager?.RegularPhone || "",
|
||||||
businesses: (companyInfo?.Businesses ?? []).map((b) => ({
|
businesses: (companyInfo?.Businesses ?? []).map(toBusinessOption),
|
||||||
licenceNumber: b.LicenceNumber,
|
|
||||||
tradeName: b.TradesName?.trim() || "",
|
|
||||||
activity: (b.SubGroups ?? [])
|
|
||||||
// Some descriptions repeat the code inline ("(65611)Import trade …").
|
|
||||||
// eTrade also puts null entries in this array, so every hop is optional.
|
|
||||||
.map((g) => g?.Description?.replace(/^\(\d+\)\s*/, "").trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(", "),
|
|
||||||
renewedTo: b.RenewedTo || "",
|
|
||||||
})),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every business licence held under a TIN, as the customer picks them.
|
||||||
|
*
|
||||||
|
* Split out from {@link extractRegistrationData} because attaching a business
|
||||||
|
* to a company profile needs the list alone — no licence detail fetch, so one
|
||||||
|
* eTrade call instead of two.
|
||||||
|
*/
|
||||||
|
async listBusinessOptions(tin: string): Promise<ETradeBusinessOption[]> {
|
||||||
|
const companyInfo = await this.getCompanyInfoByTin(tin);
|
||||||
|
return (companyInfo.Businesses ?? []).map(toBusinessOption);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve one of the TIN's licences, or throw if eTrade does not list it.
|
||||||
|
*
|
||||||
|
* This is the trust boundary for a client-supplied licence number: a profile
|
||||||
|
* may only ever be attached to a business eTrade actually holds under that
|
||||||
|
* TIN, so the snapshot that gets stored is eTrade's own data, never the
|
||||||
|
* client's.
|
||||||
|
*/
|
||||||
|
async findBusinessOption(
|
||||||
|
tin: string,
|
||||||
|
licenceNumber: string,
|
||||||
|
): Promise<ETradeBusinessOption> {
|
||||||
|
const options = await this.listBusinessOptions(tin);
|
||||||
|
const match = options.find((b) => b.licenceNumber === licenceNumber);
|
||||||
|
if (!match) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`eTrade lists no business licence "${licenceNumber}" under TIN ${tin}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBusinessOption(
|
||||||
|
b: ETradeCompanyInfo["Businesses"][number],
|
||||||
|
): ETradeBusinessOption {
|
||||||
|
return {
|
||||||
|
licenceNumber: b.LicenceNumber,
|
||||||
|
tradeName: b.TradesName?.trim() || "",
|
||||||
|
activity: (b.SubGroups ?? [])
|
||||||
|
// Some descriptions repeat the code inline ("(65611)Import trade …").
|
||||||
|
// eTrade also puts null entries in this array, so every hop is optional.
|
||||||
|
.map((g) => g?.Description?.replace(/^\(\d+\)\s*/, "").trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", "),
|
||||||
|
renewedTo: b.RenewedTo || "",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,7 +118,11 @@ export class EimsSellerCacheService implements OnModuleInit {
|
|||||||
woreda: data.woreda,
|
woreda: data.woreda,
|
||||||
});
|
});
|
||||||
this.cached = {
|
this.cached = {
|
||||||
LegalName: data.companyName || undefined,
|
// The *legal* entity name, not the licence's trade name that
|
||||||
|
// `data.companyName` now carries — an EIMS seller is filed under its
|
||||||
|
// registered name.
|
||||||
|
LegalName:
|
||||||
|
companyInfo?.BusinessName?.trim() || data.companyName || undefined,
|
||||||
Phone: data.mobilePhone || data.regularPhone || undefined,
|
Phone: data.mobilePhone || data.regularPhone || undefined,
|
||||||
Region: geo?.Region,
|
Region: geo?.Region,
|
||||||
Wereda: geo?.Wereda,
|
Wereda: geo?.Wereda,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { InvoiceLine } from "../billing/entities/invoice-line.entity";
|
|||||||
import { PayInvoiceDto as GatewayPayInvoiceDto } from "../billing/dto/pay-invoice.dto";
|
import { PayInvoiceDto as GatewayPayInvoiceDto } from "../billing/dto/pay-invoice.dto";
|
||||||
import {
|
import {
|
||||||
InvoiceDocumentModel,
|
InvoiceDocumentModel,
|
||||||
|
sameCompanyName,
|
||||||
InvoiceDocumentService,
|
InvoiceDocumentService,
|
||||||
} from "../billing/documents/invoice-document.service";
|
} from "../billing/documents/invoice-document.service";
|
||||||
import { NotificationsService } from "../notifications/notifications.service";
|
import { NotificationsService } from "../notifications/notifications.service";
|
||||||
@@ -71,6 +72,11 @@ const ACTIVE_STATUSES: Freight.InvoiceStatus[] = [
|
|||||||
export interface InvoiceDocumentDetails {
|
export interface InvoiceDocumentDetails {
|
||||||
bookingReference: string | null;
|
bookingReference: string | null;
|
||||||
customerName: string | null;
|
customerName: string | null;
|
||||||
|
/**
|
||||||
|
* Trade name of the eTrade licence the billed company profile operates as.
|
||||||
|
* Null when nothing is attached, or for a company with no eTrade record.
|
||||||
|
*/
|
||||||
|
customerTradeName: string | null;
|
||||||
inventoryReference: string | null;
|
inventoryReference: string | null;
|
||||||
inventoryInfo: string | null;
|
inventoryInfo: string | null;
|
||||||
inventoryStatus: string | null;
|
inventoryStatus: string | null;
|
||||||
@@ -745,6 +751,17 @@ export class WarehouseInvoiceService {
|
|||||||
},
|
},
|
||||||
{ label: "Booking reference", value: invoice.bookingReference ?? null },
|
{ label: "Booking reference", value: invoice.bookingReference ?? null },
|
||||||
{ label: "Customer", value: invoice.customerName ?? null },
|
{ label: "Customer", value: invoice.customerName ?? null },
|
||||||
|
// Which of the TIN's eTrade businesses was billed. Omitted when it just
|
||||||
|
// repeats the customer name — see sameCompanyName.
|
||||||
|
...(invoice.customerTradeName &&
|
||||||
|
!sameCompanyName(invoice.customerTradeName, invoice.customerName)
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: "Customer trade name",
|
||||||
|
value: invoice.customerTradeName,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
label: "Inventory reference",
|
label: "Inventory reference",
|
||||||
value: invoice.inventoryReference ?? null,
|
value: invoice.inventoryReference ?? null,
|
||||||
@@ -795,6 +812,7 @@ export class WarehouseInvoiceService {
|
|||||||
const [row] = await this.dataSource.query(
|
const [row] = await this.dataSource.query(
|
||||||
`SELECT b.reference AS "bookingReference",
|
`SELECT b.reference AS "bookingReference",
|
||||||
company.name AS "customerName",
|
company.name AS "customerName",
|
||||||
|
cp.etrade_business->>'tradeName' AS "customerTradeName",
|
||||||
COALESCE(inv.release_order_reference, b.reference) AS "inventoryReference",
|
COALESCE(inv.release_order_reference, b.reference) AS "inventoryReference",
|
||||||
inv.status AS "inventoryStatus",
|
inv.status AS "inventoryStatus",
|
||||||
inv.release_date AS "releaseDate",
|
inv.release_date AS "releaseDate",
|
||||||
@@ -812,6 +830,7 @@ export class WarehouseInvoiceService {
|
|||||||
FROM freight.warehouse_inventory inv
|
FROM freight.warehouse_inventory inv
|
||||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
|
LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
|
||||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||||
|
LEFT JOIN freight.company_profiles cp ON cp.id = b.company_profile_id AND cp.deleted_at IS NULL
|
||||||
LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
|
LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
|
||||||
LEFT JOIN freight.booking_container booking_container ON (
|
LEFT JOIN freight.booking_container booking_container ON (
|
||||||
booking_container.booking_id = b.id
|
booking_container.booking_id = b.id
|
||||||
@@ -837,6 +856,7 @@ export class WarehouseInvoiceService {
|
|||||||
return {
|
return {
|
||||||
bookingReference: row?.bookingReference ?? null,
|
bookingReference: row?.bookingReference ?? null,
|
||||||
customerName: row?.customerName ?? null,
|
customerName: row?.customerName ?? null,
|
||||||
|
customerTradeName: row?.customerTradeName ?? null,
|
||||||
inventoryReference: row?.inventoryReference ?? null,
|
inventoryReference: row?.inventoryReference ?? null,
|
||||||
inventoryInfo: row?.inventoryInfo ?? null,
|
inventoryInfo: row?.inventoryInfo ?? null,
|
||||||
inventoryStatus: row?.inventoryStatus ?? null,
|
inventoryStatus: row?.inventoryStatus ?? null,
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { AtSign, Phone, ShieldAlert, UserRound } from "lucide-react";
|
||||||
|
|
||||||
|
import type { CustomerAccount } from "@/types/customer";
|
||||||
|
import { formatDate, humanize } from "./format";
|
||||||
|
|
||||||
|
/** First letters of the person's name; falls back to the login initial. */
|
||||||
|
function initials(account: CustomerAccount): string {
|
||||||
|
const letters = [account.firstName, account.lastName]
|
||||||
|
.map((n) => n?.trim()?.[0])
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("");
|
||||||
|
return (letters || account.username?.[0] || "?").toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A labelled value; rendered only when there is something to show. */
|
||||||
|
function Field({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
after,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
value?: string | null;
|
||||||
|
after?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
if (!value?.trim()) return null;
|
||||||
|
return (
|
||||||
|
<Group gap={10} wrap="nowrap" align="flex-start">
|
||||||
|
<Box c="edr-muted" mt={2}>
|
||||||
|
{icon}
|
||||||
|
</Box>
|
||||||
|
<Box style={{ minWidth: 0 }}>
|
||||||
|
<Text size="xs" c="edr-muted">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Group gap={6} wrap="wrap">
|
||||||
|
<Text size="sm" c="edr-text" style={{ wordBreak: "break-word" }}>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
{after}
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One portal login belonging to a customer.
|
||||||
|
*
|
||||||
|
* Distinct from the contact details on the Overview tab: those are the business
|
||||||
|
* contact info on the company row, this is the credential someone actually
|
||||||
|
* signs in with — the two drift apart routinely, and staff answering "the
|
||||||
|
* customer can't log in" need this one.
|
||||||
|
*/
|
||||||
|
export function AccountCard({ account }: { account: CustomerAccount }) {
|
||||||
|
const name =
|
||||||
|
`${account.firstName ?? ""} ${account.lastName ?? ""}`.trim() ||
|
||||||
|
account.username ||
|
||||||
|
"Unnamed account";
|
||||||
|
|
||||||
|
// No IAM row at all — the profile points at a user that is gone. Treated as a
|
||||||
|
// fault rather than a status: nothing below it can be trusted, so the card
|
||||||
|
// says so once, loudly, instead of drawing empty credential fields.
|
||||||
|
const orphaned = account.username === null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||||
|
<Avatar radius="xl" color="edr-green" variant="light">
|
||||||
|
{initials(account)}
|
||||||
|
</Avatar>
|
||||||
|
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<Group gap={6} wrap="wrap">
|
||||||
|
<Text fw={600} c="edr-text" style={{ wordBreak: "break-word" }}>
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
{account.isPrimaryContact && (
|
||||||
|
<Badge size="xs" color="edr-green" variant="light">
|
||||||
|
Primary contact
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
{account.jobTitle && (
|
||||||
|
<Text size="xs" c="edr-muted">
|
||||||
|
{account.jobTitle}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group gap={6} wrap="wrap">
|
||||||
|
{orphaned ? (
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
color="red"
|
||||||
|
variant="light"
|
||||||
|
leftSection={<ShieldAlert size={11} />}
|
||||||
|
>
|
||||||
|
No IAM account
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
color={account.isActive ? "edr-green" : "orange"}
|
||||||
|
variant="light"
|
||||||
|
>
|
||||||
|
{account.isActive ? "Active" : "Inactive"}
|
||||||
|
</Badge>
|
||||||
|
{account.status && (
|
||||||
|
<Badge size="xs" color="gray" variant="light">
|
||||||
|
{humanize(account.status)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{/* Created but never activated by its owner — usually the actual
|
||||||
|
answer to "they say they never got in". */}
|
||||||
|
{account.hasSetPassword === false && (
|
||||||
|
<Badge size="xs" color="yellow" variant="light">
|
||||||
|
Password never set
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{account.onboardingCompleted ? (
|
||||||
|
<Badge size="xs" color="edr-green" variant="light">
|
||||||
|
Onboarding submitted
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge size="xs" color="yellow" variant="light">
|
||||||
|
Onboarding
|
||||||
|
{account.onboardingStep
|
||||||
|
? ` · ${humanize(account.onboardingStep)}`
|
||||||
|
: " in progress"}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{!orphaned && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Field
|
||||||
|
icon={<UserRound size={14} />}
|
||||||
|
label="Username"
|
||||||
|
value={account.username}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
icon={<AtSign size={14} />}
|
||||||
|
label="Email"
|
||||||
|
value={account.email}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
icon={<Phone size={14} />}
|
||||||
|
label="Phone"
|
||||||
|
value={account.phoneNumber}
|
||||||
|
after={
|
||||||
|
account.phoneVerified === false ? (
|
||||||
|
<Badge size="xs" color="gray" variant="light">
|
||||||
|
Unverified
|
||||||
|
</Badge>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text size="xs" c="edr-muted">
|
||||||
|
Created {formatDate(account.createdAt)}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AccountCard;
|
||||||
@@ -3,6 +3,12 @@ import type { ReactNode } from "react";
|
|||||||
|
|
||||||
export interface TableCardProps {
|
export interface TableCardProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
/**
|
||||||
|
* Optional heading row (title, chips, actions). Rendered in its own padded
|
||||||
|
* section above the table and OUTSIDE the scroll region — a header inside it
|
||||||
|
* would slide away from its own table on a narrow viewport.
|
||||||
|
*/
|
||||||
|
header?: ReactNode;
|
||||||
/**
|
/**
|
||||||
* Minimum width (px) the table is forced to occupy. The Mantine `Table` is
|
* Minimum width (px) the table is forced to occupy. The Mantine `Table` is
|
||||||
* always `width: 100%`, so without a floor it can never overflow its
|
* always `width: 100%`, so without a floor it can never overflow its
|
||||||
@@ -14,14 +20,27 @@ export interface TableCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Flush card shell for a `DataTable`: a borderless, padding-less card whose
|
* Flush card shell for a `DataTable`: a padding-less card whose table region
|
||||||
* single child is a horizontally scrollable region. Pair with the table's
|
* runs edge to edge. Padding is applied per section rather than to the card, so
|
||||||
* `containerClassName="border-0 shadow-none bg-transparent"` so every table on
|
* the optional {@link TableCardProps.header} is inset like any other card
|
||||||
* the customer pages reads identically (same surface, same scroll behaviour).
|
* content while the table's own rows and header cells reach both edges.
|
||||||
|
*
|
||||||
|
* Pair with the table's `containerClassName="border-0 shadow-none bg-transparent"`
|
||||||
|
* so every table on the customer pages reads identically (same surface, same
|
||||||
|
* scroll behaviour).
|
||||||
*/
|
*/
|
||||||
export function TableCard({ children, minWidth = 860 }: TableCardProps) {
|
export function TableCard({
|
||||||
|
children,
|
||||||
|
minWidth = 860,
|
||||||
|
header,
|
||||||
|
}: TableCardProps) {
|
||||||
return (
|
return (
|
||||||
<Card p={0}>
|
<Card p={0}>
|
||||||
|
{header && (
|
||||||
|
<Box p="md" style={{ borderBottom: "1px solid var(--mantine-color-edr-border-0)" }}>
|
||||||
|
{header}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
<Box style={{ overflowX: "auto" }} w="100%">
|
<Box style={{ overflowX: "auto" }} w="100%">
|
||||||
<Box miw={minWidth}>{children}</Box>
|
<Box miw={minWidth}>{children}</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export {
|
|||||||
ChangeRequestReview,
|
ChangeRequestReview,
|
||||||
ChangeRequestPendingBadge,
|
ChangeRequestPendingBadge,
|
||||||
} from "./ChangeRequestReview";
|
} from "./ChangeRequestReview";
|
||||||
|
export { AccountCard } from "./AccountCard";
|
||||||
export { CompanyTimeline } from "./CompanyTimeline";
|
export { CompanyTimeline } from "./CompanyTimeline";
|
||||||
export {
|
export {
|
||||||
RequestDocumentChangeModal,
|
RequestDocumentChangeModal,
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export const QUERY_KEYS = {
|
|||||||
documents: (id: string) =>
|
documents: (id: string) =>
|
||||||
["customers", "detail", id, "documents"] as const,
|
["customers", "detail", id, "documents"] as const,
|
||||||
payments: (id: string) => ["customers", "detail", id, "payments"] as const,
|
payments: (id: string) => ["customers", "detail", id, "payments"] as const,
|
||||||
|
accounts: (id: string) => ["customers", "detail", id, "accounts"] as const,
|
||||||
resetTarget: (id: string) =>
|
resetTarget: (id: string) =>
|
||||||
["customers", "detail", id, "reset-target"] as const,
|
["customers", "detail", id, "reset-target"] as const,
|
||||||
changeRequests: (id: string) =>
|
changeRequests: (id: string) =>
|
||||||
|
|||||||
@@ -138,6 +138,8 @@ export const URL_CONSTANTS = {
|
|||||||
`/backoffice/customers/${companyId}/reset-password`,
|
`/backoffice/customers/${companyId}/reset-password`,
|
||||||
RESET_TARGET: (companyId: string) =>
|
RESET_TARGET: (companyId: string) =>
|
||||||
`/backoffice/customers/${companyId}/reset-target`,
|
`/backoffice/customers/${companyId}/reset-target`,
|
||||||
|
ACCOUNTS: (companyId: string) =>
|
||||||
|
`/backoffice/customers/${companyId}/accounts`,
|
||||||
},
|
},
|
||||||
|
|
||||||
BILLING: {
|
BILLING: {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
History,
|
History,
|
||||||
Hourglass,
|
Hourglass,
|
||||||
|
KeyRound,
|
||||||
IdCard,
|
IdCard,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Package,
|
Package,
|
||||||
@@ -43,6 +44,7 @@ import { useMemo, useState } from "react";
|
|||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
AccountCard,
|
||||||
BookingStatusBadge,
|
BookingStatusBadge,
|
||||||
ChangeRequestPendingBadge,
|
ChangeRequestPendingBadge,
|
||||||
ChangeRequestReview,
|
ChangeRequestReview,
|
||||||
@@ -157,6 +159,12 @@ export default function CustomerDetailPage() {
|
|||||||
enabled: Boolean(id),
|
enabled: Boolean(id),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
const accountsQuery = useQuery(
|
||||||
|
api.customers.accounts.queryOptions({
|
||||||
|
input: { companyId: id ?? "" },
|
||||||
|
enabled: Boolean(id),
|
||||||
|
}),
|
||||||
|
);
|
||||||
const documentsQuery = useQuery(
|
const documentsQuery = useQuery(
|
||||||
api.customers.documents.queryOptions({
|
api.customers.documents.queryOptions({
|
||||||
input: { id: id ?? "" },
|
input: { id: id ?? "" },
|
||||||
@@ -240,12 +248,61 @@ export default function CustomerDetailPage() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<ProfileTypeBadge type={row.original.type} />
|
<ProfileTypeBadge type={row.original.type} />
|
||||||
|
|
||||||
<Text size="sm" fw={600} c="edr-text">
|
{/* The profile reference (EX-A00001). Minted only when a reviewer
|
||||||
{row.original.reference}
|
approves the role, so an unapproved one has none — say so
|
||||||
</Text>
|
rather than rendering an empty line that reads as a bug. */}
|
||||||
|
{row.original.reference ? (
|
||||||
|
<Text size="sm" fw={600} c="edr-text">
|
||||||
|
{row.original.reference}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text size="xs" c="dimmed" fs="italic">
|
||||||
|
Ref. issued on approval
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "etradeBusiness",
|
||||||
|
header: "eTrade business",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const business = row.original.etradeBusiness;
|
||||||
|
// Not attached is a review finding, not a blank: the role names no
|
||||||
|
// business, so there is nothing to check the uploaded licence
|
||||||
|
// against. Companies with no eTrade record legitimately show this,
|
||||||
|
// which is why it reads as a warning rather than an error.
|
||||||
|
if (!business) {
|
||||||
|
return (
|
||||||
|
<Badge size="xs" color="yellow" variant="light">
|
||||||
|
Not attached
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Stack gap={2} maw={230}>
|
||||||
|
<Text size="sm" fw={600} c="edr-text" lineClamp={2}>
|
||||||
|
{business.tradeName || "(no trade name on this licence)"}
|
||||||
|
</Text>
|
||||||
|
{business.activity && (
|
||||||
|
<Text size="xs" c="dimmed" lineClamp={2}>
|
||||||
|
{business.activity}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{/* The licence number is what the reviewer matches against the
|
||||||
|
uploaded document — trade names repeat across licences. */}
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{business.licenceNumber}
|
||||||
|
</Text>
|
||||||
|
{business.renewedTo && (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Renewed to {business.renewedTo}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "licenseFiles",
|
id: "licenseFiles",
|
||||||
header: "License documents",
|
header: "License documents",
|
||||||
@@ -774,6 +831,9 @@ export default function CustomerDetailPage() {
|
|||||||
<Tabs.Tab value="invoices" leftSection={<Receipt size={16} />}>
|
<Tabs.Tab value="invoices" leftSection={<Receipt size={16} />}>
|
||||||
Invoices
|
Invoices
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="accounts" leftSection={<KeyRound size={16} />}>
|
||||||
|
Account
|
||||||
|
</Tabs.Tab>
|
||||||
<Tabs.Tab value="history" leftSection={<History size={16} />}>
|
<Tabs.Tab value="history" leftSection={<History size={16} />}>
|
||||||
History
|
History
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
@@ -981,29 +1041,29 @@ export default function CustomerDetailPage() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
{/* Padding sits on the header section, not the card, so the
|
||||||
<Stack gap="md">
|
table runs edge to edge. minWidth carries the eTrade
|
||||||
|
business column; the region scrolls rather than squashing
|
||||||
|
the other columns. */}
|
||||||
|
<TableCard
|
||||||
|
minWidth={980}
|
||||||
|
header={
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text fw={600} c="edr-text">
|
<Text fw={600} c="edr-text">
|
||||||
Role profiles
|
Role profiles
|
||||||
</Text>
|
</Text>
|
||||||
<ProfileChips profiles={profiles} />
|
<ProfileChips profiles={profiles} />
|
||||||
</Group>
|
</Group>
|
||||||
{/* Narrower than the old full-width layout — the table
|
}
|
||||||
shares the row with the people column now. */}
|
>
|
||||||
<Box style={{ overflowX: "auto" }} w="100%">
|
<DataTable
|
||||||
<Box miw={760}>
|
columns={profileColumns}
|
||||||
<DataTable
|
data={profiles}
|
||||||
columns={profileColumns}
|
status="success"
|
||||||
data={profiles}
|
emptyMessage="No profiles registered."
|
||||||
status="success"
|
containerClassName="border-0 shadow-none bg-transparent"
|
||||||
emptyMessage="No profiles registered."
|
/>
|
||||||
containerClassName="border-0 shadow-none bg-transparent"
|
</TableCard>
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
|
|
||||||
@@ -1439,6 +1499,51 @@ export default function CustomerDetailPage() {
|
|||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
{/* HISTORY */}
|
{/* HISTORY */}
|
||||||
|
{/* ACCOUNT — the IAM logins behind this customer. Distinct from the
|
||||||
|
contact details on Overview: those are business contact info on the
|
||||||
|
company row, these are the credentials someone actually signs in
|
||||||
|
with, and the two drift apart routinely. Cards rather than a table:
|
||||||
|
it is a handful of rows of mostly-optional detail, which a table
|
||||||
|
renders as a field of dashes. */}
|
||||||
|
<Tabs.Panel value="accounts" pt="lg">
|
||||||
|
{accountsQuery.isLoading ? (
|
||||||
|
<Center py="xl">
|
||||||
|
<Loader size="sm" color="edr-green" />
|
||||||
|
</Center>
|
||||||
|
) : accountsQuery.isError ? (
|
||||||
|
<Alert
|
||||||
|
color="red"
|
||||||
|
icon={<AlertTriangle size={16} />}
|
||||||
|
title="Failed to load accounts"
|
||||||
|
>
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<Text size="sm">
|
||||||
|
We couldn't load this customer's portal logins.
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
onClick={() => void accountsQuery.refetch()}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Alert>
|
||||||
|
) : (accountsQuery.data?.length ?? 0) === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<Text size="sm" c="edr-muted" ta="center" py="md">
|
||||||
|
This customer has no portal login yet.
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
|
||||||
|
{accountsQuery.data?.map((account) => (
|
||||||
|
<AccountCard key={account.profileId} account={account} />
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
)}
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
<Tabs.Panel value="history" pt="lg">
|
<Tabs.Panel value="history" pt="lg">
|
||||||
<CompanyTimeline company={company} />
|
<CompanyTimeline company={company} />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|||||||
@@ -108,6 +108,24 @@ const CUSTOMER_FILTER_DEFS: FilterDef[] = [
|
|||||||
["customer", "freight_forwarder", "dj_freight_forwarder", "transporter"] as const
|
["customer", "freight_forwarder", "dj_freight_forwarder", "transporter"] as const
|
||||||
).map((value) => ({ value, label: humanize(value) })),
|
).map((value) => ({ value, label: humanize(value) })),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// The operational role, not `type` above: one `customer` company routinely
|
||||||
|
// holds importer AND exporter, so this asks "who does X?" rather than
|
||||||
|
// "what kind of company is this?".
|
||||||
|
key: "profileType",
|
||||||
|
label: "Role",
|
||||||
|
type: "enum",
|
||||||
|
multiple: false,
|
||||||
|
options: (
|
||||||
|
[
|
||||||
|
"importer",
|
||||||
|
"exporter",
|
||||||
|
"freight_forwarder",
|
||||||
|
"dj_freight_forwarder",
|
||||||
|
"transporter",
|
||||||
|
] as const
|
||||||
|
).map((value) => ({ value, label: humanize(value) })),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "kind",
|
key: "kind",
|
||||||
label: "Sector",
|
label: "Sector",
|
||||||
@@ -348,7 +366,7 @@ export default function CustomersPage() {
|
|||||||
<FilterBar
|
<FilterBar
|
||||||
defs={CUSTOMER_FILTER_DEFS}
|
defs={CUSTOMER_FILTER_DEFS}
|
||||||
controls={controls}
|
controls={controls}
|
||||||
searchPlaceholder="Search by company, TIN, email or profile reference…"
|
searchPlaceholder="Search by company, trade name, TIN, email, licence no. or profile ref…"
|
||||||
sortOptions={SORT_OPTIONS.map((o) => ({ ...o }))}
|
sortOptions={SORT_OPTIONS.map((o) => ({ ...o }))}
|
||||||
viewId="customers"
|
viewId="customers"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -135,14 +135,8 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
// Actual departure — staff often dispatch on paper first and record it later,
|
// Actual departure — staff often dispatch on paper first and record it later,
|
||||||
// so the time is picked (defaults to now when the dialog opens).
|
// so the time is picked (defaults to now when the dialog opens).
|
||||||
const [dispatchAt, setDispatchAt] = useState<Date | null>(null);
|
const [dispatchAt, setDispatchAt] = useState<Date | null>(null);
|
||||||
// Loading is manual: dispatch decides the fate of every unloaded origin
|
|
||||||
// boarder — checked = loaded and departs, unchecked = left behind (wagon
|
|
||||||
// freed, booking back to the pool). Default unchecked; government bookings
|
|
||||||
// cannot be removed from a train so they are forced on.
|
|
||||||
const [dispatchLoadedIds, setDispatchLoadedIds] = useState<Set<string>>(new Set());
|
|
||||||
const openDispatchConfirm = () => {
|
const openDispatchConfirm = () => {
|
||||||
setDispatchAt(new Date());
|
setDispatchAt(new Date());
|
||||||
setDispatchLoadedIds(new Set());
|
|
||||||
setDispatchConfirmOpen(true);
|
setDispatchConfirmOpen(true);
|
||||||
};
|
};
|
||||||
const [switchTarget, setSwitchTarget] = useState<EligibleContainerBooking | null>(null);
|
const [switchTarget, setSwitchTarget] = useState<EligibleContainerBooking | null>(null);
|
||||||
@@ -473,9 +467,8 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
// per yard from the track page's log-pass flow. Everything below is advisory.
|
// per yard from the track page's log-pass flow. Everything below is advisory.
|
||||||
const hasDispatchWarnings =
|
const hasDispatchWarnings =
|
||||||
unassignedCount > 0 || unloadedCount > 0 || intercityNotLoadedCount > 0;
|
unassignedCount > 0 || unloadedCount > 0 || intercityNotLoadedCount > 0;
|
||||||
// Unloaded boarders at the TRAIN's origin — the dispatch dialog's manual
|
// Unloaded boarders at the TRAIN's origin — all sent as loaded on dispatch.
|
||||||
// load/leave list. Mirrors the API's unloadedOriginBoarderIds predicate
|
// Mirrors the API's unloadedOriginBoarderIds predicate (plus government).
|
||||||
// (plus government, which is shown but forced-loaded).
|
|
||||||
const originYardId = schedule.originStation?.id;
|
const originYardId = schedule.originStation?.id;
|
||||||
const pendingOriginBoarders = dispatchBookings.filter(
|
const pendingOriginBoarders = dispatchBookings.filter(
|
||||||
(b) =>
|
(b) =>
|
||||||
@@ -489,12 +482,9 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
// Shipping-line bookings ride from accept on the credit ledger.
|
// Shipping-line bookings ride from accept on the credit ledger.
|
||||||
(Boolean(b.shippingLineCompanyId) && b.status === "FULLY_EXECUTED")),
|
(Boolean(b.shippingLineCompanyId) && b.status === "FULLY_EXECUTED")),
|
||||||
);
|
);
|
||||||
const dispatchLeftCount = pendingOriginBoarders.filter(
|
// Origin loading time window: dispatch (which marks the boarders loaded)
|
||||||
(b) => !b.isGovernment && !dispatchLoadedIds.has(b.id),
|
// is server-rejected until "Start loading" was clicked for the origin
|
||||||
).length;
|
// yard, so the button mirrors that gate.
|
||||||
// Origin loading time window: dispatch (which marks the ticked boarders
|
|
||||||
// loaded) is server-rejected until "Start loading" was clicked for the
|
|
||||||
// origin yard, so the button mirrors that gate.
|
|
||||||
const originLoadingLog = originYardId
|
const originLoadingLog = originYardId
|
||||||
? schedule.stationWorkLogs?.[originYardId]?.loading
|
? schedule.stationWorkLogs?.[originYardId]?.loading
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -557,9 +547,9 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
id: scheduleId,
|
id: scheduleId,
|
||||||
payload: {
|
payload: {
|
||||||
...(dispatchAt ? { actualDepartureAt: dispatchAt.toISOString() } : {}),
|
...(dispatchAt ? { actualDepartureAt: dispatchAt.toISOString() } : {}),
|
||||||
loadedBookingIds: pendingOriginBoarders
|
// No per-booking ticking in the dispatch dialog: every pending origin
|
||||||
.filter((b) => b.isGovernment || dispatchLoadedIds.has(b.id))
|
// boarder rides — none are left behind at dispatch time.
|
||||||
.map((b) => b.id),
|
loadedBookingIds: pendingOriginBoarders.map((b) => b.id),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await openMarshallingDocument({
|
await openMarshallingDocument({
|
||||||
@@ -1590,56 +1580,6 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
radius="md"
|
radius="md"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{pendingOriginBoarders.length > 0 ? (
|
|
||||||
<Stack gap={6}>
|
|
||||||
<Text size="sm" fw={700}>
|
|
||||||
Cargo boarding at {schedule.originStation?.label ?? "the origin yard"} —
|
|
||||||
tick what was loaded
|
|
||||||
</Text>
|
|
||||||
{originYardId ? (
|
|
||||||
<StationWorkControls
|
|
||||||
scheduleId={scheduleId}
|
|
||||||
yardId={originYardId}
|
|
||||||
phase="loading"
|
|
||||||
log={originLoadingLog}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
Unticked bookings are left behind: removed from this train, their
|
|
||||||
wagons freed, and the booking returned to the pool for a later
|
|
||||||
schedule. The customer is notified.
|
|
||||||
</Text>
|
|
||||||
<Stack gap={6} mah={220} style={{ overflowY: "auto" }}>
|
|
||||||
{pendingOriginBoarders.map((b) => (
|
|
||||||
<Checkbox
|
|
||||||
key={b.id}
|
|
||||||
size="sm"
|
|
||||||
checked={b.isGovernment || dispatchLoadedIds.has(b.id)}
|
|
||||||
disabled={b.isGovernment}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = new Set(dispatchLoadedIds);
|
|
||||||
if (e.currentTarget.checked) next.add(b.id);
|
|
||||||
else next.delete(b.id);
|
|
||||||
setDispatchLoadedIds(next);
|
|
||||||
}}
|
|
||||||
label={
|
|
||||||
<Text size="sm" span>
|
|
||||||
{b.reference ?? b.id.slice(0, 8)} — {b.customer ?? "Unknown customer"}
|
|
||||||
{b.isGovernment ? " (government — always rides)" : ""}
|
|
||||||
</Text>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
{dispatchLeftCount > 0 ? (
|
|
||||||
<Text size="xs" c="orange.7" fw={600}>
|
|
||||||
{dispatchLeftCount} booking{dispatchLeftCount === 1 ? "" : "s"} will
|
|
||||||
be left behind and returned to the booking pool.
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
</Stack>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{hasDispatchWarnings ? (
|
{hasDispatchWarnings ? (
|
||||||
<Alert
|
<Alert
|
||||||
color="orange"
|
color="orange"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
CustomerBooking,
|
CustomerBooking,
|
||||||
CustomerDocument,
|
CustomerDocument,
|
||||||
CustomerPayment,
|
CustomerPayment,
|
||||||
|
CustomerAccount,
|
||||||
CustomerResetTarget,
|
CustomerResetTarget,
|
||||||
PaginatedCompanies,
|
PaginatedCompanies,
|
||||||
ProfileStatus,
|
ProfileStatus,
|
||||||
@@ -3222,6 +3223,13 @@ export const api = {
|
|||||||
({ id }) => QUERY_KEYS.CUSTOMERS.payments(id),
|
({ id }) => QUERY_KEYS.CUSTOMERS.payments(id),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
accounts: endpoint<{ companyId: string }, CustomerAccount[]>(
|
||||||
|
"customers",
|
||||||
|
"accounts",
|
||||||
|
({ companyId }) => customersService.accounts(companyId),
|
||||||
|
({ companyId }) => QUERY_KEYS.CUSTOMERS.accounts(companyId),
|
||||||
|
),
|
||||||
|
|
||||||
resetTarget: endpoint<{ companyId: string }, CustomerResetTarget>(
|
resetTarget: endpoint<{ companyId: string }, CustomerResetTarget>(
|
||||||
"customers",
|
"customers",
|
||||||
"resetTarget",
|
"resetTarget",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
CustomerBooking,
|
CustomerBooking,
|
||||||
CustomerDocument,
|
CustomerDocument,
|
||||||
CustomerPayment,
|
CustomerPayment,
|
||||||
|
CustomerAccount,
|
||||||
CustomerResetTarget,
|
CustomerResetTarget,
|
||||||
PaginatedCompanies,
|
PaginatedCompanies,
|
||||||
ProfileStatus,
|
ProfileStatus,
|
||||||
@@ -90,6 +91,18 @@ export const customersService = {
|
|||||||
.then((r) => r.data);
|
.then((r) => r.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every portal login belonging to this customer, primary contact first.
|
||||||
|
*
|
||||||
|
* Not filtered to active accounts — a suspended or never-activated login is
|
||||||
|
* exactly what staff are checking when a customer says they cannot sign in.
|
||||||
|
*/
|
||||||
|
accounts(companyId: string): Promise<CustomerAccount[]> {
|
||||||
|
return apiClient
|
||||||
|
.get<CustomerAccount[]>(URL_CONSTANTS.COMPANIES.ACCOUNTS(companyId))
|
||||||
|
.then((r) => r.data);
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The IAM account a reset link would go to. Read before offering the action
|
* The IAM account a reset link would go to. Read before offering the action
|
||||||
* so staff see the credentials the link actually reaches, not the company's
|
* so staff see the credentials the link actually reaches, not the company's
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
* API so the data layer can be swapped to live endpoints with no UI changes.
|
* API so the data layer can be swapped to live endpoints with no UI changes.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { ETradeBusinessOption } from "@edr/types";
|
||||||
|
|
||||||
/** Mirrors backend `CompanyType`. */
|
/** Mirrors backend `CompanyType`. */
|
||||||
export type CompanyType =
|
export type CompanyType =
|
||||||
| "customer"
|
| "customer"
|
||||||
@@ -65,6 +67,15 @@ export interface CompanyProfile {
|
|||||||
/** Business-license documents uploaded for this profile. */
|
/** Business-license documents uploaded for this profile. */
|
||||||
licenseFiles?: LicenseFile[];
|
licenseFiles?: LicenseFile[];
|
||||||
attributes?: Record<string, unknown> | null;
|
attributes?: Record<string, unknown> | null;
|
||||||
|
/**
|
||||||
|
* Which of the TIN's eTrade business licences this role operates as.
|
||||||
|
*
|
||||||
|
* A TIN routinely holds a dozen licences split by activity, so "exporter" and
|
||||||
|
* "freight forwarder" are usually two different businesses under one company.
|
||||||
|
* Null when the customer has not attached one, or when the company registered
|
||||||
|
* without eTrade at all (co-operative / investment licence).
|
||||||
|
*/
|
||||||
|
etradeBusiness?: ETradeBusinessOption | null;
|
||||||
/** Reviewer note when the role is rejected. */
|
/** Reviewer note when the role is rejected. */
|
||||||
reviewNote?: string | null;
|
reviewNote?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -166,6 +177,34 @@ export interface ResetPasswordResult {
|
|||||||
* Distinct from `Company.email` / `Company.phone`, which are business contact
|
* Distinct from `Company.email` / `Company.phone`, which are business contact
|
||||||
* details and routinely differ from the credentials the customer logs in with.
|
* details and routinely differ from the credentials the customer logs in with.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* One portal login belonging to a customer: the company-side profile joined to
|
||||||
|
* the IAM account that actually signs in. Mirrors the API's `CustomerAccount`.
|
||||||
|
*
|
||||||
|
* The IAM fields are null when the profile points at a user row that no longer
|
||||||
|
* exists — surfaced rather than hidden, since that is itself a fault worth
|
||||||
|
* seeing.
|
||||||
|
*/
|
||||||
|
export interface CustomerAccount {
|
||||||
|
profileId: string;
|
||||||
|
userId: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
jobTitle: string | null;
|
||||||
|
isPrimaryContact: boolean;
|
||||||
|
onboardingStep: string | null;
|
||||||
|
onboardingCompleted: boolean;
|
||||||
|
username: string | null;
|
||||||
|
email: string | null;
|
||||||
|
phoneNumber: string | null;
|
||||||
|
phoneVerified: boolean | null;
|
||||||
|
status: string | null;
|
||||||
|
isActive: boolean | null;
|
||||||
|
/** False means the account exists but its owner never set a password. */
|
||||||
|
hasSetPassword: boolean | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CustomerResetTarget {
|
export interface CustomerResetTarget {
|
||||||
userId: string;
|
userId: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -314,6 +353,13 @@ export interface CompanyListFilter {
|
|||||||
kind?: CompanyKind;
|
kind?: CompanyKind;
|
||||||
status?: CompanyStatus;
|
status?: CompanyStatus;
|
||||||
nationality?: CompanyNationality;
|
nationality?: CompanyNationality;
|
||||||
|
/**
|
||||||
|
* Only companies holding this operational role. Distinct from `type`, which
|
||||||
|
* is the company's own kind — a `customer` company can hold importer,
|
||||||
|
* exporter and forwarder roles at once, and its other roles still come back
|
||||||
|
* on the row.
|
||||||
|
*/
|
||||||
|
profileType?: ProfileType;
|
||||||
/** ISO instants — inclusive bounds on the registration date. */
|
/** ISO instants — inclusive bounds on the registration date. */
|
||||||
createdFrom?: string;
|
createdFrom?: string;
|
||||||
createdTo?: string;
|
createdTo?: string;
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { Alert, Loader, Select, Stack, Text } from "@mantine/core";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { AlertCircle } from "lucide-react";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
import type { ETradeBusinessOption } from "@edr/types";
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The eTrade business licences held under the signed-in company's TIN, cached
|
||||||
|
* for the session. Fetching goes out to eTrade, which is slow and regularly
|
||||||
|
* down, so this must not refetch on every mount of every role card.
|
||||||
|
*/
|
||||||
|
export function useEtradeBusinesses() {
|
||||||
|
return useQuery({
|
||||||
|
...api.companies.listEtradeBusinesses.queryOptions(),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
retry: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One licence, as it reads in the dropdown: trade name, then what it licenses. */
|
||||||
|
export function businessLabel(b: ETradeBusinessOption): string {
|
||||||
|
const name = b.tradeName || "(no trade name on this licence)";
|
||||||
|
return b.activity ? `${name} — ${b.activity}` : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EtradeBusinessSelectProps {
|
||||||
|
/** Currently attached licence number, if any. */
|
||||||
|
value: string | null;
|
||||||
|
onChange: (licenceNumber: string) => void;
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which of the TIN's eTrade businesses a company profile operates as.
|
||||||
|
*
|
||||||
|
* A TIN routinely holds a dozen licences split by activity — export of coffee,
|
||||||
|
* freight forwarding, import of vehicles — so the role a customer signs up for
|
||||||
|
* corresponds to one specific business, not to the company as a whole. The same
|
||||||
|
* business may legitimately back several roles, so nothing is filtered out
|
||||||
|
* because it is already in use elsewhere.
|
||||||
|
*/
|
||||||
|
export default function EtradeBusinessSelect({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
label = "Which business does this profile operate as?",
|
||||||
|
error,
|
||||||
|
disabled,
|
||||||
|
}: EtradeBusinessSelectProps) {
|
||||||
|
const { data, isLoading, isError } = useEtradeBusinesses();
|
||||||
|
|
||||||
|
const options = useMemo(
|
||||||
|
() =>
|
||||||
|
(data ?? []).map((b) => ({
|
||||||
|
value: b.licenceNumber,
|
||||||
|
label: businessLabel(b),
|
||||||
|
})),
|
||||||
|
[data],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Stack gap={4}>
|
||||||
|
<Text size="sm" c="edr-muted">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Loader size="sm" color="edr-green" />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<Alert color="yellow" icon={<AlertCircle size={16} />}>
|
||||||
|
We couldn't reach eTrade to list your business licences. Try again in a
|
||||||
|
moment.
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.length === 0) {
|
||||||
|
return (
|
||||||
|
<Text size="xs" c="edr-muted">
|
||||||
|
eTrade lists no business licence under your TIN, so there is nothing to
|
||||||
|
attach here.
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
label={label}
|
||||||
|
placeholder="Select a business licence"
|
||||||
|
data={options}
|
||||||
|
value={value}
|
||||||
|
onChange={(v) => v && onChange(v)}
|
||||||
|
error={error}
|
||||||
|
disabled={disabled}
|
||||||
|
searchable={options.length > 8}
|
||||||
|
nothingFoundMessage="No matching licence"
|
||||||
|
// The licence number is what identifies the business; the trade name
|
||||||
|
// repeats across licences, so it alone is not enough to tell them apart.
|
||||||
|
description={value ?? undefined}
|
||||||
|
comboboxProps={{ withinPortal: true }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -428,6 +428,7 @@ export default function OnboardingWizardDialog({
|
|||||||
type: p.type,
|
type: p.type,
|
||||||
reference: p.reference,
|
reference: p.reference,
|
||||||
existingFiles: p.licenseFiles ?? [],
|
existingFiles: p.licenseFiles ?? [],
|
||||||
|
etradeBusiness: p.etradeBusiness ?? null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// The active step across the whole journey, driving the header + progress pill.
|
// The active step across the whole journey, driving the header + progress pill.
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { Anchor, Group, Stack, Text } from "@mantine/core";
|
import { Anchor, Group, Stack, Text } from "@mantine/core";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Fragment } from "react";
|
||||||
import { Paperclip } from "lucide-react";
|
import { Paperclip } from "lucide-react";
|
||||||
|
|
||||||
import { SmartFileInput } from "@edr/ui-common";
|
import { SmartFileInput } from "@edr/ui-common";
|
||||||
import type { IFileUploadSetting } from "@edr/types/freight";
|
import type { ETradeBusinessOption, IFileUploadSetting } from "@edr/types";
|
||||||
|
|
||||||
|
import EtradeBusinessSelect from "@/components/onboarding/EtradeBusinessSelect";
|
||||||
|
import { api } from "@/services/api";
|
||||||
import { fetchViewableFile } from "@/services/files.service";
|
import { fetchViewableFile } from "@/services/files.service";
|
||||||
import type { LicenseFile } from "@/services/companies.service";
|
import type { LicenseFile } from "@/services/companies.service";
|
||||||
|
|
||||||
@@ -63,6 +67,8 @@ export interface RoleLicenseProfile {
|
|||||||
reference: string;
|
reference: string;
|
||||||
/** License files already uploaded for this profile (rehydration). */
|
/** License files already uploaded for this profile (rehydration). */
|
||||||
existingFiles: LicenseFile[];
|
existingFiles: LicenseFile[];
|
||||||
|
/** The eTrade business already attached to this profile, if any. */
|
||||||
|
etradeBusiness?: ETradeBusinessOption | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RoleLicenseStepProps {
|
interface RoleLicenseStepProps {
|
||||||
@@ -73,6 +79,8 @@ interface RoleLicenseStepProps {
|
|||||||
onChange: (value: Record<string, File[]>) => void;
|
onChange: (value: Record<string, File[]>) => void;
|
||||||
/** "Business license is required" style error, keyed by profile id. */
|
/** "Business license is required" style error, keyed by profile id. */
|
||||||
errors?: Record<string, string>;
|
errors?: Record<string, string>;
|
||||||
|
/** "Choose a business" error, keyed by profile id. */
|
||||||
|
businessErrors?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -86,16 +94,43 @@ export default function RoleLicenseStep({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
errors,
|
errors,
|
||||||
|
businessErrors,
|
||||||
}: RoleLicenseStepProps) {
|
}: RoleLicenseStepProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const setFiles = (profileId: string, files: File[]) => {
|
const setFiles = (profileId: string, files: File[]) => {
|
||||||
onChange({ ...value, [profileId]: files });
|
onChange({ ...value, [profileId]: files });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Attaching saves immediately rather than riding along with the step's
|
||||||
|
// submit: the roles were created on the wizard's first step, so each already
|
||||||
|
// has a row to attach to, and persisting on pick means a refresh or a resumed
|
||||||
|
// draft keeps the choice.
|
||||||
|
const attach = useMutation({
|
||||||
|
mutationFn: (vars: { profileId: string; licenceNumber: string }) =>
|
||||||
|
api.companies.attachEtradeBusiness.call(vars),
|
||||||
|
onSuccess: () => {
|
||||||
|
// getInfo FIRST: the wizard reads its role list (and each role's attached
|
||||||
|
// business) from that query, so skipping it leaves the dropdown showing
|
||||||
|
// blank right after a successful pick.
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: api.companies.getInfo.queryKey(),
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: api.companies.getProfile.queryKey(),
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: api.companies.onboardingRequirements.queryKey(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Text size="sm" c="edr-muted">
|
<Text size="sm" c="edr-muted">
|
||||||
Upload the business license for each of your operational profiles. You
|
For each operational profile, say which of your eTrade business licences
|
||||||
can attach more than one document per profile.
|
it operates as, and upload that licence. You can attach more than one
|
||||||
|
document per profile, and the same business can back more than one role.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{profiles.map((profile) => {
|
{profiles.map((profile) => {
|
||||||
@@ -104,7 +139,21 @@ export default function RoleLicenseStep({
|
|||||||
const hasExisting = profile.existingFiles.length > 0;
|
const hasExisting = profile.existingFiles.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Fragment key={profile.id}>
|
||||||
|
<EtradeBusinessSelect
|
||||||
|
label={`Which business is your ${label} profile?`}
|
||||||
|
value={profile.etradeBusiness?.licenceNumber ?? null}
|
||||||
|
error={businessErrors?.[profile.id]}
|
||||||
|
// Only the row being saved locks; picking the importer's business
|
||||||
|
// must not freeze the exporter's dropdown next to it.
|
||||||
|
disabled={
|
||||||
|
attach.isPending && attach.variables?.profileId === profile.id
|
||||||
|
}
|
||||||
|
onChange={(licenceNumber) =>
|
||||||
|
attach.mutate({ profileId: profile.id, licenceNumber })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{hasExisting && (
|
{hasExisting && (
|
||||||
<Stack gap={4} mb="sm">
|
<Stack gap={4} mb="sm">
|
||||||
{profile.existingFiles.map((f) => (
|
{profile.existingFiles.map((f) => (
|
||||||
@@ -142,7 +191,7 @@ export default function RoleLicenseStep({
|
|||||||
setFiles(profile.id, files);
|
setFiles(profile.id, files);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -106,6 +106,9 @@ export const URL_CONSTANTS = {
|
|||||||
ONBOARDING_REVERT_TO_ETRADE: "/api/companies/onboarding/revert-to-etrade",
|
ONBOARDING_REVERT_TO_ETRADE: "/api/companies/onboarding/revert-to-etrade",
|
||||||
DASHBOARD: "/api/companies/dashboard",
|
DASHBOARD: "/api/companies/dashboard",
|
||||||
FETCH_ETRADE_INFO: "/api/companies/fetch-etrade-info",
|
FETCH_ETRADE_INFO: "/api/companies/fetch-etrade-info",
|
||||||
|
ETRADE_BUSINESSES: "/api/companies/etrade-businesses",
|
||||||
|
PROFILE_ETRADE_BUSINESS: (profileId: string) =>
|
||||||
|
`/api/companies/company-profiles/${profileId}/etrade-business`,
|
||||||
DOCUMENTS: (id: string) => `/api/companies/${id}/documents`,
|
DOCUMENTS: (id: string) => `/api/companies/${id}/documents`,
|
||||||
PROFILE_LICENSE: (profileId: string) =>
|
PROFILE_LICENSE: (profileId: string) =>
|
||||||
`/api/companies/company-profiles/${profileId}/license`,
|
`/api/companies/company-profiles/${profileId}/license`,
|
||||||
|
|||||||
@@ -232,9 +232,17 @@ const useAuth = () => {
|
|||||||
const createProfile = async (
|
const createProfile = async (
|
||||||
type: ProfileTypeValue,
|
type: ProfileTypeValue,
|
||||||
licenseFiles: File[],
|
licenseFiles: File[],
|
||||||
|
/**
|
||||||
|
* Which of the TIN's eTrade businesses the new role operates as. Required
|
||||||
|
* by the API for any company that has an eTrade record.
|
||||||
|
*/
|
||||||
|
licenceNumber?: string,
|
||||||
): Promise<Result<void>> => {
|
): Promise<Result<void>> => {
|
||||||
try {
|
try {
|
||||||
const created = await api.companies.createCompanyProfile.call({ type });
|
const created = await api.companies.createCompanyProfile.call({
|
||||||
|
type,
|
||||||
|
licenceNumber,
|
||||||
|
});
|
||||||
if (licenseFiles.length > 0) {
|
if (licenseFiles.length > 0) {
|
||||||
await companiesService.uploadProfileLicense(created.id, licenseFiles);
|
await companiesService.uploadProfileLicense(created.id, licenseFiles);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ import {
|
|||||||
useParams,
|
useParams,
|
||||||
} from "react-router-dom";
|
} from "react-router-dom";
|
||||||
import useAuth from "@/hooks/useAuth";
|
import useAuth from "@/hooks/useAuth";
|
||||||
|
import EtradeBusinessSelect, {
|
||||||
|
useEtradeBusinesses,
|
||||||
|
} from "@/components/onboarding/EtradeBusinessSelect";
|
||||||
import {
|
import {
|
||||||
CONTAINER_SIZES,
|
CONTAINER_SIZES,
|
||||||
CONTRACT_STEPS,
|
CONTRACT_STEPS,
|
||||||
@@ -417,6 +420,11 @@ export default function NewContractPage({
|
|||||||
[profileStatusByType, profileTypes],
|
[profileStatusByType, profileTypes],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Empty for a co-operative or investor-licence company — eTrade holds no
|
||||||
|
// record for it, so there is no business to attach and none is asked for.
|
||||||
|
const { data: etradeBusinesses } = useEtradeBusinesses();
|
||||||
|
const createBusinessRequired = (etradeBusinesses?.length ?? 0) > 0;
|
||||||
|
|
||||||
// Create-profile modal state (license upload → createProfile).
|
// Create-profile modal state (license upload → createProfile).
|
||||||
const [createTarget, setCreateTarget] = useState<ProfileTypeValue | null>(
|
const [createTarget, setCreateTarget] = useState<ProfileTypeValue | null>(
|
||||||
null,
|
null,
|
||||||
@@ -424,6 +432,9 @@ export default function NewContractPage({
|
|||||||
const [pendingOperation, setPendingOperation] =
|
const [pendingOperation, setPendingOperation] =
|
||||||
useState<OperationType | null>(null);
|
useState<OperationType | null>(null);
|
||||||
const [licenseFiles, setLicenseFiles] = useState<File[]>([]);
|
const [licenseFiles, setLicenseFiles] = useState<File[]>([]);
|
||||||
|
// Which eTrade business the new role operates as — a TIN holds several
|
||||||
|
// licences and the role corresponds to one of them.
|
||||||
|
const [createLicence, setCreateLicence] = useState<string | null>(null);
|
||||||
const [createError, setCreateError] = useState<string | null>(null);
|
const [createError, setCreateError] = useState<string | null>(null);
|
||||||
// After a license is uploaded the new profile comes back "pending", so the
|
// After a license is uploaded the new profile comes back "pending", so the
|
||||||
// create-profile modal switches to an "awaiting approval" success state.
|
// create-profile modal switches to an "awaiting approval" success state.
|
||||||
@@ -437,11 +448,13 @@ export default function NewContractPage({
|
|||||||
mutationFn: async ({
|
mutationFn: async ({
|
||||||
type,
|
type,
|
||||||
files,
|
files,
|
||||||
|
licenceNumber,
|
||||||
}: {
|
}: {
|
||||||
type: ProfileTypeValue;
|
type: ProfileTypeValue;
|
||||||
files: File[];
|
files: File[];
|
||||||
|
licenceNumber?: string;
|
||||||
}) => {
|
}) => {
|
||||||
const res = await auth.createProfile(type, files);
|
const res = await auth.createProfile(type, files, licenceNumber);
|
||||||
if (!res.success) {
|
if (!res.success) {
|
||||||
throw new Error(res.error?.message ?? "Failed to create profile");
|
throw new Error(res.error?.message ?? "Failed to create profile");
|
||||||
}
|
}
|
||||||
@@ -507,7 +520,17 @@ export default function NewContractPage({
|
|||||||
setCreateError("Please upload at least one business license file.");
|
setCreateError("Please upload at least one business license file.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
createProfileMutation.mutate({ type: createTarget, files: licenseFiles });
|
// Only companies eTrade actually knows have a licence list; a co-operative
|
||||||
|
// has none, and the API does not ask them for one.
|
||||||
|
if (createBusinessRequired && !createLicence) {
|
||||||
|
setCreateError("Please choose which eTrade business this profile is.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createProfileMutation.mutate({
|
||||||
|
type: createTarget,
|
||||||
|
files: licenseFiles,
|
||||||
|
licenceNumber: createLicence ?? undefined,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateProfileCancel = () => {
|
const handleCreateProfileCancel = () => {
|
||||||
@@ -1235,6 +1258,13 @@ export default function NewContractPage({
|
|||||||
Add your business license to create one. It goes to staff for
|
Add your business license to create one. It goes to staff for
|
||||||
approval before you can use it.
|
approval before you can use it.
|
||||||
</Text>
|
</Text>
|
||||||
|
{createBusinessRequired && (
|
||||||
|
<EtradeBusinessSelect
|
||||||
|
value={createLicence}
|
||||||
|
onChange={setCreateLicence}
|
||||||
|
disabled={createProfileMutation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<FileInput
|
<FileInput
|
||||||
label="Business license"
|
label="Business license"
|
||||||
multiple
|
multiple
|
||||||
|
|||||||
@@ -6,10 +6,15 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
Group,
|
Group,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
Title,
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
import EtradeBusinessSelect, {
|
||||||
|
businessLabel,
|
||||||
|
useEtradeBusinesses,
|
||||||
|
} from "@/components/onboarding/EtradeBusinessSelect";
|
||||||
import type { CompanyProfileResponse } from "@/services/companies.service";
|
import type { CompanyProfileResponse } from "@/services/companies.service";
|
||||||
import type { ProfileResponse } from "@/types/profile";
|
import type { ProfileResponse } from "@/types/profile";
|
||||||
import RoleCard from "./RoleCard";
|
import RoleCard from "./RoleCard";
|
||||||
@@ -63,6 +68,16 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
|
|||||||
|
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Which eTrade business each newly-selected role will operate as. A TIN holds
|
||||||
|
// many licences split by activity, so this is picked per role, not per
|
||||||
|
// company — and the same business may back several roles.
|
||||||
|
const [licenceByType, setLicenceByType] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// Empty for a co-operative or investor-licence company: eTrade holds no
|
||||||
|
// record for it, so there is nothing to attach and nothing to require.
|
||||||
|
const { data: businesses } = useEtradeBusinesses();
|
||||||
|
const businessRequired = (businesses?.length ?? 0) > 0;
|
||||||
|
|
||||||
const toggle = (type: string) => {
|
const toggle = (type: string) => {
|
||||||
if (profileByType.has(type)) return; // add-only: existing roles are locked
|
if (profileByType.has(type)) return; // add-only: existing roles are locked
|
||||||
setSelected((prev) => {
|
setSelected((prev) => {
|
||||||
@@ -71,13 +86,40 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
|
|||||||
else next.add(type);
|
else next.add(type);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
// Deselecting drops the licence with it, so re-picking the role does not
|
||||||
|
// silently reuse a choice the user backed out of.
|
||||||
|
setLicenceByType((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[type];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Attach (or change) the business on a role that already exists.
|
||||||
|
const attachMutation = useMutation({
|
||||||
|
mutationFn: (vars: { profileId: string; licenceNumber: string }) =>
|
||||||
|
api.companies.attachEtradeBusiness.call(vars),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: api.companies.getProfile.queryKey(),
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: api.companies.getInfo.queryKey(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: (types: string[]) =>
|
mutationFn: (types: string[]) =>
|
||||||
api.companies.addCompanyProfiles.call({ types }),
|
api.companies.addCompanyProfiles.call({
|
||||||
|
profiles: types.map((type) => ({
|
||||||
|
type,
|
||||||
|
licenceNumber: licenceByType[type],
|
||||||
|
})),
|
||||||
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setSelected(new Set());
|
setSelected(new Set());
|
||||||
|
setLicenceByType({});
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: api.companies.getProfile.queryKey(),
|
queryKey: api.companies.getProfile.queryKey(),
|
||||||
});
|
});
|
||||||
@@ -103,8 +145,14 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Every selected role needs its business named first — the API rejects a role
|
||||||
|
// added without one, so the button is what tells the user, not a 400.
|
||||||
|
const missingLicence =
|
||||||
|
businessRequired &&
|
||||||
|
Array.from(selected).some((type) => !licenceByType[type]);
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
if (selected.size === 0) return;
|
if (selected.size === 0 || missingLicence) return;
|
||||||
mutation.mutate(Array.from(selected));
|
mutation.mutate(Array.from(selected));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -142,25 +190,53 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
|
|||||||
lockedNote={view?.note}
|
lockedNote={view?.note}
|
||||||
lockedNoteColor={view?.color}
|
lockedNoteColor={view?.color}
|
||||||
detail={
|
detail={
|
||||||
(rejected || existing?.status === "suspended") &&
|
existing
|
||||||
existing?.reviewNote
|
? [
|
||||||
? `Reviewer note: ${existing.reviewNote}`
|
existing.etradeBusiness
|
||||||
|
? `Operating as: ${businessLabel(existing.etradeBusiness)}`
|
||||||
|
: businessRequired
|
||||||
|
? "No eTrade business attached yet"
|
||||||
|
: null,
|
||||||
|
(rejected || existing.status === "suspended") &&
|
||||||
|
existing.reviewNote
|
||||||
|
? `Reviewer note: ${existing.reviewNote}`
|
||||||
|
: null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ") || undefined
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
action={
|
action={
|
||||||
rejected ? (
|
existing ? (
|
||||||
<Button
|
<Stack gap="xs">
|
||||||
size="xs"
|
{businessRequired && (
|
||||||
variant="light"
|
<EtradeBusinessSelect
|
||||||
leftSection={<RefreshCw size={14} />}
|
label="Operating as"
|
||||||
loading={
|
value={existing.etradeBusiness?.licenceNumber ?? null}
|
||||||
reapplyMutation.isPending &&
|
disabled={attachMutation.isPending}
|
||||||
reapplyMutation.variables === existing.id
|
onChange={(licenceNumber) =>
|
||||||
}
|
attachMutation.mutate({
|
||||||
onClick={() => reapplyMutation.mutate(existing.id)}
|
profileId: existing.id,
|
||||||
>
|
licenceNumber,
|
||||||
Resubmit for approval
|
})
|
||||||
</Button>
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{rejected && (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
leftSection={<RefreshCw size={14} />}
|
||||||
|
loading={
|
||||||
|
reapplyMutation.isPending &&
|
||||||
|
reapplyMutation.variables === existing.id
|
||||||
|
}
|
||||||
|
onClick={() => reapplyMutation.mutate(existing.id)}
|
||||||
|
>
|
||||||
|
Resubmit for approval
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
onClick={() => toggle(opt.type)}
|
onClick={() => toggle(opt.type)}
|
||||||
@@ -170,6 +246,29 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
|
|||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{businessRequired && selected.size > 0 && (
|
||||||
|
<Stack gap="sm" mt="lg">
|
||||||
|
<Text size="sm" c="edr-muted">
|
||||||
|
Say which of your eTrade business licences each new role operates as.
|
||||||
|
</Text>
|
||||||
|
{options
|
||||||
|
.filter((opt) => selected.has(opt.type))
|
||||||
|
.map((opt) => (
|
||||||
|
<EtradeBusinessSelect
|
||||||
|
key={opt.type}
|
||||||
|
label={`${opt.label} operates as`}
|
||||||
|
value={licenceByType[opt.type] ?? null}
|
||||||
|
onChange={(licenceNumber) =>
|
||||||
|
setLicenceByType((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[opt.type]: licenceNumber,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
{options.length > 0 && (
|
{options.length > 0 && (
|
||||||
<Group
|
<Group
|
||||||
justify="space-between"
|
justify="space-between"
|
||||||
@@ -199,7 +298,7 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
leftSection={<Save size={16} />}
|
leftSection={<Save size={16} />}
|
||||||
loading={mutation.isPending}
|
loading={mutation.isPending}
|
||||||
disabled={selected.size === 0}
|
disabled={selected.size === 0 || missingLicence}
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
>
|
>
|
||||||
{selected.size > 1 ? "Add Roles" : "Add Role"}
|
{selected.size > 1 ? "Add Roles" : "Add Role"}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Freight, PaginatedResponse } from "@edr/types";
|
import type { ETradeBusinessOption, Freight, PaginatedResponse } from "@edr/types";
|
||||||
import { endpoint } from "@/utils/endpoint";
|
import { endpoint } from "@/utils/endpoint";
|
||||||
import type {
|
import type {
|
||||||
CreateFileUploadFieldDto,
|
CreateFileUploadFieldDto,
|
||||||
@@ -219,14 +219,13 @@ export const api = {
|
|||||||
companiesService.getDashboard,
|
companiesService.getDashboard,
|
||||||
),
|
),
|
||||||
|
|
||||||
addCompanyProfiles: endpoint<{ types: string[] }, CompanyProfileResponse[]>(
|
addCompanyProfiles: endpoint<
|
||||||
"companies",
|
{ profiles: { type: string; licenceNumber?: string }[] },
|
||||||
"addCompanyProfiles",
|
CompanyProfileResponse[]
|
||||||
companiesService.addCompanyProfiles,
|
>("companies", "addCompanyProfiles", companiesService.addCompanyProfiles),
|
||||||
),
|
|
||||||
|
|
||||||
createCompanyProfile: endpoint<
|
createCompanyProfile: endpoint<
|
||||||
{ type: ProfileTypeValue; businessLicense?: string },
|
{ type: ProfileTypeValue; businessLicense?: string; licenceNumber?: string },
|
||||||
CompanyProfileResponse
|
CompanyProfileResponse
|
||||||
>(
|
>(
|
||||||
"companies",
|
"companies",
|
||||||
@@ -234,6 +233,21 @@ export const api = {
|
|||||||
companiesService.createCompanyProfile,
|
companiesService.createCompanyProfile,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
listEtradeBusinesses: endpoint<void, ETradeBusinessOption[]>(
|
||||||
|
"companies",
|
||||||
|
"listEtradeBusinesses",
|
||||||
|
companiesService.listEtradeBusinesses,
|
||||||
|
),
|
||||||
|
|
||||||
|
attachEtradeBusiness: endpoint<
|
||||||
|
{ profileId: string; licenceNumber: string },
|
||||||
|
CompanyProfileResponse
|
||||||
|
>(
|
||||||
|
"companies",
|
||||||
|
"attachEtradeBusiness",
|
||||||
|
companiesService.attachEtradeBusiness,
|
||||||
|
),
|
||||||
|
|
||||||
startOnboarding: endpoint<
|
startOnboarding: endpoint<
|
||||||
{
|
{
|
||||||
companyType: string;
|
companyType: string;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { client } from "@/utils/api";
|
|||||||
import { unwrap } from "@/utils/endpoint";
|
import { unwrap } from "@/utils/endpoint";
|
||||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||||
import type { ApiResponse } from "@/types/apiResponse";
|
import type { ApiResponse } from "@/types/apiResponse";
|
||||||
|
import type { ETradeBusinessOption } from "@edr/types";
|
||||||
import type { CompanyIdentityState } from "./verifayda.service";
|
import type { CompanyIdentityState } from "./verifayda.service";
|
||||||
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
||||||
import { isAxiosError } from "axios";
|
import { isAxiosError } from "axios";
|
||||||
@@ -83,6 +84,11 @@ export interface CompanyProfileResponse {
|
|||||||
/** Business-license documents uploaded for this profile. */
|
/** Business-license documents uploaded for this profile. */
|
||||||
licenseFiles: LicenseFile[];
|
licenseFiles: LicenseFile[];
|
||||||
attributes: Record<string, any> | null;
|
attributes: Record<string, any> | null;
|
||||||
|
/**
|
||||||
|
* The eTrade business licence this role operates as, or null when nothing is
|
||||||
|
* attached yet (or the company registered without eTrade).
|
||||||
|
*/
|
||||||
|
etradeBusiness: ETradeBusinessOption | null;
|
||||||
/** Reviewer note when the role is rejected (drives the reapply prompt). */
|
/** Reviewer note when the role is rejected (drives the reapply prompt). */
|
||||||
reviewNote?: string | null;
|
reviewNote?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -338,7 +344,7 @@ export const companiesService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
addCompanyProfiles: async (payload: {
|
addCompanyProfiles: async (payload: {
|
||||||
types: string[];
|
profiles: { type: string; licenceNumber?: string }[];
|
||||||
}): Promise<CompanyProfileResponse[]> => {
|
}): Promise<CompanyProfileResponse[]> => {
|
||||||
const response = await client.post<ApiResponse<CompanyProfileResponse[]>>(
|
const response = await client.post<ApiResponse<CompanyProfileResponse[]>>(
|
||||||
URL_CONSTANTS.COMPANIES_API.COMPANY_PROFILES,
|
URL_CONSTANTS.COMPANIES_API.COMPANY_PROFILES,
|
||||||
@@ -351,6 +357,7 @@ export const companiesService = {
|
|||||||
createCompanyProfile: async (payload: {
|
createCompanyProfile: async (payload: {
|
||||||
type: ProfileTypeValue;
|
type: ProfileTypeValue;
|
||||||
businessLicense?: string;
|
businessLicense?: string;
|
||||||
|
licenceNumber?: string;
|
||||||
}): Promise<CompanyProfileResponse> => {
|
}): Promise<CompanyProfileResponse> => {
|
||||||
const response = await client.post<ApiResponse<CompanyProfileResponse>>(
|
const response = await client.post<ApiResponse<CompanyProfileResponse>>(
|
||||||
URL_CONSTANTS.COMPANIES_API.COMPANY_PROFILE,
|
URL_CONSTANTS.COMPANIES_API.COMPANY_PROFILE,
|
||||||
@@ -359,6 +366,29 @@ export const companiesService = {
|
|||||||
return unwrap(response.data);
|
return unwrap(response.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The eTrade business licences held under this company's TIN. Empty for a
|
||||||
|
* co-operative or investor-licence company, which has no eTrade record.
|
||||||
|
*/
|
||||||
|
listEtradeBusinesses: async (): Promise<ETradeBusinessOption[]> => {
|
||||||
|
const response = await client.get<ApiResponse<ETradeBusinessOption[]>>(
|
||||||
|
URL_CONSTANTS.COMPANIES_API.ETRADE_BUSINESSES,
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Attach (or re-attach) one of those businesses to an operational profile. */
|
||||||
|
attachEtradeBusiness: async (payload: {
|
||||||
|
profileId: string;
|
||||||
|
licenceNumber: string;
|
||||||
|
}): Promise<CompanyProfileResponse> => {
|
||||||
|
const response = await client.patch<ApiResponse<CompanyProfileResponse>>(
|
||||||
|
URL_CONSTANTS.COMPANIES_API.PROFILE_ETRADE_BUSINESS(payload.profileId),
|
||||||
|
{ licenceNumber: payload.licenceNumber },
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
/** Begin onboarding — create the draft company + profile + role(s) up front. */
|
/** Begin onboarding — create the draft company + profile + role(s) up front. */
|
||||||
startOnboarding: async (payload: {
|
startOnboarding: async (payload: {
|
||||||
companyType: string;
|
companyType: string;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { APP_FILTER } from "@nestjs/core";
|
|||||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||||
import { ScheduleModule } from "@nestjs/schedule";
|
import { ScheduleModule } from "@nestjs/schedule";
|
||||||
import { EventEmitterModule } from "@nestjs/event-emitter";
|
import { EventEmitterModule } from "@nestjs/event-emitter";
|
||||||
|
import { ThrottlerModule } from "@nestjs/throttler";
|
||||||
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
|
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
|
||||||
import { IamBaselineSeeder, IamSeedModule } from "@edr/iam-seed";
|
import { IamBaselineSeeder, IamSeedModule } from "@edr/iam-seed";
|
||||||
import { IamModule as TriaIamModule } from "@tria-plc/iamapi-common/iam.module";
|
import { IamModule as TriaIamModule } from "@tria-plc/iamapi-common/iam.module";
|
||||||
@@ -82,6 +83,17 @@ import { EOtpType } from "@tria-plc/iamapi-common";
|
|||||||
}),
|
}),
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
EventEmitterModule.forRoot(),
|
EventEmitterModule.forRoot(),
|
||||||
|
// Named tiers only — no APP_GUARD is registered, so nothing is throttled until a
|
||||||
|
// controller opts in with @UseGuards(ThrottlerGuard). AuthController is currently the
|
||||||
|
// only one that does, because the staged sign-in exposes an account-existence lookup.
|
||||||
|
ThrottlerModule.forRoot([
|
||||||
|
// 20/min, not the 5/min the commented-out decorators suggested: the staged sign-in
|
||||||
|
// legitimately costs 3-5 calls (lookup → request code → resend → complete → a retry
|
||||||
|
// after a typo), and the throttler keys on IP, so users sharing a NAT or mobile CGNAT
|
||||||
|
// address share the budget. 5 would lock real passengers out.
|
||||||
|
{ name: "auth", limit: 20, ttl: 60_000 },
|
||||||
|
{ name: "strict", limit: 20, ttl: 60_000 },
|
||||||
|
]),
|
||||||
TypeOrmModule.forRootAsync({
|
TypeOrmModule.forRootAsync({
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
useFactory: (config: ConfigService): TypeOrmModuleOptions =>
|
useFactory: (config: ConfigService): TypeOrmModuleOptions =>
|
||||||
@@ -97,8 +109,11 @@ import { EOtpType } from "@tria-plc/iamapi-common";
|
|||||||
`Your EDR Passenger phone verification code is ${otp}. It will expire in 5 minutes.`,
|
`Your EDR Passenger phone verification code is ${otp}. It will expire in 5 minutes.`,
|
||||||
[EOtpType.RESET_PASSWORD]: ({ route }) =>
|
[EOtpType.RESET_PASSWORD]: ({ route }) =>
|
||||||
`Reset your EDR Passenger password using this link: ${route}`,
|
`Reset your EDR Passenger password using this link: ${route}`,
|
||||||
[EOtpType.SET_PASSWORD]: ({ route }) =>
|
// Carries the bare code as well as the link: the staged sign-in asks for the code
|
||||||
`Set your EDR Passenger password using this link: ${route}`,
|
// inline, while the link is still what a `/set-password` deep link from an older SMS
|
||||||
|
// relies on. `OtpMessageContext` supplies both.
|
||||||
|
[EOtpType.SET_PASSWORD]: ({ otp, route }) =>
|
||||||
|
`Your EDR Passenger code is ${otp}. Or set your password here: ${route}`,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
// Replaces the package's DataSeeder. Shared with edr-freight-api, which
|
// Replaces the package's DataSeeder. Shared with edr-freight-api, which
|
||||||
|
|||||||
77
apps/edr-passenger-api/src/common/utils/phone.utils.ts
Normal file
77
apps/edr-passenger-api/src/common/utils/phone.utils.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* Phone normalisation shared by any lookup that has to match a number a customer typed
|
||||||
|
* against one already stored. Ethiopian numbers reach us in three interchangeable shapes
|
||||||
|
* (+2519…, 2519…, 09…) depending on whether they came from IAM, a guest booking form or a
|
||||||
|
* saved profile, so an exact-string match silently misses.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all plausible normalised variants of a raw phone string so that the
|
||||||
|
* DB query matches regardless of how the number was stored (local 09… vs international +251…).
|
||||||
|
* Returns an empty array when the input is clearly invalid (< 7 digits).
|
||||||
|
*/
|
||||||
|
export function normalizePhoneVariants(raw: string): string[] {
|
||||||
|
// Strip whitespace, dashes, dots, parentheses — keep digits and a leading +
|
||||||
|
const stripped = raw.replace(/[^\d+]/g, '');
|
||||||
|
const digits = stripped.replace(/^\+/, '');
|
||||||
|
if (digits.length < 7) return [];
|
||||||
|
|
||||||
|
const variants = new Set<string>([stripped]);
|
||||||
|
|
||||||
|
if (stripped.startsWith('+251') && digits.length === 12) {
|
||||||
|
// +251 9XXXXXXXX → 251 9XXXXXXXX (no +) → 09XXXXXXXX
|
||||||
|
variants.add(digits); // 251XXXXXXXXX
|
||||||
|
variants.add('0' + digits.slice(3)); // 09XXXXXXXXX
|
||||||
|
} else if (stripped.startsWith('251') && digits.length === 12) {
|
||||||
|
// 251 9XXXXXXXX → +251 9XXXXXXXX → 09XXXXXXXX
|
||||||
|
variants.add('+' + stripped); // +251XXXXXXXXX
|
||||||
|
variants.add('0' + digits.slice(3)); // 09XXXXXXXXX
|
||||||
|
} else if (stripped.startsWith('0') && digits.length === 10) {
|
||||||
|
// 09XXXXXXXX → +251 9XXXXXXXX → 251 9XXXXXXXX (no +)
|
||||||
|
variants.add('+251' + digits.slice(1)); // +251XXXXXXXXX
|
||||||
|
variants.add('251' + digits.slice(1)); // 251XXXXXXXXX
|
||||||
|
} else if (!stripped.startsWith('+') && digits.length >= 9) {
|
||||||
|
// bare international digits without +
|
||||||
|
variants.add('+' + digits);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...variants];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A sign-in identifier is a single free-text field: the passenger types either an email
|
||||||
|
* address or a phone number and the server works out which. Phone is the default reading —
|
||||||
|
* an email must contain an `@` with something either side of it, everything else is treated
|
||||||
|
* as a number so that malformed emails don't silently fall through to a phone lookup that
|
||||||
|
* can never match.
|
||||||
|
*/
|
||||||
|
export type ResolvedIdentifier = {
|
||||||
|
kind: 'email' | 'phone';
|
||||||
|
/** Lower-cased email, or null when the input is a phone number. */
|
||||||
|
email: string | null;
|
||||||
|
/** Every stored shape the number could have, or [] when the input is an email. */
|
||||||
|
phoneVariants: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
|
export function resolveIdentifier(raw: string): ResolvedIdentifier {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (EMAIL_RE.test(trimmed)) {
|
||||||
|
return { kind: 'email', email: trimmed.toLowerCase(), phoneVariants: [] };
|
||||||
|
}
|
||||||
|
return { kind: 'phone', email: null, phoneVariants: normalizePhoneVariants(trimmed) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `+251912345678` → `+2519****678`. Shown on the OTP screen so the passenger can tell which
|
||||||
|
* number the code went to without the server handing back the full number to an unauthenticated
|
||||||
|
* caller.
|
||||||
|
*/
|
||||||
|
export function maskPhone(phone: string): string {
|
||||||
|
const stripped = phone.replace(/[^\d+]/g, '');
|
||||||
|
if (stripped.length <= 7) return stripped;
|
||||||
|
const head = stripped.slice(0, stripped.startsWith('+') ? 5 : 4);
|
||||||
|
const tail = stripped.slice(-3);
|
||||||
|
return `${head}${'*'.repeat(4)}${tail}`;
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
} from "@nestjs/swagger";
|
} from "@nestjs/swagger";
|
||||||
import { IsPublic } from "@tria-plc/api-common/modules/auth/decorators/public.decorator";
|
import { IsPublic } from "@tria-plc/api-common/modules/auth/decorators/public.decorator";
|
||||||
|
import { Throttle, ThrottlerGuard } from "@nestjs/throttler";
|
||||||
import { PassengerAuthService } from "./passenger-auth.service";
|
import { PassengerAuthService } from "./passenger-auth.service";
|
||||||
import {
|
import {
|
||||||
RegisterDto,
|
RegisterDto,
|
||||||
@@ -28,12 +29,19 @@ import {
|
|||||||
ResendRegistrationCodeDto,
|
ResendRegistrationCodeDto,
|
||||||
FaydaRequestPasswordSetupDto,
|
FaydaRequestPasswordSetupDto,
|
||||||
FaydaVerifyAndLoginDto,
|
FaydaVerifyAndLoginDto,
|
||||||
|
IdentifierLookupDto,
|
||||||
|
PasswordSetupRequestDto,
|
||||||
|
PasswordSetupCompleteDto,
|
||||||
} from "./auth.dto";
|
} from "./auth.dto";
|
||||||
import { JwtGuard } from "../../common/jwt.guard";
|
import { JwtGuard } from "../../common/jwt.guard";
|
||||||
|
|
||||||
@ApiTags("Passenger Auth")
|
@ApiTags("Passenger Auth")
|
||||||
@Controller("auth")
|
@Controller("auth")
|
||||||
// @Throttle({ auth: { limit: 5, ttl: 60_000 } })
|
// Scoped to this controller rather than registered as a global APP_GUARD: the staged sign-in
|
||||||
|
// exposes an account-existence lookup, and rate limiting is the mitigation for it. Applying the
|
||||||
|
// guard app-wide would change the behaviour of every other module at the same time.
|
||||||
|
@UseGuards(ThrottlerGuard)
|
||||||
|
@Throttle({ auth: { limit: 20, ttl: 60_000 } })
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(private passengerAuthService: PassengerAuthService) {}
|
constructor(private passengerAuthService: PassengerAuthService) {}
|
||||||
|
|
||||||
@@ -189,6 +197,63 @@ export class AuthController {
|
|||||||
return this.passengerAuthService.resetUserPassword(id, body.tempPassword);
|
return this.passengerAuthService.resetUserPassword(id, body.tempPassword);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post("identifier/lookup")
|
||||||
|
@IsPublic()
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
// Tighter than the rest of the controller: this is the endpoint that answers "does this
|
||||||
|
// account exist", so it is the one worth making expensive to sweep. Still roomy enough
|
||||||
|
// that a passenger correcting a typo two or three times is unaffected.
|
||||||
|
@Throttle({ auth: { limit: 10, ttl: 60_000 } })
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Step 1 of sign-in — decide what to ask the user for next",
|
||||||
|
description:
|
||||||
|
"Takes a phone number or an email and reports whether the account exists and whether it " +
|
||||||
|
"already has a password. PASSWORD → ask for the password. NEEDS_PASSWORD_SETUP → send a " +
|
||||||
|
"code and let them set one. NOT_FOUND → sign them up.",
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: "{ status, method?, maskedPhone? } — never returns email or user id",
|
||||||
|
})
|
||||||
|
@ApiBody({ type: IdentifierLookupDto })
|
||||||
|
lookupIdentifier(@Body() dto: IdentifierLookupDto) {
|
||||||
|
return this.passengerAuthService.lookupIdentifier(dto.identifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("password-setup/request")
|
||||||
|
@IsPublic()
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Send the SMS code that lets an account with no password set one",
|
||||||
|
description:
|
||||||
|
"Covers Fayda-created accounts and abandoned registrations alike. Always returns " +
|
||||||
|
"{ sent: true } regardless of whether the account exists.",
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: "{ sent: true }" })
|
||||||
|
@ApiBody({ type: PasswordSetupRequestDto })
|
||||||
|
requestPasswordSetup(@Body() dto: PasswordSetupRequestDto, @Request() req: any) {
|
||||||
|
return this.passengerAuthService.requestPasswordSetup(dto.identifier, req);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("password-setup/complete")
|
||||||
|
@IsPublic()
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Redeem the code, set the password, and sign in",
|
||||||
|
description:
|
||||||
|
"Accepts both set-password codes (from password-setup/request) and verify-phone-number " +
|
||||||
|
"codes (from POST /auth/register), so one screen finishes both branches.",
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: "Same shape as POST /auth/login — token, refreshToken and user.",
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 401, description: "Invalid or expired code" })
|
||||||
|
@ApiBody({ type: PasswordSetupCompleteDto })
|
||||||
|
completePasswordSetup(@Body() dto: PasswordSetupCompleteDto) {
|
||||||
|
return this.passengerAuthService.completePasswordSetup(dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Post("fayda/request-password-setup")
|
@Post("fayda/request-password-setup")
|
||||||
@IsPublic()
|
@IsPublic()
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { IsEmail, IsNotEmpty, IsString, ValidateNested } from 'class-validator';
|
import {
|
||||||
|
IsEmail,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsString,
|
||||||
|
IsStrongPassword,
|
||||||
|
Length,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
@@ -75,3 +82,55 @@ export class FaydaVerifyAndLoginDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
otp: string;
|
otp: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 1 of the staged sign-in. One field: the passenger types either their phone number
|
||||||
|
* or their email and the server decides which of the three branches follows.
|
||||||
|
*/
|
||||||
|
export class IdentifierLookupDto {
|
||||||
|
@ApiProperty({
|
||||||
|
example: '+251912345678',
|
||||||
|
description: 'Phone number or email address — the server detects which',
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
identifier: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Step 2a: ask for the SMS code that lets an account with no password set one. */
|
||||||
|
export class PasswordSetupRequestDto {
|
||||||
|
@ApiProperty({ example: '+251912345678', description: 'Phone number or email address' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
identifier: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Step 2b: redeem the code, set the password, and receive a session in one call. */
|
||||||
|
export class PasswordSetupCompleteDto {
|
||||||
|
@ApiProperty({ example: '+251912345678', description: 'Phone number or email address' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
identifier: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '123456', description: '6-digit code received via SMS' })
|
||||||
|
@IsString()
|
||||||
|
@Length(4, 10)
|
||||||
|
otp: string;
|
||||||
|
|
||||||
|
// The credential is written directly against iam.user_credentials rather than through
|
||||||
|
// the IAM's own set-password route, so the IAM's @IsStrongPassword rule has to be
|
||||||
|
// restated here or weak passwords would slip in unvalidated.
|
||||||
|
@ApiProperty({ example: 'Str0ng!Pass', format: 'password' })
|
||||||
|
@IsStrongPassword({
|
||||||
|
minLength: 8,
|
||||||
|
minLowercase: 1,
|
||||||
|
minUppercase: 1,
|
||||||
|
minNumbers: 1,
|
||||||
|
minSymbols: 1,
|
||||||
|
})
|
||||||
|
newPassword: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'Str0ng!Pass', format: 'password' })
|
||||||
|
@IsString()
|
||||||
|
confirmPassword: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
Injectable,
|
Injectable,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
InternalServerErrorException,
|
InternalServerErrorException,
|
||||||
@@ -13,7 +14,22 @@ import { AuthService as IamAuthService } from '@tria-plc/iamapi-common/module/au
|
|||||||
import { EUserType } from '@tria-plc/api-common/utils/enums/user.enum';
|
import { EUserType } from '@tria-plc/api-common/utils/enums/user.enum';
|
||||||
import { EOtpType } from '@tria-plc/iamapi-common/enums/otp.enum';
|
import { EOtpType } from '@tria-plc/iamapi-common/enums/otp.enum';
|
||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
import { RegisterDto, LoginDto } from './auth.dto';
|
import { RegisterDto, LoginDto, PasswordSetupCompleteDto } from './auth.dto';
|
||||||
|
import { maskPhone, resolveIdentifier } from '../../common/utils/phone.utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `iam.users` columns every sign-in branch needs. Kept separate from `IamUserRow`
|
||||||
|
* (which is profile-shaped) because the auth branches key off credential state, not metadata.
|
||||||
|
*/
|
||||||
|
type IamAuthRow = {
|
||||||
|
id: string;
|
||||||
|
email: string | null;
|
||||||
|
name: { en: string; am: string } | null;
|
||||||
|
username: string;
|
||||||
|
phone_number: string | null;
|
||||||
|
has_set_password: boolean;
|
||||||
|
verified_by: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
type IamUserRow = {
|
type IamUserRow = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -170,9 +186,19 @@ export class PassengerAuthService {
|
|||||||
async login(dto: LoginDto, req: any) {
|
async login(dto: LoginDto, req: any) {
|
||||||
const iamAuthService = await this.resolveIamAuthService(req);
|
const iamAuthService = await this.resolveIamAuthService(req);
|
||||||
|
|
||||||
|
// `dto.email` may hold an email OR a phone number, in any of the shapes a passenger might
|
||||||
|
// type. Resolve it to the exact string the IAM stores before handing it over: the IAM
|
||||||
|
// matches the identifier literally, so someone entering `0912…` for a number stored as
|
||||||
|
// `+2519…` would be told their credentials are invalid despite a correct password.
|
||||||
|
const known = await this.findUserByIdentifier(dto.email);
|
||||||
|
const loginIdentifier = known?.email ?? known?.phone_number ?? dto.email;
|
||||||
|
|
||||||
let iamResult: { token: string; refreshToken: string } | { mfaRequired: boolean };
|
let iamResult: { token: string; refreshToken: string } | { mfaRequired: boolean };
|
||||||
try {
|
try {
|
||||||
iamResult = await iamAuthService.login({ email: dto.email, password: dto.password });
|
iamResult = await iamAuthService.login({
|
||||||
|
email: loginIdentifier,
|
||||||
|
password: dto.password,
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
this.eventEmitter.emit('auth.login.failed', { email: dto.email });
|
this.eventEmitter.emit('auth.login.failed', { email: dto.email });
|
||||||
throw new UnauthorizedException('Invalid credentials');
|
throw new UnauthorizedException('Invalid credentials');
|
||||||
@@ -184,14 +210,16 @@ export class PassengerAuthService {
|
|||||||
|
|
||||||
const { token, refreshToken } = iamResult as { token: string; refreshToken: string };
|
const { token, refreshToken } = iamResult as { token: string; refreshToken: string };
|
||||||
|
|
||||||
// `dto.email` may hold an email OR a phone number (passengers without an email log in
|
// `known` is the same row the identifier resolved to; only fall back to a fresh lookup if
|
||||||
// with their phone). Match on either so the post-auth lookup works regardless of which
|
// the resolve missed but the IAM authenticated anyway.
|
||||||
// identifier was used.
|
let iamUser: { id: string; email: string | null } | null = known;
|
||||||
const iamRows = await this.dataSource.query<IamUserRow[]>(
|
if (!iamUser) {
|
||||||
`SELECT id, email, name, phone_number, metadata FROM iam.users WHERE email = $1 OR phone_number = $1 LIMIT 1`,
|
const iamRows = await this.dataSource.query<IamUserRow[]>(
|
||||||
[dto.email],
|
`SELECT id, email, name, phone_number, metadata FROM iam.users WHERE email = $1 OR phone_number = $1 LIMIT 1`,
|
||||||
);
|
[loginIdentifier],
|
||||||
const iamUser = iamRows[0];
|
);
|
||||||
|
iamUser = iamRows[0] ?? null;
|
||||||
|
}
|
||||||
if (!iamUser) {
|
if (!iamUser) {
|
||||||
throw new InternalServerErrorException('IAM user not found after successful authentication');
|
throw new InternalServerErrorException('IAM user not found after successful authentication');
|
||||||
}
|
}
|
||||||
@@ -496,19 +524,26 @@ export class PassengerAuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async resetUserPassword(id: string, tempPassword: string) {
|
async resetUserPassword(id: string, tempPassword: string) {
|
||||||
|
await this.writeActiveCredential(id, tempPassword);
|
||||||
|
return { success: true, message: 'Password reset successfully' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces the user's active credential. The IAM keeps credential history and relies on
|
||||||
|
* exactly one row per user having `is_active = true`, so the old row is deactivated in the
|
||||||
|
* same call rather than deleted.
|
||||||
|
*/
|
||||||
|
private async writeActiveCredential(userId: string, password: string): Promise<void> {
|
||||||
const { hashPassword } = await import('@tria-plc/api-common/utils/argon');
|
const { hashPassword } = await import('@tria-plc/api-common/utils/argon');
|
||||||
const passwordHash = await hashPassword(tempPassword);
|
const passwordHash = await hashPassword(password);
|
||||||
// Deactivate existing credentials first (IAM keeps history, only one active at a time)
|
|
||||||
await this.dataSource.query(
|
await this.dataSource.query(
|
||||||
`UPDATE iam.user_credentials SET is_active = false WHERE user_id = $1 AND is_active = true`,
|
`UPDATE iam.user_credentials SET is_active = false WHERE user_id = $1 AND is_active = true`,
|
||||||
[id],
|
[userId],
|
||||||
);
|
);
|
||||||
// Insert new active credential
|
|
||||||
await this.dataSource.query(
|
await this.dataSource.query(
|
||||||
`INSERT INTO iam.user_credentials (user_id, password, is_active) VALUES ($1, $2, true)`,
|
`INSERT INTO iam.user_credentials (user_id, password, is_active) VALUES ($1, $2, true)`,
|
||||||
[id, passwordHash],
|
[userId, passwordHash],
|
||||||
);
|
);
|
||||||
return { success: true, message: 'Password reset successfully' };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async requestFaydaPasswordSetup(phoneNumber: string, req: any): Promise<{ sent: boolean }> {
|
async requestFaydaPasswordSetup(phoneNumber: string, req: any): Promise<{ sent: boolean }> {
|
||||||
@@ -554,15 +589,45 @@ export class PassengerAuthService {
|
|||||||
if (!users.length) throw new UnauthorizedException('Invalid phone number or OTP');
|
if (!users.length) throw new UnauthorizedException('Invalid phone number or OTP');
|
||||||
const u = users[0];
|
const u = users[0];
|
||||||
|
|
||||||
|
await this.consumeSetupOtp(u.id, otp, 'Invalid phone number or OTP');
|
||||||
|
|
||||||
|
const { token, refreshToken } = await this.mintSession(
|
||||||
|
{ ...u, verified_by: 'fayda' },
|
||||||
|
'fayda-otp-setup',
|
||||||
|
);
|
||||||
|
|
||||||
|
return { token, refreshToken, requiresPassword: !u.has_set_password, iamUserId: u.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies and burns a one-time code from `iam.user_verifications`.
|
||||||
|
*
|
||||||
|
* Both password-setup entry points land here: `set-password` codes come from
|
||||||
|
* `password-setup/request`, `verify-phone-number` codes from `POST /auth/register`. Accepting
|
||||||
|
* both is what lets a single screen finish the "existing account with no password" branch and
|
||||||
|
* the "brand new signup" branch.
|
||||||
|
*
|
||||||
|
* Codes are argon2-hashed at rest, so this is a verify rather than an equality check. The
|
||||||
|
* attempt counter is incremented *before* the comparison so a crash mid-verify still costs an
|
||||||
|
* attempt, and the code is burned on the 6th try.
|
||||||
|
*/
|
||||||
|
private async consumeSetupOtp(
|
||||||
|
userId: string,
|
||||||
|
otp: string,
|
||||||
|
failureMessage = 'Invalid or expired code',
|
||||||
|
): Promise<void> {
|
||||||
const verifications = await this.dataSource.query<{
|
const verifications = await this.dataSource.query<{
|
||||||
id: string; verification_code: string; attempt_count: number;
|
id: string; verification_code: string; attempt_count: number;
|
||||||
}[]>(
|
}[]>(
|
||||||
`SELECT id, verification_code, attempt_count FROM iam.user_verifications
|
`SELECT id, verification_code, attempt_count FROM iam.user_verifications
|
||||||
WHERE user_id = $1 AND otp_type = 'set-password' AND "isUsed" = false AND expires_at > NOW()
|
WHERE user_id = $1
|
||||||
|
AND otp_type IN ('set-password', 'verify-phone-number')
|
||||||
|
AND "isUsed" = false
|
||||||
|
AND expires_at > NOW()
|
||||||
ORDER BY created_at DESC LIMIT 1`,
|
ORDER BY created_at DESC LIMIT 1`,
|
||||||
[u.id],
|
[userId],
|
||||||
);
|
);
|
||||||
if (!verifications.length) throw new UnauthorizedException('Invalid phone number or OTP');
|
if (!verifications.length) throw new UnauthorizedException(failureMessage);
|
||||||
const v = verifications[0];
|
const v = verifications[0];
|
||||||
|
|
||||||
if (v.attempt_count >= 5) {
|
if (v.attempt_count >= 5) {
|
||||||
@@ -578,12 +643,24 @@ export class PassengerAuthService {
|
|||||||
|
|
||||||
const { verifyPassword } = await import('@tria-plc/api-common/utils/argon');
|
const { verifyPassword } = await import('@tria-plc/api-common/utils/argon');
|
||||||
const valid = await verifyPassword(otp, v.verification_code);
|
const valid = await verifyPassword(otp, v.verification_code);
|
||||||
if (!valid) throw new UnauthorizedException('Invalid phone number or OTP');
|
if (!valid) throw new UnauthorizedException(failureMessage);
|
||||||
|
|
||||||
await this.dataSource.query(
|
await this.dataSource.query(
|
||||||
`UPDATE iam.user_verifications SET "isUsed" = true WHERE id = $1`, [v.id],
|
`UPDATE iam.user_verifications SET "isUsed" = true WHERE id = $1`, [v.id],
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserts (or refreshes) an `iam.sessions` row and mints the token pair for it. The JWT payload
|
||||||
|
* is only the session id — `JwtGuard` resolves everything else from the table.
|
||||||
|
*
|
||||||
|
* `device` participates in a unique constraint on `(user_id, device)`, so each flow passes its
|
||||||
|
* own value and none of them clobbers a session another flow established.
|
||||||
|
*/
|
||||||
|
private async mintSession(
|
||||||
|
u: IamAuthRow,
|
||||||
|
device: string,
|
||||||
|
): Promise<{ token: string; refreshToken: string }> {
|
||||||
const userInfo = {
|
const userInfo = {
|
||||||
id: u.id,
|
id: u.id,
|
||||||
email: u.email ?? '',
|
email: u.email ?? '',
|
||||||
@@ -604,19 +681,183 @@ export class PassengerAuthService {
|
|||||||
const sessions = await this.dataSource.query<{ id: string }[]>(
|
const sessions = await this.dataSource.query<{ id: string }[]>(
|
||||||
`INSERT INTO iam.sessions
|
`INSERT INTO iam.sessions
|
||||||
(id, email, device, "userInfo", expiry_time, refresh_count, status, user_id)
|
(id, email, device, "userInfo", expiry_time, refresh_count, status, user_id)
|
||||||
VALUES (gen_random_uuid(), $1, 'fayda-otp-setup', $2::jsonb, NOW() + INTERVAL '1 day', 0, 'ACTIVE', $3)
|
VALUES (gen_random_uuid(), $1, $2, $3::jsonb, NOW() + INTERVAL '1 day', 0, 'ACTIVE', $4)
|
||||||
ON CONFLICT (user_id, device) DO UPDATE
|
ON CONFLICT (user_id, device) DO UPDATE
|
||||||
SET status = 'ACTIVE', "userInfo" = EXCLUDED."userInfo",
|
SET status = 'ACTIVE', "userInfo" = EXCLUDED."userInfo",
|
||||||
expiry_time = NOW() + INTERVAL '1 day', updated_at = NOW()
|
expiry_time = NOW() + INTERVAL '1 day', updated_at = NOW()
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
[u.email ?? '', JSON.stringify(userInfo), u.id],
|
[u.email ?? '', device, JSON.stringify(userInfo), u.id],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { generateToken, generateRefreshToken } = await import('@tria-plc/api-common/utils/token');
|
const { generateToken, generateRefreshToken } = await import('@tria-plc/api-common/utils/token');
|
||||||
const token = generateToken({ id: sessions[0].id });
|
return {
|
||||||
const refreshToken = generateRefreshToken({ id: sessions[0].id });
|
token: generateToken({ id: sessions[0].id }),
|
||||||
|
refreshToken: generateRefreshToken({ id: sessions[0].id }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return { token, refreshToken, requiresPassword: !u.has_set_password, iamUserId: u.id };
|
/**
|
||||||
|
* Resolves the single sign-in identifier field to an `iam.users` row.
|
||||||
|
*
|
||||||
|
* A phone number reaches us in three interchangeable shapes (`+2519…`, `2519…`, `09…`)
|
||||||
|
* depending on whether the account was created by IAM signup, a guest booking or Fayda, so
|
||||||
|
* matching on one canonical form silently misses. `normalizePhoneVariants` produces every
|
||||||
|
* shape and the query matches any of them.
|
||||||
|
*
|
||||||
|
* `ORDER BY has_set_password DESC` makes a fully-registered account win over a leftover
|
||||||
|
* pending row that shares the same phone — otherwise a passenger with an abandoned signup
|
||||||
|
* would be pushed into password setup for an account they already finished.
|
||||||
|
*/
|
||||||
|
private async findUserByIdentifier(identifier: string): Promise<IamAuthRow | null> {
|
||||||
|
const resolved = resolveIdentifier(identifier);
|
||||||
|
if (!resolved.email && resolved.phoneVariants.length === 0) return null;
|
||||||
|
|
||||||
|
const rows = await this.dataSource.query<IamAuthRow[]>(
|
||||||
|
`SELECT id, email, name, username, phone_number, has_set_password, verified_by
|
||||||
|
FROM iam.users
|
||||||
|
WHERE ($1::text IS NOT NULL AND lower(email) = $1)
|
||||||
|
OR phone_number = ANY($2::text[])
|
||||||
|
ORDER BY has_set_password DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
[resolved.email, resolved.phoneVariants],
|
||||||
|
);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 1 of the staged sign-in: decide which of the three branches the portal should render.
|
||||||
|
*
|
||||||
|
* This deliberately reports whether an account exists — the whole point of the flow is that the
|
||||||
|
* passenger stops guessing — so it is a user-enumeration oracle by design. `POST
|
||||||
|
* /v1/auth/forgot-password` already leaks the same fact by throwing `user_not_found`; the
|
||||||
|
* mitigation here is the throttle on this controller, not secrecy. Nothing identifying is
|
||||||
|
* returned: no email, no user id, and the phone only ever masked.
|
||||||
|
*/
|
||||||
|
async lookupIdentifier(identifier: string): Promise<{
|
||||||
|
status: 'PASSWORD' | 'NEEDS_PASSWORD_SETUP' | 'NOT_FOUND';
|
||||||
|
method?: 'fayda' | 'pending';
|
||||||
|
maskedPhone?: string;
|
||||||
|
}> {
|
||||||
|
const user = await this.findUserByIdentifier(identifier);
|
||||||
|
if (!user) return { status: 'NOT_FOUND' };
|
||||||
|
if (user.has_set_password) return { status: 'PASSWORD' };
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'NEEDS_PASSWORD_SETUP',
|
||||||
|
method: user.verified_by === 'fayda' ? 'fayda' : 'pending',
|
||||||
|
maskedPhone: user.phone_number ? maskPhone(user.phone_number) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends the SMS code that lets an account with no password set one. Covers both Fayda-created
|
||||||
|
* accounts and abandoned registrations — the distinction only changes the copy the portal
|
||||||
|
* shows, not what happens here.
|
||||||
|
*
|
||||||
|
* Always resolves `{ sent: true }`. Returning a real result would make this a cheaper
|
||||||
|
* enumeration oracle than `lookupIdentifier`, which at least sits behind the same throttle.
|
||||||
|
*/
|
||||||
|
async requestPasswordSetup(identifier: string, req: any): Promise<{ sent: boolean }> {
|
||||||
|
const user = await this.findUserByIdentifier(identifier);
|
||||||
|
if (!user || user.has_set_password) return { sent: true };
|
||||||
|
|
||||||
|
if (!user.phone_number) {
|
||||||
|
// OTP delivery is SMS + in-app only; there is no email channel. Every account-creation
|
||||||
|
// path requires a phone, so this should be unreachable — log it rather than fail silently.
|
||||||
|
this.logger.warn(
|
||||||
|
`requestPasswordSetup: user ${user.id} has no phone number — no channel to send a code on`,
|
||||||
|
);
|
||||||
|
return { sent: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const iamAuthService = await this.resolveIamAuthService(req);
|
||||||
|
try {
|
||||||
|
await iamAuthService.generateVerificationCode({
|
||||||
|
// Both fields must match the stored row exactly: the IAM looks the user up with
|
||||||
|
// `where: { phoneNumber, email }`, which is AND, not OR. Passing the values we just
|
||||||
|
// read back guarantees the match — including a null email, which TypeORM renders as
|
||||||
|
// `IS NULL` and which coercing to '' would break.
|
||||||
|
email: user.email as string,
|
||||||
|
phoneNumber: user.phone_number,
|
||||||
|
type: EOtpType.SET_PASSWORD,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`[PassengerAuthService] password setup code failed for user ${user.id}`,
|
||||||
|
(err as Error).message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { sent: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redeems the code, writes the password, and returns a session — the passenger lands signed in
|
||||||
|
* rather than being bounced back to the login form.
|
||||||
|
*
|
||||||
|
* Returns the same shape as `login()` so the portal can store the result through one code path.
|
||||||
|
*/
|
||||||
|
async completePasswordSetup(dto: PasswordSetupCompleteDto): Promise<{
|
||||||
|
token: string;
|
||||||
|
refreshToken: string;
|
||||||
|
user: { id: string; iamUserId: string; email: string | null; passengerId: string };
|
||||||
|
}> {
|
||||||
|
if (dto.newPassword !== dto.confirmPassword) {
|
||||||
|
throw new BadRequestException('Passwords do not match');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.findUserByIdentifier(dto.identifier);
|
||||||
|
// Same message whether the account is missing or the code is wrong: the branch was already
|
||||||
|
// disclosed by `lookupIdentifier`, but there is no reason to re-confirm it on every attempt.
|
||||||
|
if (!user) throw new UnauthorizedException('Invalid or expired code');
|
||||||
|
if (user.has_set_password) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'This account already has a password. Sign in with it instead.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.consumeSetupOtp(user.id, dto.otp);
|
||||||
|
await this.writeActiveCredential(user.id, dto.newPassword);
|
||||||
|
|
||||||
|
// Redeeming the code proves ownership of the phone, which is what promotes a Fayda-created
|
||||||
|
// `submitted` row or a pending signup to a usable account.
|
||||||
|
await this.dataSource.query(
|
||||||
|
`UPDATE iam.users
|
||||||
|
SET has_set_password = true,
|
||||||
|
status = 'accepted',
|
||||||
|
is_active = true,
|
||||||
|
is_phone_number_verified = true,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1`,
|
||||||
|
[user.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { token, refreshToken } = await this.mintSession(
|
||||||
|
{ ...user, has_set_password: true },
|
||||||
|
'password-setup',
|
||||||
|
);
|
||||||
|
|
||||||
|
let passenger = await this.prisma.passenger.findUnique({
|
||||||
|
where: { iamUserId: user.id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!passenger) {
|
||||||
|
const result = await this.provisionPassengerSatellite({
|
||||||
|
iamUserId: user.id,
|
||||||
|
auditAction: 'USER_AUTO_PROVISIONED',
|
||||||
|
});
|
||||||
|
passenger = { id: result.passengerId };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
refreshToken,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
iamUserId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
passengerId: passenger.id,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private standardizePhone(phone: string): string {
|
private standardizePhone(phone: string): string {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { CurrencyService } from '../currency/currency.service';
|
|||||||
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
||||||
import { PaymentsService } from '../payments/payments.service';
|
import { PaymentsService } from '../payments/payments.service';
|
||||||
import { MAX_PAYMENT_HOURS } from '../../common/utils/payment-deadline.utils';
|
import { MAX_PAYMENT_HOURS } from '../../common/utils/payment-deadline.utils';
|
||||||
|
import { normalizePhoneVariants } from '../../common/utils/phone.utils';
|
||||||
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
|
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
|
||||||
import { JourneyDirection } from '../seats/seats.dto';
|
import { JourneyDirection } from '../seats/seats.dto';
|
||||||
import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto';
|
import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto';
|
||||||
@@ -46,39 +47,6 @@ function resolvePackageRoundTripTotal(
|
|||||||
return adultCount * adultFareMinor + paidChildren * adultFareMinor;
|
return adultCount * adultFareMinor + paidChildren * adultFareMinor;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns all plausible normalised variants of a raw phone string so that the
|
|
||||||
* DB query matches regardless of how the number was stored (local 09… vs international +251…).
|
|
||||||
* Returns an empty array when the input is clearly invalid (< 7 digits).
|
|
||||||
*/
|
|
||||||
function normalizePhoneVariants(raw: string): string[] {
|
|
||||||
// Strip whitespace, dashes, dots, parentheses — keep digits and a leading +
|
|
||||||
const stripped = raw.replace(/[^\d+]/g, '');
|
|
||||||
const digits = stripped.replace(/^\+/, '');
|
|
||||||
if (digits.length < 7) return [];
|
|
||||||
|
|
||||||
const variants = new Set<string>([stripped]);
|
|
||||||
|
|
||||||
if (stripped.startsWith('+251') && digits.length === 12) {
|
|
||||||
// +251 9XXXXXXXX → 251 9XXXXXXXX (no +) → 09XXXXXXXX
|
|
||||||
variants.add(digits); // 251XXXXXXXXX
|
|
||||||
variants.add('0' + digits.slice(3)); // 09XXXXXXXXX
|
|
||||||
} else if (stripped.startsWith('251') && digits.length === 12) {
|
|
||||||
// 251 9XXXXXXXX → +251 9XXXXXXXX → 09XXXXXXXX
|
|
||||||
variants.add('+' + stripped); // +251XXXXXXXXX
|
|
||||||
variants.add('0' + digits.slice(3)); // 09XXXXXXXXX
|
|
||||||
} else if (stripped.startsWith('0') && digits.length === 10) {
|
|
||||||
// 09XXXXXXXX → +251 9XXXXXXXX → 251 9XXXXXXXX (no +)
|
|
||||||
variants.add('+251' + digits.slice(1)); // +251XXXXXXXXX
|
|
||||||
variants.add('251' + digits.slice(1)); // 251XXXXXXXXX
|
|
||||||
} else if (!stripped.startsWith('+') && digits.length >= 9) {
|
|
||||||
// bare international digits without +
|
|
||||||
variants.add('+' + digits);
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...variants];
|
|
||||||
}
|
|
||||||
|
|
||||||
function calculateAge(dateOfBirth: Date): number {
|
function calculateAge(dateOfBirth: Date): number {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
let age = today.getFullYear() - dateOfBirth.getFullYear();
|
let age = today.getFullYear() - dateOfBirth.getFullYear();
|
||||||
|
|||||||
@@ -1,50 +1,295 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { z } from 'zod';
|
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { useAuthStore } from '@/lib/auth-store';
|
import { useAuthStore } from '@/lib/auth-store';
|
||||||
import { useState, Suspense } from 'react';
|
import { iamAuthApi } from '@/lib/api/auth';
|
||||||
|
import { isStrongPassword, PASSWORD_RULE } from '@/lib/password';
|
||||||
|
import { useState, useRef, Suspense } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Train, ShieldCheck, Eye, EyeOff } from 'lucide-react';
|
import { Train, Eye, EyeOff, Pencil } from 'lucide-react';
|
||||||
|
|
||||||
const loginSchema = z.object({
|
/**
|
||||||
// Accepts either an email or a phone number. Passengers who registered without an
|
* Staged sign-in.
|
||||||
// email sign in with their phone number, which is sent in the same `email` field —
|
*
|
||||||
// the IAM matches on either identifier.
|
* The passenger gives one identifier — phone or email — and the server decides which of three
|
||||||
email: z.string().min(1, 'Phone or email is required'),
|
* things happens next. Previously this page asked for identifier *and* password up front and
|
||||||
password: z.string().min(6, 'Password must be at least 6 characters'),
|
* offered three competing links underneath ("Create account", "Already verified with Fayda?",
|
||||||
});
|
* "Forgot password?"), which made the user guess something only the server knows: whether their
|
||||||
|
* number has an account, and whether that account has a password yet. Guessing wrong dead-ended.
|
||||||
|
*
|
||||||
|
* Now exactly one branch is ever on screen.
|
||||||
|
*/
|
||||||
|
type Step = 'identifier' | 'password' | 'setup' | 'signup';
|
||||||
|
|
||||||
type LoginForm = z.infer<typeof loginSchema>;
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loose enough to accept every shape a passenger might type (`+2519…`, `2519…`, `09…`) and the
|
||||||
|
* occasional foreign number, strict enough that free text never reaches the signup branch — an
|
||||||
|
* identifier that is neither an email nor a number would otherwise be stored as a phone the SMS
|
||||||
|
* code can never reach. Mirrors the 7-digit floor in the API's `normalizePhoneVariants`.
|
||||||
|
*/
|
||||||
|
const looksLikePhone = (v: string) => v.replace(/[^\d]/g, '').length >= 7;
|
||||||
|
|
||||||
function LoginContent() {
|
function LoginContent() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const login = useAuthStore((s) => s.login);
|
const login = useAuthStore((s) => s.login);
|
||||||
|
const registerUser = useAuthStore((s) => s.register);
|
||||||
|
const setUser = useAuthStore((s) => s.setUser);
|
||||||
|
|
||||||
|
const [step, setStep] = useState<Step>('identifier');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
const { register, handleSubmit, formState: { errors } } = useForm<LoginForm>({
|
// Step 1
|
||||||
resolver: zodResolver(loginSchema as any),
|
const [identifier, setIdentifier] = useState('');
|
||||||
});
|
const identifierRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const onSubmit = async (data: LoginForm) => {
|
// Step 2 — sign in
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
|
||||||
|
// Step 3 — set a password (existing account with none, or a fresh signup)
|
||||||
|
const [maskedPhone, setMaskedPhone] = useState('');
|
||||||
|
const [setupMethod, setSetupMethod] = useState<'fayda' | 'pending' | 'new'>('pending');
|
||||||
|
const [otp, setOtp] = useState('');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [resendNote, setResendNote] = useState('');
|
||||||
|
|
||||||
|
// Step 4 — signup
|
||||||
|
const [fullName, setFullName] = useState('');
|
||||||
|
const [secondaryContact, setSecondaryContact] = useState('');
|
||||||
|
|
||||||
|
const identifierIsEmail = EMAIL_RE.test(identifier.trim());
|
||||||
|
|
||||||
|
const finish = () => {
|
||||||
|
const redirect = searchParams.get('redirect') || '/booking/search';
|
||||||
|
router.push(redirect);
|
||||||
|
};
|
||||||
|
|
||||||
|
const goBackToIdentifier = () => {
|
||||||
|
setStep('identifier');
|
||||||
|
setError('');
|
||||||
|
setPassword('');
|
||||||
|
setOtp('');
|
||||||
|
setNewPassword('');
|
||||||
|
setConfirmPassword('');
|
||||||
|
setResendNote('');
|
||||||
|
// Keep what they typed — they are usually fixing a typo, not starting over — but select
|
||||||
|
// it, so typing replaces the value instead of appending to it. Without this, clicking
|
||||||
|
// into a controlled input that still holds the old identifier silently concatenates.
|
||||||
|
setTimeout(() => identifierRef.current?.select(), 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const apiMessage = (err: any, fallback: string) =>
|
||||||
|
err?.response?.data?.message || fallback;
|
||||||
|
|
||||||
|
// --- Step 1: who are you? ---------------------------------------------------
|
||||||
|
const submitIdentifier = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const value = identifier.trim();
|
||||||
|
if (!value) {
|
||||||
|
setError('Enter your phone number or email');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!EMAIL_RE.test(value) && !looksLikePhone(value)) {
|
||||||
|
setError('Enter a valid phone number or email address');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
await login(data.email, data.password);
|
const res = await iamAuthApi.lookupIdentifier(identifier.trim());
|
||||||
const redirect = searchParams.get('redirect') || '/booking/search';
|
const result = res.data.data;
|
||||||
router.push(redirect);
|
|
||||||
|
if (result.status === 'PASSWORD') {
|
||||||
|
setStep('password');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.status === 'NEEDS_PASSWORD_SETUP') {
|
||||||
|
setMaskedPhone(result.maskedPhone || '');
|
||||||
|
setSetupMethod(result.method || 'pending');
|
||||||
|
// Fire the code now so the next screen is already actionable. It resolves even for
|
||||||
|
// an unknown identifier, so a failure here is a transport problem, not a verdict.
|
||||||
|
await iamAuthApi.requestPasswordSetup(identifier.trim());
|
||||||
|
setStep('setup');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStep('signup');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.message || 'Login failed. Please check your credentials.');
|
setError(apiMessage(err, 'Something went wrong. Please try again.'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- Step 2: existing account, has a password -------------------------------
|
||||||
|
const submitPassword = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!password) {
|
||||||
|
setError('Enter your password');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await login(identifier.trim(), password);
|
||||||
|
finish();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(apiMessage(err, 'Incorrect password. Please try again.'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Step 3: set a password with the SMS code -------------------------------
|
||||||
|
const submitSetup = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!otp.trim()) {
|
||||||
|
setError('Enter the code we sent you');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isStrongPassword(newPassword)) {
|
||||||
|
setError(PASSWORD_RULE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setError('Passwords do not match');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const res = await iamAuthApi.completePasswordSetup({
|
||||||
|
identifier: identifier.trim(),
|
||||||
|
otp: otp.trim(),
|
||||||
|
newPassword,
|
||||||
|
confirmPassword,
|
||||||
|
});
|
||||||
|
const { token, user } = res.data.data;
|
||||||
|
// The response carries a real session, so the user lands signed in instead of being
|
||||||
|
// sent back to the form. `setUser` is the same action `login()` persists through.
|
||||||
|
setUser(user as any, token);
|
||||||
|
finish();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(apiMessage(err, 'That code is not valid. Please try again.'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resend = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
setResendNote('');
|
||||||
|
try {
|
||||||
|
await iamAuthApi.requestPasswordSetup(identifier.trim());
|
||||||
|
setResendNote('We sent a new code.');
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(apiMessage(err, 'Could not send a new code. Please try again.'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Step 4: no account yet --------------------------------------------------
|
||||||
|
const submitSignup = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const name = fullName.trim();
|
||||||
|
const other = secondaryContact.trim();
|
||||||
|
if (name.length < 2) {
|
||||||
|
setError('Enter your full name');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A phone is always required — the verification code is sent by SMS and there is no
|
||||||
|
// email channel for it. An email is optional.
|
||||||
|
if (identifierIsEmail) {
|
||||||
|
if (!other) {
|
||||||
|
setError('Enter your phone number');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!looksLikePhone(other)) {
|
||||||
|
setError('Enter a valid phone number — your verification code is sent by SMS');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (other && !EMAIL_RE.test(other)) {
|
||||||
|
// Only validate the shape when they actually typed something.
|
||||||
|
setError('Enter a valid email address');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const phone = identifierIsEmail ? other : identifier.trim();
|
||||||
|
// The IAM requires a non-empty account identifier in its `email` field but never checks
|
||||||
|
// that it is email-shaped, so a passenger with no email address signs up under their phone
|
||||||
|
// number — the same fallback `/register` uses. Both then match on either identifier.
|
||||||
|
const email = identifierIsEmail ? identifier.trim() : other || phone;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await registerUser({ fullName: name, email, phone });
|
||||||
|
setMaskedPhone(phone);
|
||||||
|
setSetupMethod('new');
|
||||||
|
setStep('setup');
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.response?.status === 409) {
|
||||||
|
setError('An account with this email or phone number already exists. Go back and sign in.');
|
||||||
|
} else {
|
||||||
|
setError(apiMessage(err, 'Could not create your account. Please try again.'));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The identifier, shown on every step after the first, with one way back to change it.
|
||||||
|
*
|
||||||
|
* It is a real `autocomplete="username"` input rather than a `<span>`, and it is rendered
|
||||||
|
* *inside* each form. That is what makes password managers behave: a password field sitting
|
||||||
|
* alone in a form gives Chrome nothing to match a saved credential against, so it fills
|
||||||
|
* whichever password it holds for the origin — a password belonging to some other account.
|
||||||
|
* Pairing it with the username lets the manager fill the right credential, or none at all.
|
||||||
|
*/
|
||||||
|
const identifierChip = (
|
||||||
|
<div className="flex items-center justify-between gap-3 mb-4 px-3 py-2 rounded bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={identifier}
|
||||||
|
readOnly
|
||||||
|
tabIndex={-1}
|
||||||
|
autoComplete="username"
|
||||||
|
aria-label="Signing in as"
|
||||||
|
onFocus={(e) => e.currentTarget.blur()}
|
||||||
|
className="flex-1 min-w-0 truncate bg-transparent border-0 p-0 text-sm text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-0 cursor-default"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={goBackToIdentifier}
|
||||||
|
className="flex items-center gap-1 text-sm text-[rgb(20_113_76)] dark:text-emerald-400 hover:underline shrink-0"
|
||||||
|
>
|
||||||
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
|
Change
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const heading = {
|
||||||
|
identifier: { title: 'Sign in', subtitle: 'Enter your phone number or email to continue' },
|
||||||
|
password: { title: 'Welcome back', subtitle: 'Enter your password to sign in' },
|
||||||
|
setup: { title: 'Set your password', subtitle: 'Enter the code we sent, then choose a password' },
|
||||||
|
signup: { title: 'Create your account', subtitle: 'We just need a couple of details' },
|
||||||
|
}[step];
|
||||||
|
|
||||||
|
const setupBlurb =
|
||||||
|
setupMethod === 'fayda'
|
||||||
|
? 'Your Fayda-verified account does not have a password yet.'
|
||||||
|
: setupMethod === 'new'
|
||||||
|
? 'Your account is almost ready.'
|
||||||
|
: 'You started signing up but never chose a password.';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-[rgb(20_113_76)] from-10% via-transparent to-[rgb(20_113_76)] to-90% dark:from-gray-900 dark:to-gray-800 flex items-center justify-center py-12 px-4">
|
<div className="min-h-screen bg-gradient-to-br from-[rgb(20_113_76)] from-10% via-transparent to-[rgb(20_113_76)] to-90% dark:from-gray-900 dark:to-gray-800 flex items-center justify-center py-12 px-4">
|
||||||
<div className="max-w-md w-full">
|
<div className="max-w-md w-full">
|
||||||
@@ -54,86 +299,227 @@ function LoginContent() {
|
|||||||
<Train className="w-6 h-6 text-white" />
|
<Train className="w-6 h-6 text-white" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">Sign in</h1>
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">{heading.title}</h1>
|
||||||
<p className="text-gray-600 dark:text-gray-400 mt-2">Welcome back</p>
|
<p className="text-gray-600 dark:text-gray-400 mt-2">{heading.subtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
{error && (
|
||||||
{error && (
|
<div className="bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300 px-4 py-3 rounded mb-4">
|
||||||
<div className="bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300 px-4 py-3 rounded">
|
{error}
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone or email</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
{...register('email')}
|
|
||||||
className="input-field"
|
|
||||||
placeholder="+251912345678 or your@email.com"
|
|
||||||
autoComplete="username"
|
|
||||||
/>
|
|
||||||
{errors.email && (
|
|
||||||
<p className="text-red-500 text-sm mt-1">{errors.email.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
{step === 'identifier' && (
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Password</label>
|
<form onSubmit={submitIdentifier} className="space-y-4">
|
||||||
<div className="relative">
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
||||||
|
Phone number or email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
ref={identifierRef}
|
||||||
|
type="text"
|
||||||
|
value={identifier}
|
||||||
|
onChange={(e) => { setIdentifier(e.target.value); setError(''); }}
|
||||||
|
className="input-field"
|
||||||
|
placeholder="+251912345678 or your@email.com"
|
||||||
|
autoComplete="username"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect="off"
|
||||||
|
spellCheck={false}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="btn-primary w-full" disabled={loading}>
|
||||||
|
{loading ? 'Checking...' : 'Continue'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'password' && (
|
||||||
|
<form onSubmit={submitPassword} className="space-y-4">
|
||||||
|
{identifierChip}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => { setPassword(e.target.value); setError(''); }}
|
||||||
|
className="input-field pr-10"
|
||||||
|
placeholder="••••••••"
|
||||||
|
autoComplete="current-password"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword((v) => !v)}
|
||||||
|
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||||
|
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end mt-1">
|
||||||
|
<Link
|
||||||
|
href="/forgot-password"
|
||||||
|
className="text-sm text-[rgb(20_113_76)] dark:text-emerald-400 hover:underline"
|
||||||
|
>
|
||||||
|
Forgot password?
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="btn-primary w-full" disabled={loading}>
|
||||||
|
{loading ? 'Signing in...' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'setup' && (
|
||||||
|
<form onSubmit={submitSetup} className="space-y-4">
|
||||||
|
{identifierChip}
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{setupBlurb}{' '}
|
||||||
|
{maskedPhone
|
||||||
|
? <>We sent a code to <span className="font-medium">{maskedPhone}</span>.</>
|
||||||
|
: 'We sent a code to your registered phone.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
||||||
|
Verification code
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={otp}
|
||||||
|
onChange={(e) => { setOtp(e.target.value); setError(''); }}
|
||||||
|
className="input-field tracking-widest"
|
||||||
|
placeholder="A1b2C3"
|
||||||
|
// The IAM issues codes with generateRandomString(6): letters and digits,
|
||||||
|
// and case-sensitive — so no numeric keypad and no autocapitalise.
|
||||||
|
inputMode="text"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect="off"
|
||||||
|
spellCheck={false}
|
||||||
|
maxLength={6}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
||||||
|
New password
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => { setNewPassword(e.target.value); setError(''); }}
|
||||||
|
className="input-field pr-10"
|
||||||
|
placeholder="••••••••"
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword((v) => !v)}
|
||||||
|
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||||
|
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">{PASSWORD_RULE}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
||||||
|
Confirm password
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type={showPassword ? 'text' : 'password'}
|
type={showPassword ? 'text' : 'password'}
|
||||||
{...register('password')}
|
value={confirmPassword}
|
||||||
className="input-field pr-10"
|
onChange={(e) => { setConfirmPassword(e.target.value); setError(''); }}
|
||||||
|
className="input-field"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
|
autoComplete="new-password"
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowPassword((v) => !v)}
|
|
||||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
|
||||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
|
||||||
tabIndex={-1}
|
|
||||||
>
|
|
||||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{errors.password && (
|
|
||||||
<p className="text-red-500 text-sm mt-1">{errors.password.message}</p>
|
<button type="submit" className="btn-primary w-full" disabled={loading}>
|
||||||
)}
|
{loading ? 'Setting password...' : 'Set password and sign in'}
|
||||||
<div className="flex justify-end mt-1">
|
</button>
|
||||||
<Link
|
|
||||||
href="/forgot-password"
|
<div className="text-center">
|
||||||
className="text-sm text-[rgb(20_113_76)] dark:text-emerald-400 hover:underline"
|
{resendNote ? (
|
||||||
>
|
<span className="text-sm text-gray-600 dark:text-gray-400">{resendNote}</span>
|
||||||
Forgot password?
|
) : (
|
||||||
</Link>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={resend}
|
||||||
|
disabled={loading}
|
||||||
|
className="text-sm text-[rgb(20_113_76)] dark:text-emerald-400 hover:underline disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Didn't get a code? Send it again
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
<button type="submit" className="btn-primary w-full" disabled={loading}>
|
{step === 'signup' && (
|
||||||
{loading ? 'Signing in...' : 'Sign in'}
|
<form onSubmit={submitSignup} className="space-y-4">
|
||||||
</button>
|
{identifierChip}
|
||||||
</form>
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
We couldn't find an account for that {identifierIsEmail ? 'email' : 'number'}, so
|
||||||
|
let's create one.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700 space-y-3">
|
<div>
|
||||||
<div className="text-center">
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
||||||
<span className="text-sm text-gray-600 dark:text-gray-400">Don't have an account? </span>
|
Full name
|
||||||
<Link href="/register" className="text-sm font-medium text-[rgb(20_113_76)] dark:text-emerald-400 hover:underline">
|
</label>
|
||||||
Create account
|
<input
|
||||||
</Link>
|
type="text"
|
||||||
</div>
|
value={fullName}
|
||||||
<Link
|
onChange={(e) => { setFullName(e.target.value); setError(''); }}
|
||||||
href="/fayda-setup"
|
className="input-field"
|
||||||
className="flex items-center justify-center gap-2 text-sm text-gray-600 dark:text-gray-400 hover:text-[rgb(20_113_76)] dark:hover:text-emerald-400 transition-colors"
|
placeholder="e.g. Abebe Kebede"
|
||||||
>
|
autoComplete="name"
|
||||||
<ShieldCheck className="w-4 h-4" />
|
autoFocus
|
||||||
Already verified with Fayda? Set up your password
|
/>
|
||||||
</Link>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 text-center">
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
||||||
|
{identifierIsEmail ? 'Phone number' : 'Email address (optional)'}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type={identifierIsEmail ? 'tel' : 'email'}
|
||||||
|
value={secondaryContact}
|
||||||
|
onChange={(e) => { setSecondaryContact(e.target.value); setError(''); }}
|
||||||
|
className="input-field"
|
||||||
|
placeholder={identifierIsEmail ? '+251912345678' : 'your@email.com'}
|
||||||
|
autoComplete={identifierIsEmail ? 'tel' : 'email'}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
{identifierIsEmail
|
||||||
|
? "We'll text your verification code to this number."
|
||||||
|
: "For receipts and booking confirmations. Your verification code is sent by SMS either way."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" className="btn-primary w-full" disabled={loading}>
|
||||||
|
{loading ? 'Creating account...' : 'Create account'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700 text-center">
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/booking/search')}
|
onClick={() => router.push('/booking/search')}
|
||||||
className="text-sm text-gray-600 dark:text-gray-400 hover:text-primary dark:hover:text-primary-400"
|
className="text-sm text-gray-600 dark:text-gray-400 hover:text-primary dark:hover:text-primary-400"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Train, CheckCircle, ArrowLeft, ArrowRight } from 'lucide-react';
|
import { Train, CheckCircle, ArrowLeft, ArrowRight } from 'lucide-react';
|
||||||
import { iamAuthApi } from '@/lib/api/auth';
|
import { iamAuthApi } from '@/lib/api/auth';
|
||||||
|
import { isStrongPassword, PASSWORD_RULE } from '@/lib/password';
|
||||||
|
|
||||||
function ResetPasswordContent() {
|
function ResetPasswordContent() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -24,8 +25,8 @@ function ResetPasswordContent() {
|
|||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
if (newPassword.length < 6) {
|
if (!isStrongPassword(newPassword)) {
|
||||||
setError('Password must be at least 6 characters.');
|
setError(PASSWORD_RULE);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (newPassword !== confirmPassword) {
|
if (newPassword !== confirmPassword) {
|
||||||
|
|||||||
@@ -6,18 +6,8 @@ import Link from 'next/link';
|
|||||||
import { Train, ArrowLeft, ShieldCheck } from 'lucide-react';
|
import { Train, ArrowLeft, ShieldCheck } from 'lucide-react';
|
||||||
import { iamAuthApi } from '@/lib/api/auth';
|
import { iamAuthApi } from '@/lib/api/auth';
|
||||||
import { useAuthStore } from '@/lib/auth-store';
|
import { useAuthStore } from '@/lib/auth-store';
|
||||||
|
import { isStrongPassword } from '@/lib/password';
|
||||||
|
|
||||||
// Mirrors the IAM set-password requirement (class-validator @IsStrongPassword defaults):
|
|
||||||
// min length 8, with lower- and upper-case letters, a number, and a symbol.
|
|
||||||
function isStrongPassword(pw: string): boolean {
|
|
||||||
return (
|
|
||||||
pw.length >= 8 &&
|
|
||||||
/[a-z]/.test(pw) &&
|
|
||||||
/[A-Z]/.test(pw) &&
|
|
||||||
/[0-9]/.test(pw) &&
|
|
||||||
/[^A-Za-z0-9]/.test(pw)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function VerifyAccountContent() {
|
function VerifyAccountContent() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|||||||
@@ -192,18 +192,12 @@ export default function AppSidebar() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2 px-1 pt-1">
|
<div className="px-1 pt-1">
|
||||||
<Link
|
<Link
|
||||||
href="/login"
|
href="/login"
|
||||||
className="flex-1 text-center px-3 py-2 text-sm font-medium text-gray-100 hover:bg-white/10 rounded-lg transition-colors"
|
className="block text-center px-3 py-2 text-sm font-medium bg-white text-[rgb(20_113_76)] hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
Sign in
|
Sign in or register
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/register"
|
|
||||||
className="flex-1 text-center px-3 py-2 text-sm font-medium bg-white text-[rgb(20_113_76)] hover:bg-gray-100 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
Register
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState } from 'react';
|
|||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { X, CheckCircle } from 'lucide-react';
|
import { X, CheckCircle } from 'lucide-react';
|
||||||
import { iamAuthApi } from '@/lib/api/auth';
|
import { iamAuthApi } from '@/lib/api/auth';
|
||||||
|
import { isStrongPassword, PASSWORD_RULE } from '@/lib/password';
|
||||||
|
|
||||||
interface ChangePasswordModalProps {
|
interface ChangePasswordModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -32,8 +33,8 @@ export default function ChangePasswordModal({ isOpen, onClose }: ChangePasswordM
|
|||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
if (newPassword.length < 6) {
|
if (!isStrongPassword(newPassword)) {
|
||||||
setError('New password must be at least 6 characters.');
|
setError(PASSWORD_RULE);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (newPassword !== confirmPassword) {
|
if (newPassword !== confirmPassword) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Train, ShieldCheck, CheckCircle, Info, ArrowLeft, ArrowRight } from 'lucide-react';
|
import { Train, ShieldCheck, CheckCircle, Info, ArrowLeft, ArrowRight } from 'lucide-react';
|
||||||
import { iamAuthApi } from '@/lib/api/auth';
|
import { iamAuthApi } from '@/lib/api/auth';
|
||||||
|
import { isStrongPassword, PASSWORD_RULE } from '@/lib/password';
|
||||||
|
|
||||||
interface FaydaSetupWizardProps {
|
interface FaydaSetupWizardProps {
|
||||||
// Prefilled OTP when landing from the SMS link (/set-password?verificationCode=...)
|
// Prefilled OTP when landing from the SMS link (/set-password?verificationCode=...)
|
||||||
@@ -41,8 +42,8 @@ export default function FaydaSetupWizard({ initialOtp }: FaydaSetupWizardProps)
|
|||||||
const handleSetPassword = async (e: React.FormEvent) => {
|
const handleSetPassword = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
if (newPassword.length < 6) {
|
if (!isStrongPassword(newPassword)) {
|
||||||
setError('Password must be at least 6 characters.');
|
setError(PASSWORD_RULE);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (newPassword !== confirmPassword) {
|
if (newPassword !== confirmPassword) {
|
||||||
|
|||||||
@@ -36,6 +36,42 @@ export const iamAuthApi = {
|
|||||||
headers: { Authorization: `Bearer ${localStorage.getItem('auth_token')}` },
|
headers: { Authorization: `Bearer ${localStorage.getItem('auth_token')}` },
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// --- Staged sign-in (/login) -------------------------------------------------
|
||||||
|
// Step 1: hand the server one field and let it say which branch follows. `identifier`
|
||||||
|
// is a phone number or an email; the server works out which.
|
||||||
|
lookupIdentifier: (identifier: string) =>
|
||||||
|
axios.post<{
|
||||||
|
success: boolean;
|
||||||
|
data: {
|
||||||
|
status: 'PASSWORD' | 'NEEDS_PASSWORD_SETUP' | 'NOT_FOUND';
|
||||||
|
method?: 'fayda' | 'pending';
|
||||||
|
maskedPhone?: string;
|
||||||
|
};
|
||||||
|
}>(`${API_URL}/auth/identifier/lookup`, { identifier }),
|
||||||
|
|
||||||
|
// Step 2a: SMS the code for an account that exists but has no password yet.
|
||||||
|
// Always resolves — the server reports { sent: true } even for an unknown identifier.
|
||||||
|
requestPasswordSetup: (identifier: string) =>
|
||||||
|
axios.post(`${API_URL}/auth/password-setup/request`, { identifier }),
|
||||||
|
|
||||||
|
// Step 2b: redeem the code and set the password. Unlike the older Fayda dance this
|
||||||
|
// returns a usable session directly, so the user lands signed in rather than back on
|
||||||
|
// the login form. Same response shape as POST /auth/login.
|
||||||
|
completePasswordSetup: (data: {
|
||||||
|
identifier: string;
|
||||||
|
otp: string;
|
||||||
|
newPassword: string;
|
||||||
|
confirmPassword: string;
|
||||||
|
}) =>
|
||||||
|
axios.post<{
|
||||||
|
success: boolean;
|
||||||
|
data: {
|
||||||
|
token: string;
|
||||||
|
refreshToken: string;
|
||||||
|
user: { id: string; iamUserId: string; email: string | null; passengerId: string };
|
||||||
|
};
|
||||||
|
}>(`${API_URL}/auth/password-setup/complete`, data),
|
||||||
|
|
||||||
faydaRequestPasswordSetup: (phoneNumber: string) =>
|
faydaRequestPasswordSetup: (phoneNumber: string) =>
|
||||||
axios.post(`${API_URL}/auth/fayda/request-password-setup`, { phoneNumber }),
|
axios.post(`${API_URL}/auth/fayda/request-password-setup`, { phoneNumber }),
|
||||||
|
|
||||||
|
|||||||
@@ -113,13 +113,10 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
login: async (email: string, password: string) => {
|
login: async (email: string, password: string) => {
|
||||||
const response: any = await apiClient.post('/auth/login', { email, password });
|
const response: any = await apiClient.post('/auth/login', { email, password });
|
||||||
const { token, user } = response.data || response;
|
const { token, user } = response.data || response;
|
||||||
|
// `setUser` is the one place a session is persisted. The staged sign-in's
|
||||||
if (typeof window !== 'undefined') {
|
// password-setup branch establishes a session without going through /auth/login,
|
||||||
localStorage.setItem('auth_token', token);
|
// so it calls the same action rather than duplicating the storage writes.
|
||||||
localStorage.setItem('auth_user', JSON.stringify(user));
|
get().setUser(user, token);
|
||||||
}
|
|
||||||
|
|
||||||
set({ user, token, isAuthenticated: true });
|
|
||||||
},
|
},
|
||||||
|
|
||||||
register: async (data: RegisterData): Promise<RegisterResult> => {
|
register: async (data: RegisterData): Promise<RegisterResult> => {
|
||||||
|
|||||||
21
apps/edr-passenger-web/portal/src/lib/password.ts
Normal file
21
apps/edr-passenger-web/portal/src/lib/password.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* The one password rule the portal enforces.
|
||||||
|
*
|
||||||
|
* It mirrors class-validator's `@IsStrongPassword` defaults, which is what the IAM applies on
|
||||||
|
* `PATCH /v1/auth/set-password` and what `POST /auth/password-setup/complete` applies on the
|
||||||
|
* passenger API. Screens that used a looser check (`length < 6`) accepted passwords the server
|
||||||
|
* then rejected with an opaque 400, so every screen shares this instead.
|
||||||
|
*/
|
||||||
|
export function isStrongPassword(pw: string): boolean {
|
||||||
|
return (
|
||||||
|
pw.length >= 8 &&
|
||||||
|
/[a-z]/.test(pw) &&
|
||||||
|
/[A-Z]/.test(pw) &&
|
||||||
|
/[0-9]/.test(pw) &&
|
||||||
|
/[^A-Za-z0-9]/.test(pw)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The rule stated for humans. Shown as helper text and reused as the validation message. */
|
||||||
|
export const PASSWORD_RULE =
|
||||||
|
'Password must be at least 8 characters and include an upper-case letter, a lower-case letter, a number and a symbol.';
|
||||||
@@ -383,3 +383,19 @@ export function expectProfileActive(companyName: string, type = "importer") {
|
|||||||
expect(rows[0].company_status).to.eq("active");
|
expect(rows[0].company_status).to.eq("active");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach one of the TIN's eTrade businesses to a role, on the documents step.
|
||||||
|
*
|
||||||
|
* Which one does not matter to these flows — only that a company with an eTrade
|
||||||
|
* record cannot submit onboarding until every role names one. A co-operative or
|
||||||
|
* investor-licence company has no list, so its flows never call this.
|
||||||
|
*/
|
||||||
|
export function chooseRoleBusiness(role = "Importer") {
|
||||||
|
cy.contains("label", `Which business is your ${role} profile?`)
|
||||||
|
.parents(".mantine-InputWrapper-root")
|
||||||
|
.first()
|
||||||
|
.find("input")
|
||||||
|
.click();
|
||||||
|
cy.get("[role='option']").first().click();
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
vatNumber,
|
vatNumber,
|
||||||
wizardClick,
|
wizardClick,
|
||||||
SIGNUP_PASSWORD,
|
SIGNUP_PASSWORD,
|
||||||
|
chooseRoleBusiness,
|
||||||
} from "./onboarding-utils";
|
} from "./onboarding-utils";
|
||||||
|
|
||||||
const stamp = Date.now();
|
const stamp = Date.now();
|
||||||
@@ -128,6 +129,7 @@ describe("onboarding — Ethiopian company, eTrade verified", { retries: 0 }, ()
|
|||||||
cy.contains("Upload Importer Business license file(s)", {
|
cy.contains("Upload Importer Business license file(s)", {
|
||||||
timeout: 20000,
|
timeout: 20000,
|
||||||
}).should("be.visible");
|
}).should("be.visible");
|
||||||
|
chooseRoleBusiness();
|
||||||
attachNextFile();
|
attachNextFile();
|
||||||
attachNextFile();
|
attachNextFile();
|
||||||
wizardClick("Submit for review");
|
wizardClick("Submit for review");
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
signupIdentity,
|
signupIdentity,
|
||||||
wizardClick,
|
wizardClick,
|
||||||
SIGNUP_PASSWORD,
|
SIGNUP_PASSWORD,
|
||||||
|
chooseRoleBusiness,
|
||||||
} from "./onboarding-utils";
|
} from "./onboarding-utils";
|
||||||
|
|
||||||
const stamp = Date.now();
|
const stamp = Date.now();
|
||||||
@@ -133,6 +134,7 @@ describe("onboarding — switching back to eTrade registration", { retries: 0 },
|
|||||||
cy.contains("Upload Importer Business license file(s)", {
|
cy.contains("Upload Importer Business license file(s)", {
|
||||||
timeout: 20000,
|
timeout: 20000,
|
||||||
}).should("be.visible");
|
}).should("be.visible");
|
||||||
|
chooseRoleBusiness();
|
||||||
wizardClick("Submit for review");
|
wizardClick("Submit for review");
|
||||||
cy.contains("You're all set", { timeout: 30000 }).should("be.visible");
|
cy.contains("You're all set", { timeout: 30000 }).should("be.visible");
|
||||||
|
|
||||||
|
|||||||
@@ -76,9 +76,14 @@ export interface ETradeBusinessOption {
|
|||||||
|
|
||||||
export interface CompanyRegistrationData {
|
export interface CompanyRegistrationData {
|
||||||
/**
|
/**
|
||||||
* The registered organization name — `ETradeCompanyInfo.BusinessName`, falling
|
* The selected licence's trade name — `ETradeBusinessInfo.TradeName`, falling
|
||||||
* back to the licence's `TradeName`. Never the manager/owner's personal name;
|
* back to the registered organization name (`ETradeCompanyInfo.BusinessName`)
|
||||||
* that is {@link managerName}.
|
* when eTrade leaves the licence's trade name blank. Never the manager/owner's
|
||||||
|
* personal name; that is {@link managerName}.
|
||||||
|
*
|
||||||
|
* NOT the legal entity name: a TIN often trades under a different name, and
|
||||||
|
* some hold several licences with different trade names. Anything that needs
|
||||||
|
* the registered name (tax/EIMS) must read `BusinessName` directly.
|
||||||
*/
|
*/
|
||||||
companyName: string;
|
companyName: string;
|
||||||
licenceNumber: string;
|
licenceNumber: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user