Merge pull request #1432 from Tria-plc/staging

Staging
This commit is contained in:
marshal
2026-08-27 14:24:54 +03:00
committed by GitHub
64 changed files with 2705 additions and 343 deletions

View File

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

View File

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

View File

@@ -12,6 +12,10 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { BackofficeResetPasswordDto } from "./dto/forgot-password.dto";
import {
CustomerAccount,
CustomerAccountsService,
} from "./customer-accounts.service";
import {
CustomerResetService,
CustomerResetTarget,
@@ -25,7 +29,22 @@ import {
@Controller("backoffice/customers")
@ApiBearerAuth()
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")
@BookingStaff(FREIGHT_PERMS.customers.resetPassword)

View File

@@ -13,6 +13,7 @@ import { AccountController } from './account.controller';
import { AccountService } from './account.service';
import { CheckAvailabilityController } from './check-availability.controller';
import { CheckAvailabilityService } from './check-availability.service';
import { CustomerAccountsService } from './customer-accounts.service';
import { CustomerResetController } from './customer-reset.controller';
import { CustomerResetService } from './customer-reset.service';
import { ForgotPasswordController } from './forgot-password.controller';
@@ -50,6 +51,7 @@ import { ListUsersService } from './list-users.service';
CheckAvailabilityService,
ForgotPasswordService,
CustomerResetService,
CustomerAccountsService,
],
// Shipping-line registration mints activation links through the same
// staff-triggered reset path customers use.

View File

@@ -29,6 +29,7 @@ import { PaymentService } from "../payment/payment.service";
import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
import {
InvoiceDocumentModel,
sameCompanyName,
InvoiceDocumentService,
pngDataUrl,
} from "./documents/invoice-document.service";
@@ -875,10 +876,21 @@ export class BillingService {
totals.push({ label: "Paid", amount: Number(invoice.paidAmount) });
totals.push({ label: "Balance", amount: Number(invoice.balanceAmount) });
const tradeName = invoice.companyProfile?.etradeBusiness?.tradeName?.trim();
const summary: InvoiceDocumentModel["summary"] = [
// 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.
{ 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 },
...(invoice.company?.vatNumber
? [{ label: "Buyer VAT No.", value: invoice.company.vatNumber }]

View File

@@ -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 => ({
kind: "INVOICE",
@@ -87,3 +87,38 @@ describe("InvoiceDocumentService.buildThermalHtml", () => {
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);
});
});

View File

@@ -48,6 +48,34 @@ function formatDate(value: unknown): string {
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). */
export interface InvoiceDocumentLine {
description: string | null;

View File

@@ -32,7 +32,9 @@ import { CreateCompanyDto } from "./dto/create-company.dto";
import { UpdateCompanyDto } from "./dto/update-company.dto";
import { CreateExternalProfileDto } from "./dto/create-external-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 { AttachEtradeBusinessDto } from "./dto/attach-etrade-business.dto";
import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto";
import {
CompanyIdentityStateDto,
@@ -260,11 +262,42 @@ export class CompaniesController {
): Promise<ResponseCompanyProfileDto[]> {
const profiles = await this.companiesService.addCompanyProfilesForUser(
user.id,
dto.types,
dto.profiles,
);
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")
@PortalCustomer()
@ApiOperation({
@@ -321,6 +354,7 @@ export class CompaniesController {
user.id,
dto.type,
dto.businessLicense,
dto.licenceNumber,
);
return new ResponseCompanyProfileDto(profile);
}

View File

@@ -162,7 +162,16 @@ function makeService(overrides: Partial<Ctx> = {}) {
{} as never,
deps.filesService 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,
{} as never,
deps.verifayda as never,
@@ -502,7 +511,9 @@ describe("the owner is checked against the eTrade licence", () => {
describe("the freight-forwarder gate", () => {
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 () => {
const { service } = makeService({ attributes: { poaDeclared: "yes" } });

View File

@@ -133,7 +133,16 @@ function makeService(overrides: Partial<Ctx> = {}) {
{} as never,
deps.filesService 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,
{} as never,
{} as never,
@@ -200,6 +209,8 @@ describe("PoA delegation paper is enforced wherever PoA state changes", () => {
service.createCompanyProfileForUser(
"user-1",
ProfileType.freightForwarder,
undefined,
"LIC-1",
),
).rejects.toBeInstanceOf(BadRequestException);
});
@@ -263,6 +274,8 @@ describe("PoA delegation paper is enforced wherever PoA state changes", () => {
service.createCompanyProfileForUser(
"user-1",
ProfileType.freightForwarder,
undefined,
"LIC-1",
),
).rejects.toBeInstanceOf(BadRequestException);
});
@@ -277,6 +290,8 @@ describe("PoA delegation paper is enforced wherever PoA state changes", () => {
service.createCompanyProfileForUser(
"user-1",
ProfileType.freightForwarder,
undefined,
"LIC-1",
),
).resolves.toBeDefined();
});

View File

@@ -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([]);
});
});

View File

@@ -84,6 +84,7 @@ export class CompaniesRepository extends BaseRepository<Company> {
createdTo,
onboardingCompleted,
hasPendingChangeRequest,
profileType,
sortBy = 'review',
sortOrder = 'DESC',
} = query;
@@ -138,20 +139,46 @@ export class CompaniesRepository extends BaseRepository<Company> {
if (search) {
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(
`(company.name ILIKE :term
OR company.tin ILIKE :term
OR company.email ILIKE :term
OR company.licence_number ILIKE :term
OR EXISTS (
SELECT 1 FROM freight.company_profiles cp
WHERE cp.company_id = company.id
AND cp.reference ILIKE :term
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 },
);
}
// 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.
if (sortBy === 'review') {
// Queue ordering: actionable tiers first, newest first within each. The

View File

@@ -47,7 +47,7 @@ import { ETradeService } from "./services/etrade.service";
import { CompanyNotifierService } from "./company-notifier.service";
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
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 { UpdateCompanyDto } from "./dto/update-company.dto";
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
@@ -343,6 +343,11 @@ export class CompaniesService {
companyId: company.id,
type: input.type,
businessLicense: input.businessLicense ?? null,
etradeBusiness: await this.resolveProfileBusiness(
company,
input.licenceNumber,
input.type,
),
status: ProfileStatus.Pending,
});
}
@@ -2149,8 +2154,9 @@ export class CompaniesService {
*/
async addCompanyProfilesForUser(
userId: string,
types: ProfileType[],
inputs: Array<{ type: ProfileType; licenceNumber?: string }>,
): Promise<CompanyProfile[]> {
const types = inputs.map((i) => i.type);
const profile = await this.profilesRepo.findByUserId(userId);
if (!profile)
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
// is minted only when a backoffice reviewer approves the role.
await this.companyProfilesRepo.create({
companyId,
type,
etradeBusiness,
status: ProfileStatus.Pending,
});
}
@@ -2207,6 +2223,7 @@ export class CompaniesService {
userId: string,
type: ProfileType,
businessLicense?: string,
licenceNumber?: string,
): Promise<CompanyProfile> {
const profile = await this.profilesRepo.findByUserId(userId);
if (!profile)
@@ -2232,12 +2249,18 @@ export class CompaniesService {
);
}
if (!created) {
const etradeBusiness = await this.resolveProfileBusiness(
company,
licenceNumber,
type,
);
// New self-service roles start Pending (awaiting backoffice approval) and
// carry no reference until approved.
created = await this.companyProfilesRepo.create({
companyId,
type,
businessLicense: businessLicense ?? null,
etradeBusiness,
status: ProfileStatus.Pending,
});
}
@@ -2320,6 +2343,7 @@ export class CompaniesService {
type: p.type,
reference: p.reference ?? "",
uploaded: records.some((r) => r.code === LICENSE_CODE),
etradeBusiness: p.etradeBusiness ?? null,
};
}),
);
@@ -2331,6 +2355,19 @@ export class CompaniesService {
? []
: 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
// declaration — the question the wizard asks outright — and that answer is
// what decides whose identity gets verified, so an unanswered one is itself
@@ -2378,6 +2415,10 @@ export class CompaniesService {
(p) =>
`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()}`),
...(missingDelegation
? [`Upload the ${POA_DELEGATION_LABEL} for your Power of Attorney`]
@@ -2416,6 +2457,8 @@ export class CompaniesService {
requiredInfo.length +
requiredDocCount +
(cooperative ? 0 : licenseProfiles.length) +
// One "which business?" item per role, on the same terms as the licences.
(usesManualRegistration(company) ? 0 : licenseProfiles.length) +
poaItemCount +
// The declaration and the verification it selects.
2;
@@ -2424,6 +2467,7 @@ export class CompaniesService {
(missingInfo.length +
missingDocs.length +
missingLicenses.length +
missingBusinesses.length +
missingPoaFields.length +
(missingDelegation || flaggedDelegation ? 1 : 0) +
missingIdentityCount);
@@ -3747,6 +3791,86 @@ export class CompaniesService {
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. */
private async resolveEtradeRegistration(
tin: string,

View File

@@ -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";
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 {
@IsArray()
@ArrayMinSize(1)
@IsEnum(ProfileType, { each: true })
types!: ProfileType[];
@ValidateNested({ each: true })
@Type(() => AddCompanyProfileInputDto)
profiles!: AddCompanyProfileInputDto[];
}

View File

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

View File

@@ -9,4 +9,14 @@ export class CreateCompanyProfileDto {
@IsString()
@MaxLength(100)
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;
}

View File

@@ -22,6 +22,16 @@ export class CompanyProfileInputDto {
@IsString()
@MaxLength(100)
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 {

View File

@@ -15,6 +15,7 @@ import {
CompanyStatus,
CompanyType,
} from "../entities/company.entity";
import { ProfileType } from "../entities/company-profile.entity";
export class ListCompaniesQueryDto {
@ApiPropertyOptional({ default: 1 })
@@ -56,6 +57,16 @@ export class ListCompaniesQueryDto {
@IsIn(Object.values(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)." })
@IsOptional()
@IsDateString()

View File

@@ -8,6 +8,7 @@
* truth the wizard uses to auto-finish.
*/
import type { ETradeBusinessOption } from "@edr/types";
import {
CompanyIdentityStateDto,
PoaDeclaration,
@@ -38,6 +39,11 @@ export interface OnboardingLicenseProfile {
reference: string;
/** True when at least one business-license file is stored on the profile. */
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 {

View File

@@ -6,6 +6,7 @@ import {
hasInvestorLicence,
isCooperative,
} from '../entities/company.entity';
import type { ETradeBusinessOption } from '@edr/types';
import {
CompanyProfile,
ProfileLicenseFileView,
@@ -31,6 +32,12 @@ export class ResponseCompanyProfileDto {
*/
licenseFiles: ProfileLicenseFileView[];
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). */
reviewNote?: string | null;
createdAt: Date;
@@ -45,6 +52,7 @@ export class ResponseCompanyProfileDto {
this.businessLicense = profile.businessLicense;
this.licenseFiles = [];
this.attributes = profile.attributes;
this.etradeBusiness = profile.etradeBusiness ?? null;
this.reviewNote = profile.reviewNote ?? null;
this.createdAt = profile.createdAt;
this.updatedAt = profile.updatedAt;

View File

@@ -1,4 +1,5 @@
import { BaseEntity } from "@edr/api-common";
import type { ETradeBusinessOption } from "@edr/types";
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
import { Company } from "./company.entity";
@@ -126,6 +127,23 @@ export class CompanyProfile extends BaseEntity {
@Column({ name: "business_license_files", type: "jsonb", nullable: true })
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 })
attributes?: Record<string, any> | null;

View File

@@ -78,6 +78,27 @@ describe('ETradeService business selection', () => {
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', () => {
const { service } = build();
const data = service.extractRegistrationData(

View File

@@ -5,6 +5,7 @@ import { firstValueFrom } from "rxjs";
import {
ETradeCompanyInfo,
ETradeBusinessInfo,
ETradeBusinessOption,
CompanyRegistrationData,
normalizeRegion,
} from "@edr/types";
@@ -102,10 +103,16 @@ export class ETradeService {
}
/**
* `companyInfo` carries the registered organization name (`BusinessName`);
* `businessInfo` only carries the licence's `TradeName`. Pass both so the
* company name resolves to the legal entity rather than the trade name — and
* never to `ManagerNameEng`, which is the manager's personal name.
* `businessInfo` carries the selected licence's `TradeName`; `companyInfo`
* carries the registered organization name (`BusinessName`). The company name
* resolves to the trade name of the licence the customer picked — a TIN
* 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(
businessInfo: ETradeBusinessInfo,
@@ -115,7 +122,7 @@ export class ETradeService {
return {
companyName:
companyInfo?.BusinessName?.trim() || businessInfo.TradeName?.trim() || "",
businessInfo.TradeName?.trim() || companyInfo?.BusinessName?.trim() || "",
licenceNumber: businessInfo.LicenceNumber,
statusDescription: businessInfo.StatusDescription,
dateRegistered: businessInfo.DateRegistered,
@@ -137,17 +144,57 @@ export class ETradeService {
regularPhone: businessInfo.AddressInfo?.RegularPhone || "",
managerName: primaryManager?.ManagerNameEng || "",
managerPhone: primaryManager?.RegularPhone || "",
businesses: (companyInfo?.Businesses ?? []).map((b) => ({
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 || "",
})),
businesses: (companyInfo?.Businesses ?? []).map(toBusinessOption),
};
}
/**
* 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 || "",
};
}

View File

@@ -118,7 +118,11 @@ export class EimsSellerCacheService implements OnModuleInit {
woreda: data.woreda,
});
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,
Region: geo?.Region,
Wereda: geo?.Wereda,

View File

@@ -22,6 +22,7 @@ import { InvoiceLine } from "../billing/entities/invoice-line.entity";
import { PayInvoiceDto as GatewayPayInvoiceDto } from "../billing/dto/pay-invoice.dto";
import {
InvoiceDocumentModel,
sameCompanyName,
InvoiceDocumentService,
} from "../billing/documents/invoice-document.service";
import { NotificationsService } from "../notifications/notifications.service";
@@ -71,6 +72,11 @@ const ACTIVE_STATUSES: Freight.InvoiceStatus[] = [
export interface InvoiceDocumentDetails {
bookingReference: 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;
inventoryInfo: string | null;
inventoryStatus: string | null;
@@ -745,6 +751,17 @@ export class WarehouseInvoiceService {
},
{ label: "Booking reference", value: invoice.bookingReference ?? 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",
value: invoice.inventoryReference ?? null,
@@ -795,6 +812,7 @@ export class WarehouseInvoiceService {
const [row] = await this.dataSource.query(
`SELECT b.reference AS "bookingReference",
company.name AS "customerName",
cp.etrade_business->>'tradeName' AS "customerTradeName",
COALESCE(inv.release_order_reference, b.reference) AS "inventoryReference",
inv.status AS "inventoryStatus",
inv.release_date AS "releaseDate",
@@ -812,6 +830,7 @@ export class WarehouseInvoiceService {
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.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.booking_container booking_container ON (
booking_container.booking_id = b.id
@@ -837,6 +856,7 @@ export class WarehouseInvoiceService {
return {
bookingReference: row?.bookingReference ?? null,
customerName: row?.customerName ?? null,
customerTradeName: row?.customerTradeName ?? null,
inventoryReference: row?.inventoryReference ?? null,
inventoryInfo: row?.inventoryInfo ?? null,
inventoryStatus: row?.inventoryStatus ?? null,

View File

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

View File

@@ -3,6 +3,12 @@ import type { ReactNode } from "react";
export interface TableCardProps {
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
* 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
* single child is a horizontally scrollable region. 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).
* Flush card shell for a `DataTable`: a padding-less card whose table region
* runs edge to edge. Padding is applied per section rather than to the card, so
* the optional {@link TableCardProps.header} is inset like any other card
* 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 (
<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 miw={minWidth}>{children}</Box>
</Box>

View File

@@ -15,6 +15,7 @@ export {
ChangeRequestReview,
ChangeRequestPendingBadge,
} from "./ChangeRequestReview";
export { AccountCard } from "./AccountCard";
export { CompanyTimeline } from "./CompanyTimeline";
export {
RequestDocumentChangeModal,

View File

@@ -80,6 +80,7 @@ export const QUERY_KEYS = {
documents: (id: string) =>
["customers", "detail", id, "documents"] as const,
payments: (id: string) => ["customers", "detail", id, "payments"] as const,
accounts: (id: string) => ["customers", "detail", id, "accounts"] as const,
resetTarget: (id: string) =>
["customers", "detail", id, "reset-target"] as const,
changeRequests: (id: string) =>

View File

@@ -138,6 +138,8 @@ export const URL_CONSTANTS = {
`/backoffice/customers/${companyId}/reset-password`,
RESET_TARGET: (companyId: string) =>
`/backoffice/customers/${companyId}/reset-target`,
ACCOUNTS: (companyId: string) =>
`/backoffice/customers/${companyId}/accounts`,
},
BILLING: {

View File

@@ -29,6 +29,7 @@ import {
FileText,
History,
Hourglass,
KeyRound,
IdCard,
LayoutGrid,
Package,
@@ -43,6 +44,7 @@ import { useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
AccountCard,
BookingStatusBadge,
ChangeRequestPendingBadge,
ChangeRequestReview,
@@ -157,6 +159,12 @@ export default function CustomerDetailPage() {
enabled: Boolean(id),
}),
);
const accountsQuery = useQuery(
api.customers.accounts.queryOptions({
input: { companyId: id ?? "" },
enabled: Boolean(id),
}),
);
const documentsQuery = useQuery(
api.customers.documents.queryOptions({
input: { id: id ?? "" },
@@ -240,12 +248,61 @@ export default function CustomerDetailPage() {
<div className="space-y-2">
<ProfileTypeBadge type={row.original.type} />
<Text size="sm" fw={600} c="edr-text">
{row.original.reference}
</Text>
{/* The profile reference (EX-A00001). Minted only when a reviewer
approves the role, so an unapproved one has none — say so
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>
),
},
{
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",
header: "License documents",
@@ -774,6 +831,9 @@ export default function CustomerDetailPage() {
<Tabs.Tab value="invoices" leftSection={<Receipt size={16} />}>
Invoices
</Tabs.Tab>
<Tabs.Tab value="accounts" leftSection={<KeyRound size={16} />}>
Account
</Tabs.Tab>
<Tabs.Tab value="history" leftSection={<History size={16} />}>
History
</Tabs.Tab>
@@ -981,29 +1041,29 @@ export default function CustomerDetailPage() {
</Stack>
</Card>
<Card>
<Stack gap="md">
{/* Padding sits on the header section, not the card, so the
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">
<Text fw={600} c="edr-text">
Role profiles
</Text>
<ProfileChips profiles={profiles} />
</Group>
{/* Narrower than the old full-width layout — the table
shares the row with the people column now. */}
<Box style={{ overflowX: "auto" }} w="100%">
<Box miw={760}>
<DataTable
columns={profileColumns}
data={profiles}
status="success"
emptyMessage="No profiles registered."
containerClassName="border-0 shadow-none bg-transparent"
/>
</Box>
</Box>
</Stack>
</Card>
}
>
<DataTable
columns={profileColumns}
data={profiles}
status="success"
emptyMessage="No profiles registered."
containerClassName="border-0 shadow-none bg-transparent"
/>
</TableCard>
</Stack>
</Grid.Col>
@@ -1439,6 +1499,51 @@ export default function CustomerDetailPage() {
</Tabs.Panel>
{/* 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">
<CompanyTimeline company={company} />
</Tabs.Panel>

View File

@@ -108,6 +108,24 @@ const CUSTOMER_FILTER_DEFS: FilterDef[] = [
["customer", "freight_forwarder", "dj_freight_forwarder", "transporter"] as const
).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",
label: "Sector",
@@ -348,7 +366,7 @@ export default function CustomersPage() {
<FilterBar
defs={CUSTOMER_FILTER_DEFS}
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 }))}
viewId="customers"
>

View File

@@ -135,14 +135,8 @@ export default function TrainScheduleV2DetailPage() {
// Actual departure — staff often dispatch on paper first and record it later,
// so the time is picked (defaults to now when the dialog opens).
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 = () => {
setDispatchAt(new Date());
setDispatchLoadedIds(new Set());
setDispatchConfirmOpen(true);
};
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.
const hasDispatchWarnings =
unassignedCount > 0 || unloadedCount > 0 || intercityNotLoadedCount > 0;
// Unloaded boarders at the TRAIN's origin — the dispatch dialog's manual
// load/leave list. Mirrors the API's unloadedOriginBoarderIds predicate
// (plus government, which is shown but forced-loaded).
// Unloaded boarders at the TRAIN's origin — all sent as loaded on dispatch.
// Mirrors the API's unloadedOriginBoarderIds predicate (plus government).
const originYardId = schedule.originStation?.id;
const pendingOriginBoarders = dispatchBookings.filter(
(b) =>
@@ -489,12 +482,9 @@ export default function TrainScheduleV2DetailPage() {
// Shipping-line bookings ride from accept on the credit ledger.
(Boolean(b.shippingLineCompanyId) && b.status === "FULLY_EXECUTED")),
);
const dispatchLeftCount = pendingOriginBoarders.filter(
(b) => !b.isGovernment && !dispatchLoadedIds.has(b.id),
).length;
// 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.
// Origin loading time window: dispatch (which marks the boarders loaded)
// is server-rejected until "Start loading" was clicked for the origin
// yard, so the button mirrors that gate.
const originLoadingLog = originYardId
? schedule.stationWorkLogs?.[originYardId]?.loading
: undefined;
@@ -557,9 +547,9 @@ export default function TrainScheduleV2DetailPage() {
id: scheduleId,
payload: {
...(dispatchAt ? { actualDepartureAt: dispatchAt.toISOString() } : {}),
loadedBookingIds: pendingOriginBoarders
.filter((b) => b.isGovernment || dispatchLoadedIds.has(b.id))
.map((b) => b.id),
// No per-booking ticking in the dispatch dialog: every pending origin
// boarder rides — none are left behind at dispatch time.
loadedBookingIds: pendingOriginBoarders.map((b) => b.id),
},
});
await openMarshallingDocument({
@@ -1590,56 +1580,6 @@ export default function TrainScheduleV2DetailPage() {
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 ? (
<Alert
color="orange"

View File

@@ -13,6 +13,7 @@ import type {
CustomerBooking,
CustomerDocument,
CustomerPayment,
CustomerAccount,
CustomerResetTarget,
PaginatedCompanies,
ProfileStatus,
@@ -3222,6 +3223,13 @@ export const api = {
({ 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>(
"customers",
"resetTarget",

View File

@@ -10,6 +10,7 @@ import type {
CustomerBooking,
CustomerDocument,
CustomerPayment,
CustomerAccount,
CustomerResetTarget,
PaginatedCompanies,
ProfileStatus,
@@ -90,6 +91,18 @@ export const customersService = {
.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
* so staff see the credentials the link actually reaches, not the company's

View File

@@ -8,6 +8,8 @@
* API so the data layer can be swapped to live endpoints with no UI changes.
*/
import type { ETradeBusinessOption } from "@edr/types";
/** Mirrors backend `CompanyType`. */
export type CompanyType =
| "customer"
@@ -65,6 +67,15 @@ export interface CompanyProfile {
/** Business-license documents uploaded for this profile. */
licenseFiles?: LicenseFile[];
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. */
reviewNote?: string | null;
createdAt: string;
@@ -166,6 +177,34 @@ export interface ResetPasswordResult {
* Distinct from `Company.email` / `Company.phone`, which are business contact
* 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 {
userId: string;
name: string;
@@ -314,6 +353,13 @@ export interface CompanyListFilter {
kind?: CompanyKind;
status?: CompanyStatus;
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. */
createdFrom?: string;
createdTo?: string;

View File

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

View File

@@ -428,6 +428,7 @@ export default function OnboardingWizardDialog({
type: p.type,
reference: p.reference,
existingFiles: p.licenseFiles ?? [],
etradeBusiness: p.etradeBusiness ?? null,
}));
// The active step across the whole journey, driving the header + progress pill.

View File

@@ -1,9 +1,13 @@
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 { 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 type { LicenseFile } from "@/services/companies.service";
@@ -63,6 +67,8 @@ export interface RoleLicenseProfile {
reference: string;
/** License files already uploaded for this profile (rehydration). */
existingFiles: LicenseFile[];
/** The eTrade business already attached to this profile, if any. */
etradeBusiness?: ETradeBusinessOption | null;
}
interface RoleLicenseStepProps {
@@ -73,6 +79,8 @@ interface RoleLicenseStepProps {
onChange: (value: Record<string, File[]>) => void;
/** "Business license is required" style error, keyed by profile id. */
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,
onChange,
errors,
businessErrors,
}: RoleLicenseStepProps) {
const queryClient = useQueryClient();
const setFiles = (profileId: string, files: File[]) => {
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 (
<Stack gap="md">
<Text size="sm" c="edr-muted">
Upload the business license for each of your operational profiles. You
can attach more than one document per profile.
For each operational profile, say which of your eTrade business licences
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>
{profiles.map((profile) => {
@@ -104,7 +139,21 @@ export default function RoleLicenseStep({
const hasExisting = profile.existingFiles.length > 0;
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 && (
<Stack gap={4} mb="sm">
{profile.existingFiles.map((f) => (
@@ -142,7 +191,7 @@ export default function RoleLicenseStep({
setFiles(profile.id, files);
}}
/>
</>
</Fragment>
);
})}
</Stack>

View File

@@ -106,6 +106,9 @@ export const URL_CONSTANTS = {
ONBOARDING_REVERT_TO_ETRADE: "/api/companies/onboarding/revert-to-etrade",
DASHBOARD: "/api/companies/dashboard",
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`,
PROFILE_LICENSE: (profileId: string) =>
`/api/companies/company-profiles/${profileId}/license`,

View File

@@ -232,9 +232,17 @@ const useAuth = () => {
const createProfile = async (
type: ProfileTypeValue,
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>> => {
try {
const created = await api.companies.createCompanyProfile.call({ type });
const created = await api.companies.createCompanyProfile.call({
type,
licenceNumber,
});
if (licenseFiles.length > 0) {
await companiesService.uploadProfileLicense(created.id, licenseFiles);
}

View File

@@ -38,6 +38,9 @@ import {
useParams,
} from "react-router-dom";
import useAuth from "@/hooks/useAuth";
import EtradeBusinessSelect, {
useEtradeBusinesses,
} from "@/components/onboarding/EtradeBusinessSelect";
import {
CONTAINER_SIZES,
CONTRACT_STEPS,
@@ -417,6 +420,11 @@ export default function NewContractPage({
[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).
const [createTarget, setCreateTarget] = useState<ProfileTypeValue | null>(
null,
@@ -424,6 +432,9 @@ export default function NewContractPage({
const [pendingOperation, setPendingOperation] =
useState<OperationType | null>(null);
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);
// After a license is uploaded the new profile comes back "pending", so the
// create-profile modal switches to an "awaiting approval" success state.
@@ -437,11 +448,13 @@ export default function NewContractPage({
mutationFn: async ({
type,
files,
licenceNumber,
}: {
type: ProfileTypeValue;
files: File[];
licenceNumber?: string;
}) => {
const res = await auth.createProfile(type, files);
const res = await auth.createProfile(type, files, licenceNumber);
if (!res.success) {
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.");
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 = () => {
@@ -1235,6 +1258,13 @@ export default function NewContractPage({
Add your business license to create one. It goes to staff for
approval before you can use it.
</Text>
{createBusinessRequired && (
<EtradeBusinessSelect
value={createLicence}
onChange={setCreateLicence}
disabled={createProfileMutation.isPending}
/>
)}
<FileInput
label="Business license"
multiple

View File

@@ -6,10 +6,15 @@ import {
Card,
Group,
SimpleGrid,
Stack,
Text,
Title,
} from "@mantine/core";
import { api } from "@/services/api";
import EtradeBusinessSelect, {
businessLabel,
useEtradeBusinesses,
} from "@/components/onboarding/EtradeBusinessSelect";
import type { CompanyProfileResponse } from "@/services/companies.service";
import type { ProfileResponse } from "@/types/profile";
import RoleCard from "./RoleCard";
@@ -63,6 +68,16 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
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) => {
if (profileByType.has(type)) return; // add-only: existing roles are locked
setSelected((prev) => {
@@ -71,13 +86,40 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
else next.add(type);
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({
mutationFn: (types: string[]) =>
api.companies.addCompanyProfiles.call({ types }),
api.companies.addCompanyProfiles.call({
profiles: types.map((type) => ({
type,
licenceNumber: licenceByType[type],
})),
}),
onSuccess: () => {
setSelected(new Set());
setLicenceByType({});
queryClient.invalidateQueries({
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 = () => {
if (selected.size === 0) return;
if (selected.size === 0 || missingLicence) return;
mutation.mutate(Array.from(selected));
};
@@ -142,25 +190,53 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
lockedNote={view?.note}
lockedNoteColor={view?.color}
detail={
(rejected || existing?.status === "suspended") &&
existing?.reviewNote
? `Reviewer note: ${existing.reviewNote}`
existing
? [
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
}
action={
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>
existing ? (
<Stack gap="xs">
{businessRequired && (
<EtradeBusinessSelect
label="Operating as"
value={existing.etradeBusiness?.licenceNumber ?? null}
disabled={attachMutation.isPending}
onChange={(licenceNumber) =>
attachMutation.mutate({
profileId: existing.id,
licenceNumber,
})
}
/>
)}
{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
}
onClick={() => toggle(opt.type)}
@@ -170,6 +246,29 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
</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 && (
<Group
justify="space-between"
@@ -199,7 +298,7 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
type="button"
leftSection={<Save size={16} />}
loading={mutation.isPending}
disabled={selected.size === 0}
disabled={selected.size === 0 || missingLicence}
onClick={handleSave}
>
{selected.size > 1 ? "Add Roles" : "Add Role"}

View File

@@ -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 type {
CreateFileUploadFieldDto,
@@ -219,14 +219,13 @@ export const api = {
companiesService.getDashboard,
),
addCompanyProfiles: endpoint<{ types: string[] }, CompanyProfileResponse[]>(
"companies",
"addCompanyProfiles",
companiesService.addCompanyProfiles,
),
addCompanyProfiles: endpoint<
{ profiles: { type: string; licenceNumber?: string }[] },
CompanyProfileResponse[]
>("companies", "addCompanyProfiles", companiesService.addCompanyProfiles),
createCompanyProfile: endpoint<
{ type: ProfileTypeValue; businessLicense?: string },
{ type: ProfileTypeValue; businessLicense?: string; licenceNumber?: string },
CompanyProfileResponse
>(
"companies",
@@ -234,6 +233,21 @@ export const api = {
companiesService.createCompanyProfile,
),
listEtradeBusinesses: endpoint<void, ETradeBusinessOption[]>(
"companies",
"listEtradeBusinesses",
companiesService.listEtradeBusinesses,
),
attachEtradeBusiness: endpoint<
{ profileId: string; licenceNumber: string },
CompanyProfileResponse
>(
"companies",
"attachEtradeBusiness",
companiesService.attachEtradeBusiness,
),
startOnboarding: endpoint<
{
companyType: string;

View File

@@ -2,6 +2,7 @@ import { client } from "@/utils/api";
import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS";
import type { ApiResponse } from "@/types/apiResponse";
import type { ETradeBusinessOption } from "@edr/types";
import type { CompanyIdentityState } from "./verifayda.service";
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
import { isAxiosError } from "axios";
@@ -83,6 +84,11 @@ export interface CompanyProfileResponse {
/** Business-license documents uploaded for this profile. */
licenseFiles: LicenseFile[];
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). */
reviewNote?: string | null;
createdAt: string;
@@ -338,7 +344,7 @@ export const companiesService = {
},
addCompanyProfiles: async (payload: {
types: string[];
profiles: { type: string; licenceNumber?: string }[];
}): Promise<CompanyProfileResponse[]> => {
const response = await client.post<ApiResponse<CompanyProfileResponse[]>>(
URL_CONSTANTS.COMPANIES_API.COMPANY_PROFILES,
@@ -351,6 +357,7 @@ export const companiesService = {
createCompanyProfile: async (payload: {
type: ProfileTypeValue;
businessLicense?: string;
licenceNumber?: string;
}): Promise<CompanyProfileResponse> => {
const response = await client.post<ApiResponse<CompanyProfileResponse>>(
URL_CONSTANTS.COMPANIES_API.COMPANY_PROFILE,
@@ -359,6 +366,29 @@ export const companiesService = {
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. */
startOnboarding: async (payload: {
companyType: string;

View File

@@ -3,6 +3,7 @@ import { APP_FILTER } from "@nestjs/core";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { ScheduleModule } from "@nestjs/schedule";
import { EventEmitterModule } from "@nestjs/event-emitter";
import { ThrottlerModule } from "@nestjs/throttler";
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
import { IamBaselineSeeder, IamSeedModule } from "@edr/iam-seed";
import { IamModule as TriaIamModule } from "@tria-plc/iamapi-common/iam.module";
@@ -82,6 +83,17 @@ import { EOtpType } from "@tria-plc/iamapi-common";
}),
ScheduleModule.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({
inject: [ConfigService],
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.`,
[EOtpType.RESET_PASSWORD]: ({ route }) =>
`Reset your EDR Passenger password using this link: ${route}`,
[EOtpType.SET_PASSWORD]: ({ route }) =>
`Set your EDR Passenger password using this link: ${route}`,
// Carries the bare code as well as the link: the staged sign-in asks for the code
// 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

View 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}`;
}

View File

@@ -21,6 +21,7 @@ import {
ApiBearerAuth,
} from "@nestjs/swagger";
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 {
RegisterDto,
@@ -28,12 +29,19 @@ import {
ResendRegistrationCodeDto,
FaydaRequestPasswordSetupDto,
FaydaVerifyAndLoginDto,
IdentifierLookupDto,
PasswordSetupRequestDto,
PasswordSetupCompleteDto,
} from "./auth.dto";
import { JwtGuard } from "../../common/jwt.guard";
@ApiTags("Passenger 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 {
constructor(private passengerAuthService: PassengerAuthService) {}
@@ -189,6 +197,63 @@ export class AuthController {
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")
@IsPublic()
@HttpCode(HttpStatus.OK)

View File

@@ -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 { ApiProperty } from '@nestjs/swagger';
@@ -75,3 +82,55 @@ export class FaydaVerifyAndLoginDto {
@IsString()
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;
}

View File

@@ -1,4 +1,5 @@
import {
BadRequestException,
Injectable,
ConflictException,
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 { EOtpType } from '@tria-plc/iamapi-common/enums/otp.enum';
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 = {
id: string;
@@ -170,9 +186,19 @@ export class PassengerAuthService {
async login(dto: LoginDto, req: any) {
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 };
try {
iamResult = await iamAuthService.login({ email: dto.email, password: dto.password });
iamResult = await iamAuthService.login({
email: loginIdentifier,
password: dto.password,
});
} catch {
this.eventEmitter.emit('auth.login.failed', { email: dto.email });
throw new UnauthorizedException('Invalid credentials');
@@ -184,14 +210,16 @@ export class PassengerAuthService {
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
// with their phone). Match on either so the post-auth lookup works regardless of which
// identifier was used.
const iamRows = await this.dataSource.query<IamUserRow[]>(
`SELECT id, email, name, phone_number, metadata FROM iam.users WHERE email = $1 OR phone_number = $1 LIMIT 1`,
[dto.email],
);
const iamUser = iamRows[0];
// `known` is the same row the identifier resolved to; only fall back to a fresh lookup if
// the resolve missed but the IAM authenticated anyway.
let iamUser: { id: string; email: string | null } | null = known;
if (!iamUser) {
const iamRows = await this.dataSource.query<IamUserRow[]>(
`SELECT id, email, name, phone_number, metadata FROM iam.users WHERE email = $1 OR phone_number = $1 LIMIT 1`,
[loginIdentifier],
);
iamUser = iamRows[0] ?? null;
}
if (!iamUser) {
throw new InternalServerErrorException('IAM user not found after successful authentication');
}
@@ -496,19 +524,26 @@ export class PassengerAuthService {
}
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 passwordHash = await hashPassword(tempPassword);
// Deactivate existing credentials first (IAM keeps history, only one active at a time)
const passwordHash = await hashPassword(password);
await this.dataSource.query(
`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(
`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 }> {
@@ -554,15 +589,45 @@ export class PassengerAuthService {
if (!users.length) throw new UnauthorizedException('Invalid phone number or OTP');
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<{
id: string; verification_code: string; attempt_count: number;
}[]>(
`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`,
[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];
if (v.attempt_count >= 5) {
@@ -578,12 +643,24 @@ export class PassengerAuthService {
const { verifyPassword } = await import('@tria-plc/api-common/utils/argon');
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(
`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 = {
id: u.id,
email: u.email ?? '',
@@ -604,19 +681,183 @@ export class PassengerAuthService {
const sessions = await this.dataSource.query<{ id: string }[]>(
`INSERT INTO iam.sessions
(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
SET status = 'ACTIVE', "userInfo" = EXCLUDED."userInfo",
expiry_time = NOW() + INTERVAL '1 day', updated_at = NOW()
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 token = generateToken({ id: sessions[0].id });
const refreshToken = generateRefreshToken({ id: sessions[0].id });
return {
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 {

View File

@@ -13,6 +13,7 @@ import { CurrencyService } from '../currency/currency.service';
import { FareEngineService } from '../fare-engine/fare-engine.service';
import { PaymentsService } from '../payments/payments.service';
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 { JourneyDirection } from '../seats/seats.dto';
import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto';
@@ -46,39 +47,6 @@ function resolvePackageRoundTripTotal(
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 {
const today = new Date();
let age = today.getFullYear() - dateOfBirth.getFullYear();

View File

@@ -1,50 +1,295 @@
'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 { 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 { 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
// email sign in with their phone number, which is sent in the same `email` field —
// the IAM matches on either identifier.
email: z.string().min(1, 'Phone or email is required'),
password: z.string().min(6, 'Password must be at least 6 characters'),
});
/**
* Staged sign-in.
*
* The passenger gives one identifier — phone or email — and the server decides which of three
* things happens next. Previously this page asked for identifier *and* password up front and
* 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() {
const router = useRouter();
const searchParams = useSearchParams();
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 [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const { register, handleSubmit, formState: { errors } } = useForm<LoginForm>({
resolver: zodResolver(loginSchema as any),
});
// Step 1
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);
setError('');
try {
await login(data.email, data.password);
const redirect = searchParams.get('redirect') || '/booking/search';
router.push(redirect);
const res = await iamAuthApi.lookupIdentifier(identifier.trim());
const result = res.data.data;
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) {
setError(err.response?.data?.message || 'Login failed. Please check your credentials.');
setError(apiMessage(err, 'Something went wrong. Please try again.'));
} finally {
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 (
<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">
@@ -54,86 +299,227 @@ function LoginContent() {
<Train className="w-6 h-6 text-white" />
</div>
</div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">Sign in</h1>
<p className="text-gray-600 dark:text-gray-400 mt-2">Welcome back</p>
<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">{heading.subtitle}</p>
</div>
<div className="card">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{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">
{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>
)}
{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">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Password</label>
<div className="relative">
{step === 'identifier' && (
<form onSubmit={submitIdentifier} className="space-y-4">
<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
type={showPassword ? 'text' : 'password'}
{...register('password')}
className="input-field pr-10"
value={confirmPassword}
onChange={(e) => { setConfirmPassword(e.target.value); setError(''); }}
className="input-field"
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>
{errors.password && (
<p className="text-red-500 text-sm mt-1">{errors.password.message}</p>
)}
<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>
<button type="submit" className="btn-primary w-full" disabled={loading}>
{loading ? 'Setting password...' : 'Set password and sign in'}
</button>
<div className="text-center">
{resendNote ? (
<span className="text-sm text-gray-600 dark:text-gray-400">{resendNote}</span>
) : (
<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&apos;t get a code? Send it again
</button>
)}
</div>
</div>
</form>
)}
<button type="submit" className="btn-primary w-full" disabled={loading}>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
{step === 'signup' && (
<form onSubmit={submitSignup} className="space-y-4">
{identifierChip}
<p className="text-sm text-gray-600 dark:text-gray-400">
We couldn&apos;t find an account for that {identifierIsEmail ? 'email' : 'number'}, so
let&apos;s create one.
</p>
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700 space-y-3">
<div className="text-center">
<span className="text-sm text-gray-600 dark:text-gray-400">Don&apos;t have an account? </span>
<Link href="/register" className="text-sm font-medium text-[rgb(20_113_76)] dark:text-emerald-400 hover:underline">
Create account
</Link>
</div>
<Link
href="/fayda-setup"
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"
>
<ShieldCheck className="w-4 h-4" />
Already verified with Fayda? Set up your password
</Link>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
Full name
</label>
<input
type="text"
value={fullName}
onChange={(e) => { setFullName(e.target.value); setError(''); }}
className="input-field"
placeholder="e.g. Abebe Kebede"
autoComplete="name"
autoFocus
/>
</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
onClick={() => router.push('/booking/search')}
className="text-sm text-gray-600 dark:text-gray-400 hover:text-primary dark:hover:text-primary-400"

View File

@@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { Train, CheckCircle, ArrowLeft, ArrowRight } from 'lucide-react';
import { iamAuthApi } from '@/lib/api/auth';
import { isStrongPassword, PASSWORD_RULE } from '@/lib/password';
function ResetPasswordContent() {
const router = useRouter();
@@ -24,8 +25,8 @@ function ResetPasswordContent() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (newPassword.length < 6) {
setError('Password must be at least 6 characters.');
if (!isStrongPassword(newPassword)) {
setError(PASSWORD_RULE);
return;
}
if (newPassword !== confirmPassword) {

View File

@@ -6,18 +6,8 @@ import Link from 'next/link';
import { Train, ArrowLeft, ShieldCheck } from 'lucide-react';
import { iamAuthApi } from '@/lib/api/auth';
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() {
const searchParams = useSearchParams();

View File

@@ -192,18 +192,12 @@ export default function AppSidebar() {
)}
</div>
) : (
<div className="flex items-center gap-2 px-1 pt-1">
<div className="px-1 pt-1">
<Link
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
</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
Sign in or register
</Link>
</div>
)}

View File

@@ -4,6 +4,7 @@ import { useState } from 'react';
import { createPortal } from 'react-dom';
import { X, CheckCircle } from 'lucide-react';
import { iamAuthApi } from '@/lib/api/auth';
import { isStrongPassword, PASSWORD_RULE } from '@/lib/password';
interface ChangePasswordModalProps {
isOpen: boolean;
@@ -32,8 +33,8 @@ export default function ChangePasswordModal({ isOpen, onClose }: ChangePasswordM
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (newPassword.length < 6) {
setError('New password must be at least 6 characters.');
if (!isStrongPassword(newPassword)) {
setError(PASSWORD_RULE);
return;
}
if (newPassword !== confirmPassword) {

View File

@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { Train, ShieldCheck, CheckCircle, Info, ArrowLeft, ArrowRight } from 'lucide-react';
import { iamAuthApi } from '@/lib/api/auth';
import { isStrongPassword, PASSWORD_RULE } from '@/lib/password';
interface FaydaSetupWizardProps {
// 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) => {
e.preventDefault();
setError('');
if (newPassword.length < 6) {
setError('Password must be at least 6 characters.');
if (!isStrongPassword(newPassword)) {
setError(PASSWORD_RULE);
return;
}
if (newPassword !== confirmPassword) {

View File

@@ -36,6 +36,42 @@ export const iamAuthApi = {
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) =>
axios.post(`${API_URL}/auth/fayda/request-password-setup`, { phoneNumber }),

View File

@@ -113,13 +113,10 @@ export const useAuthStore = create<AuthState>((set, get) => ({
login: async (email: string, password: string) => {
const response: any = await apiClient.post('/auth/login', { email, password });
const { token, user } = response.data || response;
if (typeof window !== 'undefined') {
localStorage.setItem('auth_token', token);
localStorage.setItem('auth_user', JSON.stringify(user));
}
set({ user, token, isAuthenticated: true });
// `setUser` is the one place a session is persisted. The staged sign-in's
// password-setup branch establishes a session without going through /auth/login,
// so it calls the same action rather than duplicating the storage writes.
get().setUser(user, token);
},
register: async (data: RegisterData): Promise<RegisterResult> => {

View 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.';

View File

@@ -383,3 +383,19 @@ export function expectProfileActive(companyName: string, type = "importer") {
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();
}

View File

@@ -33,6 +33,7 @@ import {
vatNumber,
wizardClick,
SIGNUP_PASSWORD,
chooseRoleBusiness,
} from "./onboarding-utils";
const stamp = Date.now();
@@ -128,6 +129,7 @@ describe("onboarding — Ethiopian company, eTrade verified", { retries: 0 }, ()
cy.contains("Upload Importer Business license file(s)", {
timeout: 20000,
}).should("be.visible");
chooseRoleBusiness();
attachNextFile();
attachNextFile();
wizardClick("Submit for review");

View File

@@ -26,6 +26,7 @@ import {
signupIdentity,
wizardClick,
SIGNUP_PASSWORD,
chooseRoleBusiness,
} from "./onboarding-utils";
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)", {
timeout: 20000,
}).should("be.visible");
chooseRoleBusiness();
wizardClick("Submit for review");
cy.contains("You're all set", { timeout: 30000 }).should("be.visible");

View File

@@ -76,9 +76,14 @@ export interface ETradeBusinessOption {
export interface CompanyRegistrationData {
/**
* The registered organization name — `ETradeCompanyInfo.BusinessName`, falling
* back to the licence's `TradeName`. Never the manager/owner's personal name;
* that is {@link managerName}.
* The selected licence's trade name — `ETradeBusinessInfo.TradeName`, falling
* back to the registered organization name (`ETradeCompanyInfo.BusinessName`)
* 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;
licenceNumber: string;