diff --git a/.gitignore b/.gitignore index b9dc16c3c..18fdae9c2 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,6 @@ test-results/ playwright-report/ blob-report/ RUNNING_LOCALLY.md + +# Generated per-shard compose file for the integration suite (it.mjs). +integration/.it-shards.yaml diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 9338a958f..c579eb388 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -98,10 +98,10 @@ FAYDA_PRIVATE_KEY_BASE64= # OAuth redirect_uri for MOBILE clients (must be registered with eSignet) FAYDA_REDIRECT_URI=http://localhost:3001/api/fayda/verification/complete # OAuth redirect_uri for WEB clients. Defaults to FAYDA_REDIRECT_URI when unset. -FAYDA_WEB_REDIRECT_URI=http://localhost:3000/callback +FAYDA_WEB_REDIRECT_URI=http://localhost:3000/fayda/callback # OAuth redirect_uri for the customer portal (its own origin — must also be # registered with eSignet). Defaults to FAYDA_WEB_REDIRECT_URI when unset. -FAYDA_PORTAL_REDIRECT_URI=http://localhost:5173/callback +FAYDA_PORTAL_REDIRECT_URI=http://localhost:5173/fayda/callback CLIENT_ASSERTION_TYPE=urn:ietf:params:oauth:client-assertion-type:jwt-bearer FAYDA_SCOPE=openid profile email phone address FAYDA_ACR_VALUES=mosip:idp:acr:generated-code diff --git a/apps/edr-freight-api/docs/FREIGHT_MASTER_FLOW.md b/apps/edr-freight-api/docs/FREIGHT_MASTER_FLOW.md index ea68e6938..98657843c 100644 --- a/apps/edr-freight-api/docs/FREIGHT_MASTER_FLOW.md +++ b/apps/edr-freight-api/docs/FREIGHT_MASTER_FLOW.md @@ -21,7 +21,7 @@ flowchart TD S0(["Customer visits portal"]):::start S0 --> S1["Signup via IAM
GET /auth/check-availability @Public
POST /otp/send + /otp/verify (P)"]:::port S1 --> S2{"Identity proofing
(VeriFayda)?"}:::dec - S2 -->|"Yes"| S3["POST /fayda/verification/start →
/callback → /complete
upsert iam.users (verified_by=fayda) (P)"]:::port + S2 -->|"Yes"| S3["POST /fayda/verification/start →
/fayda/callback → /complete
upsert iam.users (verified_by=fayda) (P)"]:::port S2 -->|"No"| S4 S3 --> S4["POST /companies/onboarding/start
draft company (placeholder TIN, PENDING) (P)"]:::port S4 --> S4b["Wizard: PATCH /profile, /onboarding-step,
upload license + docs
GET /onboarding/requirements (P)"]:::port diff --git a/apps/edr-freight-api/docs/FREIGHT_SYSTEM_FLOW.md b/apps/edr-freight-api/docs/FREIGHT_SYSTEM_FLOW.md index a6fd8bbeb..b29763060 100644 --- a/apps/edr-freight-api/docs/FREIGHT_SYSTEM_FLOW.md +++ b/apps/edr-freight-api/docs/FREIGHT_SYSTEM_FLOW.md @@ -131,7 +131,7 @@ sequenceDiagram `HasActiveDelegationGuard` as **global `APP_GUARD`s** — *every* route is JWT-protected unless it carries `@Public()`. Fine-grained `FreightPermissionGuard([perm])` decorators add permission checks on staff routes. Explicitly **public** endpoints: `GET /api/files/:fileId`, `POST /api/otp/{send,verify}`, -`GET /api/auth/check-availability`, the `fayda/verification/*` + `/callback` endpoints, +`GET /api/auth/check-availability`, the `fayda/verification/*` + `/fayda/callback` endpoints, `GET /api/payments/{checkout,receipt/:orderId}`, and the service-to-service `POST /api/internal/payments/mark-paid`. Real login / JWT issuance lives in the **external IAM package**, not this repo. (Note: `@edr/api-common`'s `@Public` and `@tria-plc/api-common`'s `@IsPublic` both set the same `"isPublic"` metadata key the guard reads.) @@ -288,7 +288,7 @@ flowchart TD chk --> otp["POST /otp/send + /otp/verify (P) @Public"] otp --> fayda{"Identity proofing?"} fayda -->|"VeriFayda 2.0"| fstart["POST /fayda/verification/start
→ eSignet authorize URL"] - fstart --> fcb["Fayda redirect → GET /callback (ack)
→ GET /fayda/verification/complete
(PKCE code exchange → upsert iam.users)"] + fstart --> fcb["Fayda redirect → GET /fayda/callback (ack)
→ GET /fayda/verification/complete
(PKCE code exchange → upsert iam.users)"] fcb --> onb fayda -->|"skip"| onb @@ -313,7 +313,7 @@ drives the required document set. Booking guards elsewhere `403` if the acting p | POST | `/api/fayda/verification/start` | start eSignet session (PKCE) | `@Public` + OptionalJwt | (B) verifayda.service | | GET | `/api/fayda/verification/complete` | code→identity, upsert `iam.users` | `@Public` | (B) verifayda.service | | GET | `/api/fayda/verification/status` | current user's Fayda link | JwtGuard | — | -| GET | `/callback` | passive Fayda redirect ack (no `/api`) | `@Public` | popup postMessage | +| GET | `/fayda/callback` | passive Fayda redirect ack (no `/api`) | `@Public` | popup postMessage | | GET·PUT | `/api/me/signature` | reusable signature (MinIO, base64) | JwtGuard | (P)(B) signatures.service | | GET | `/api/test_user1` · `/api/test_user2` | permission-guard demo | `PermissionGuard` | (B) demo pages | | GET | `/api/companies/getInfo` · `/profile` · `/dashboard` | company info / KPIs | JwtGuard | (P) companies.service | diff --git a/apps/edr-freight-api/src/main.ts b/apps/edr-freight-api/src/main.ts index f13c596d2..a4fbefdd2 100644 --- a/apps/edr-freight-api/src/main.ts +++ b/apps/edr-freight-api/src/main.ts @@ -19,7 +19,7 @@ import { AppModule } from "./app.module"; * ~13.4MB on the wire. Express defaults to 100kb, which rejected any real stamp * image with a 413 "request entity too large". */ -const JSON_BODY_LIMIT = '20mb'; +const JSON_BODY_LIMIT = "20mb"; /** * Static /etc/hosts-style overrides from `DNS_HOST_OVERRIDES`, formatted as @@ -44,7 +44,9 @@ function applyDnsHostOverrides(): void { } if (overrides.size === 0) return; - const dns = createRequire(__filename)("node:dns") as typeof import("node:dns"); + const dns = createRequire(__filename)( + "node:dns", + ) as typeof import("node:dns"); const originalLookup = dns.lookup.bind(dns); // `dns.lookup` is overloaded (options optional, all/family variants); the // cast keeps that surface intact while we intercept only mapped hostnames. @@ -63,7 +65,9 @@ function applyDnsHostOverrides(): void { ) => void; const family = ip.includes(":") ? 6 : 4; const wantsAll = - typeof options === "object" && options !== null && (options as { all?: boolean }).all; + typeof options === "object" && + options !== null && + (options as { all?: boolean }).all; process.nextTick(() => wantsAll ? done(null, [{ address: ip, family }]) : done(null, ip, family), @@ -77,7 +81,15 @@ function applyDnsHostOverrides(): void { applyDnsHostOverrides(); -async function bootstrap() { +/** + * Build the app with every global the production process applies, but do NOT + * listen. Exported so a test harness can boot the REAL app in its own process + * (integration/src/app.ts) and get the same prefix, pipe, filter, interceptor + * and body-parser configuration — replaying this list by hand is how an e2e + * harness silently drifts from production (routes 404 without the "api" + * prefix, responses lose the transform envelope). + */ +export async function createFreightApp(): Promise { const app = await NestFactory.create(AppModule); // Nest's own body-parser API, NOT `app.use(json(...))` from express: express @@ -86,14 +98,14 @@ async function bootstrap() { // pnpm's hoisted dev store and died as MODULE_NOT_FOUND in the production // image, where `pnpm deploy --prod` installs declared dependencies only. // This also RECONFIGURES the default parsers rather than racing them. - app.useBodyParser('json', { limit: JSON_BODY_LIMIT }); - app.useBodyParser('urlencoded', { limit: JSON_BODY_LIMIT, extended: true }); + app.useBodyParser("json", { limit: JSON_BODY_LIMIT }); + app.useBodyParser("urlencoded", { limit: JSON_BODY_LIMIT, extended: true }); // Dev CORS: reflect any localhost origin and allow credentials so the // freight portal (5173), passenger portal (5174), backoffices (5183/5184) // and any other dev port can call the API with cookies + Authorization. // For production, restrict `origin` to known FQDNs. - + app.enableCors({ origin: true, // reflect request origin credentials: true, @@ -130,8 +142,11 @@ async function bootstrap() { maxAge: 86400, // cache preflight for 24h to cut chatter in dev }); - // /callback stays un-prefixed: it's the Fayda OAuth redirect_uri ack endpoint. - app.setGlobalPrefix("api", { exclude: ["callback"] }); + // /fayda/callback stays un-prefixed: it's the Fayda OAuth redirect_uri ack + // endpoint. Exact path, not "fayda" — exclusion is an exact route match, so + // "fayda" would leave /fayda/callback prefixed (404 at the registered + // redirect_uri) while still reading as if it covered the whole subtree. + app.setGlobalPrefix("api", { exclude: ["fayda/callback"] }); // enableImplicitConversion is OFF: class-transformer's implicit boolean // coercion turns any non-empty multipart/form-data string (including the // literal "false") into `true`, silently corrupting flags like isHazardous @@ -154,13 +169,21 @@ async function bootstrap() { const document = SwaggerModule.createDocument(app, config); SwaggerModule.setup("api/docs", app, document); + return app; +} + +async function bootstrap() { + const app = await createFreightApp(); const port = parseInt(process.env.PORT ?? "3001", 10); // await app.listen(port, "0.0.0.0"); - await app.listen( - - port) + await app.listen(port); // eslint-disable-next-line no-console console.log(`[freight-api] listening on port ${port}`); } -bootstrap(); +// Only self-start when this file IS the entrypoint. The Dockerfile's +// `CMD ["node", "dist/main.js"]` still boots; importers get `createFreightApp` +// without the process binding a port behind their back. +if (require.main === module) { + bootstrap(); +} diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 7665d82eb..1c9d75487 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -408,6 +408,30 @@ export class CompaniesController { return this.companiesService.completeIdentityVerification(user.id, dto); } + @Post("identity/gm/same-as-owner") + @ApiOperation({ + summary: + "Declare the General Manager is the company's owner, copying the owner's verified identity across. " + + "Refused until the owner is Fayda-verified — there would be nothing proven to copy.", + }) + async setGmSameAsOwner( + @CurrentUser() user: CurrentIamUser, + ): Promise { + return this.companiesService.setGmSameAsOwner(user.id); + } + + @Delete("identity/gm") + @ApiOperation({ + summary: + "Clear the General Manager's identity — the \"same as owner\" declaration or a verification, and the details either wrote. " + + "Leaves the GM open to be verified in their own right, or typed where Fayda is optional.", + }) + async clearGmIdentity( + @CurrentUser() user: CurrentIamUser, + ): Promise { + return this.companiesService.clearGmIdentity(user.id); + } + @Delete("identity/fayda/poa") @ApiOperation({ summary: diff --git a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts index 511c3aa25..32513df4f 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts @@ -21,9 +21,11 @@ import { POA_DELEGATION_FILE_KEY } from "../file-upload-settings/poa-delegation. * is named, both nationalities must verify them, and their details come from * the verified payload rather than the form. * - * The owner is NOT the general manager — GM is a separate, plain typed role - * the portal offers a "same as owner" copy for, but it is never itself - * Fayda-verified or gated on. + * The owner is NOT the general manager. The GM is proved the same way, by one + * of two routes — verifying in their own right, or being declared the owner, + * which reuses that verification rather than making one human prove themselves + * twice. It stays out of the trading gate either way: the GM names who to talk + * to, not what the company may do. */ interface Ctx { @@ -418,10 +420,12 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi ).resolves.toBeDefined(); }); - it("still requires a Fayda-verified PoA from a foreign company", async () => { - // The owner's credential is nationality-specific; the representative's is - // not. A PoA acts for the company inside Ethiopia whoever owns it, so a - // typed foreign name is not a representative the platform can accept. + it("accepts a typed PoA from a foreign company, whose representative may hold no Fayda ID", async () => { + // Fayda is an Ethiopian national ID, so only an Ethiopian company's + // representative can be held to it. A foreign company is offered the + // verification and uses it where its representative holds one, but a typed + // name stays sufficient — holding it to Fayda would leave a foreign + // company whose representative has no Fayda ID unable to trade at all. const { service } = makeService({ profileTypes: [ProfileType.importer], nationality: CompanyNationality.Foreign, @@ -434,6 +438,48 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi files: [paper()], }); + await expect( + service.createCompanyProfileForUser( + "user-1", + ProfileType.freightForwarder, + ), + ).resolves.toBeDefined(); + }); + + it("still refuses a foreign company that named no PoA at all", async () => { + // The typed fallback is a different credential, not a waiver: a freight + // forwarder acts on other companies' behalf and needs a representative + // whatever its nationality. + const { service } = makeService({ + profileTypes: [ProfileType.importer], + nationality: CompanyNationality.Foreign, + attributes: { ownerPassportNumber: "P1234567" }, + files: [paper()], + }); + + await expect( + service.createCompanyProfileForUser( + "user-1", + ProfileType.freightForwarder, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("holds an Ethiopian company to a Fayda-verified PoA, typed details notwithstanding", async () => { + // The relaxation above is scoped to foreign companies only — an Ethiopian + // representative holds a Fayda ID, so typing a name must not substitute. + const { service } = makeService({ + profileTypes: [ProfileType.importer], + nationality: CompanyNationality.Ethiopian, + attributes: { + ...OWNER_VERIFIED, + poaName: "Abebe Bekele", + poaEmail: "abebe@example.com", + poaPhone: "+251911000000", + }, + files: [paper()], + }); + await expect( service.createCompanyProfileForUser( "user-1", @@ -463,4 +509,119 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi ), ).rejects.toBeInstanceOf(BadRequestException); }); + + // ------------------------------------------------------------------------- + // General manager + // ------------------------------------------------------------------------- + + it("reuses the owner's verified identity when the GM is declared the same person", async () => { + // The GM is very often the owner. Copying the proven identity is the whole + // point — asking one human to complete two verifications proves nothing + // extra, and typing the details instead would forge a verified badge. + const { service, ctx } = makeService({ + attributes: { + ...OWNER_VERIFIED, + ownerEmail: "abebe@example.com", + ownerPhone: "+251911222333", + }, + }); + + const state = await service.setGmSameAsOwner("user-1"); + + expect(state.gm.verified).toBe(true); + expect(state.gmSameAsOwner).toBe(true); + expect(state.gm.name).toBe("Abebe Bikila"); + expect(ctx.attributes.gmFaydaSub).toBe("owner-sub"); + // The notifiers mail the flat column, so a linked GM has to land there too. + expect(ctx.attributes.generalManagerEmail).toBe("abebe@example.com"); + }); + + it("refuses to declare the GM is the owner while the owner is unverified", async () => { + // Without a verification there is no proven identity to copy — only typed + // text, which would arrive wearing a badge it had not earned. + const { service } = makeService({ attributes: {} }); + + await expect(service.setGmSameAsOwner("user-1")).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it("lets the GM verify as the same human as the owner", async () => { + // The owner/PoA collision check exists because self-delegation is not + // delegation. It must not fire here: the GM being the owner is a supported + // answer, so verifying with the owner's own Fayda sub has to succeed. + const { service, ctx } = makeService({ + attributes: { ...OWNER_VERIFIED }, + verification: { + purpose: "VERIFY", + verified: true, + sub: "owner-sub", + fullName: "Abebe Bikila", + email: "abebe@example.com", + phoneNumber: "+251911222333", + }, + }); + + const state = await service.completeIdentityVerification("user-1", { + subject: "gm", + code: "c", + state: "s", + }); + + expect(state.gm.verified).toBe(true); + expect(ctx.attributes.gmFaydaSub).toBe("owner-sub"); + expect(ctx.attributes.generalManagerName).toBe("Abebe Bikila"); + }); + + it("still refuses a PoA who is the owner", async () => { + // The GM exemption above must not have widened into the PoA. + const { service } = makeService({ + attributes: { ...OWNER_VERIFIED }, + verification: { + purpose: "VERIFY", + verified: true, + sub: "owner-sub", + fullName: "Abebe Bikila", + }, + }); + + await expect( + service.completeIdentityVerification("user-1", { + subject: "poa", + code: "c", + state: "s", + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("reports a pre-existing typed GM as unverified rather than blank", async () => { + // Companies onboarded before the GM was verifiable have typed details and + // no gm* attributes. Those details are still what the notifiers mail, so + // they must survive — flagged unverified so the portal offers the upgrade. + const { service, company } = makeService({ + attributes: { + ...OWNER_VERIFIED, + generalManagerName: "Legacy Manager", + generalManagerEmail: "legacy@example.com", + }, + }); + + const state = service.getCompanyIdentityState(company() as never); + + expect(state.gm.verified).toBe(false); + expect(state.gm.name).toBe("Legacy Manager"); + expect(state.gm.email).toBe("legacy@example.com"); + }); + + it("does not let an unproven GM block the company from trading", async () => { + // The GM names who to talk to, not what the company may do. Capturing it + // through Fayda changed how it is collected, not whether it gates. + const { service } = makeService({ + attributes: { ...OWNER_VERIFIED }, + }); + + await expect( + service.createCompanyProfileForUser("user-1", ProfileType.importer), + ).resolves.toBeDefined(); + }); }); diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index e0af07e0b..f21263c7f 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -33,6 +33,7 @@ import { buildCompanyIdentityState, CompanyIdentityStateDto, CompleteIdentityVerificationDto, + IDENTITY_SUBJECTS, IdentitySubject, } from "./dto/complete-identity-verification.dto"; import { ETradeService } from "./services/etrade.service"; @@ -122,31 +123,47 @@ const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [ /** * `attributes` key prefix per verifiable person. The owner is NOT the general - * manager — GM is a plain typed role (the portal offers a "same as owner" copy - * once the owner is verified), while the owner is who this verification - * actually proves. They're very often the same human; that's what the copy is - * for. + * manager: the owner is who the verification proves the company through, the + * GM is personnel it names. They're very often the same human, which is what + * the portal's "same as owner" copy is for. */ -const IDENTITY_PREFIX: Record = { +const IDENTITY_PREFIX: Record = { owner: "owner", poa: "poa", + gm: "gm", }; const IDENTITY_LABEL: Record = { owner: "owner", poa: "Power of Attorney", + gm: "General Manager", }; +/** + * Typed GM columns a GM verification also writes. Three notifier services mail + * `company.generalManagerEmail` directly, so leaving these behind would mean a + * verified GM whose address the system never actually uses. + */ +const GM_TYPED_FIELDS = [ + "generalManagerName", + "generalManagerEmail", + "generalManagerPhone", +] as const; + /** * Identity fields a Fayda verification owns outright, per person. Once verified * these can no longer be typed — the government IdP is the source, so an edit * that disagrees with it is either a mistake or an attempt to launder the - * guarantee away. The GM fields are deliberately absent: GM is never itself - * Fayda-verified, so it stays freely editable regardless of the owner's state. + * guarantee away. + * + * The GM's entries are its typed columns: a verified GM is locked the same way + * the others are, while an unverified one (a foreign company's, or a record + * that predates this) stays freely editable. */ const IDENTITY_OWNED_FIELDS: Record = { owner: ["ownerName", "ownerEmail", "ownerPhone", "ownerAddress"], poa: ["poaName", "poaEmail", "poaPhone", "poaAddress"], + gm: [...GM_TYPED_FIELDS], }; /** @@ -834,7 +851,7 @@ export class CompaniesService { // Renaming a Fayda-verified person by hand would launder the guarantee // away, so the fields the verification owns are refused once it exists. - for (const subject of ["owner", "poa"] as IdentitySubject[]) { + for (const subject of IDENTITY_SUBJECTS) { if (!attrUpdates[`${IDENTITY_PREFIX[subject]}FaydaSub`]) continue; for (const field of IDENTITY_OWNED_FIELDS[subject]) { const incoming = (dto as Record)[field]; @@ -2693,12 +2710,17 @@ export class CompaniesService { // The owner delegating power of attorney to themselves is not a // delegation — it would let one identity satisfy both halves of the check. - const other: IdentitySubject = dto.subject === "poa" ? "owner" : "poa"; - const otherSub = company.attributes?.[`${IDENTITY_PREFIX[other]}FaydaSub`]; - if (otherSub && otherSub === result.sub) { - throw new BadRequestException( - `This identity is already registered as the company's ${IDENTITY_LABEL[other]}. The Power of Attorney must be a different person from the owner.`, - ); + // Only owner/PoA collide this way: the GM is very often the owner, and + // saying so is a supported answer rather than a conflict, so it is left out + // of this check entirely. + if (dto.subject === "owner" || dto.subject === "poa") { + const other: IdentitySubject = dto.subject === "poa" ? "owner" : "poa"; + const otherSub = company.attributes?.[`${IDENTITY_PREFIX[other]}FaydaSub`]; + if (otherSub && otherSub === result.sub) { + throw new BadRequestException( + `This identity is already registered as the company's ${IDENTITY_LABEL[other]}. The Power of Attorney must be a different person from the owner.`, + ); + } } const now = new Date().toISOString(); @@ -2714,13 +2736,26 @@ export class CompaniesService { ...(result.address ? { [`${prefix}Address`]: result.address } : {}), }; + // A GM verification also lands on the typed columns the rest of the system + // already reads (the booking, train-scheduling and contract notifiers all + // mail `generalManagerEmail`), and clears any earlier "same as owner" + // declaration — verifying in their own right is the GM answering for + // themselves. + if (dto.subject === "gm") { + identity.gmSameAsOwner = false; + if (result.fullName) identity.generalManagerName = result.fullName; + if (result.email) identity.generalManagerEmail = result.email; + if (result.phoneNumber) + identity.generalManagerPhone = normalizeE164(result.phoneNumber); + } + // An approved company's *owner* is its identity proof, so re-verifying one // is staged for backoffice review rather than quietly rewriting a live - // record. The PoA is personnel — the company names its own representative, - // and the delegation letter backing them is what the reviewer sees — so a - // PoA verification lands live, matching the typed PoA fields in - // `SELF_SERVICE_ATTRIBUTES`. - if (company.status === CompanyStatus.Active && dto.subject !== "poa") { + // record. The PoA and GM are personnel — the company names its own + // representative and manager, and the delegation letter backing the PoA is + // what the reviewer sees — so those land live, matching their typed + // counterparts in `SELF_SERVICE_ATTRIBUTES`. + if (company.status === CompanyStatus.Active && dto.subject === "owner") { await this.stageIdentityChange(company, userId, identity); return this.getCompanyIdentityState(company); } @@ -2734,6 +2769,85 @@ export class CompaniesService { return this.getCompanyIdentityState(updated); } + /** + * Declare that the General Manager is the company's owner. + * + * The GM is very often the owner, and making that human verify twice buys + * nothing — the owner's verification already proves them. So this copies the + * owner's verified identity across rather than starting a second flow, and + * records `gmSameAsOwner` so the portal can show it as a declaration rather + * than as a verification the GM passed in their own right. + * + * Refused until the owner is actually verified: without that there is no + * proven identity to copy, only typed text that would arrive wearing a + * verified badge. + */ + async setGmSameAsOwner(userId: string): Promise { + const { company } = await this.getCompanyInfoByUserId(userId); + const attrs = company.attributes ?? {}; + const ownerSub = attrs.ownerFaydaSub as string | undefined; + if (!ownerSub) { + throw new BadRequestException( + "Verify the company owner with Fayda first — there is no proven identity to reuse yet.", + ); + } + + const copied: Record = { + gmSameAsOwner: true, + gmFaydaSub: ownerSub, + gmFaydaVerifiedAt: attrs.ownerFaydaVerifiedAt ?? new Date().toISOString(), + gmName: attrs.ownerName ?? null, + gmEmail: attrs.ownerEmail ?? null, + gmPhone: attrs.ownerPhone ?? null, + gmAddress: attrs.ownerAddress ?? null, + gmBirthdate: attrs.ownerBirthdate ?? null, + gmGender: attrs.ownerGender ?? null, + // Kept in step for the notifiers, same as a GM verification does. + generalManagerName: attrs.ownerName ?? null, + generalManagerEmail: attrs.ownerEmail ?? null, + generalManagerPhone: attrs.ownerPhone ?? null, + }; + + const updated = await this.companiesRepo.update(company.id, { + attributes: { ...attrs, ...copied }, + }); + if (!updated) + throw new NotFoundException(`Company ${company.id} not found`); + updated.companyProfiles = company.companyProfiles; + return this.getCompanyIdentityState(updated); + } + + /** + * Undo the "same as owner" declaration, clearing the copied identity so the + * GM can be verified in their own right (or typed, where Fayda is optional). + */ + async clearGmIdentity(userId: string): Promise { + const { company } = await this.getCompanyInfoByUserId(userId); + const attrs = { ...(company.attributes ?? {}) }; + for (const key of [ + "gmSameAsOwner", + "gmFaydaSub", + "gmFaydaVerifiedAt", + "gmName", + "gmEmail", + "gmPhone", + "gmAddress", + "gmBirthdate", + "gmGender", + ...GM_TYPED_FIELDS, + ]) { + attrs[key] = null; + } + + const updated = await this.companiesRepo.update(company.id, { + attributes: attrs, + }); + if (!updated) + throw new NotFoundException(`Company ${company.id} not found`); + updated.companyProfiles = company.companyProfiles; + return this.getCompanyIdentityState(updated); + } + /** * Drop the Power of Attorney entirely — the verified identity, the details it * wrote and the delegation paper together. @@ -2864,14 +2978,28 @@ export class CompaniesService { ); } - // The representative is not. A PoA acts for the company inside Ethiopia - // whoever owns it, so they are always an Ethiopian holding a Fayda ID — - // a foreign company nominates one rather than typing a name. const poaNamed = POA_ATTRIBUTES.some((k) => (company.attributes?.[k] as string | undefined)?.trim(), ); if (!opts.requirePoa && !poaNamed) return; + // Fayda is an Ethiopian national ID, so only an Ethiopian company's + // representative can be held to it. A foreign company is offered the + // verification and nominates a Fayda-holding representative where it can, + // but a typed name has to remain sufficient — otherwise a foreign company + // whose representative holds no Fayda ID could never trade at all. Mirrors + // `poaProven` in buildCompanyIdentityState; the two must agree. + if (state.passportRequired) { + if (!state.poa.verified && !state.poa.name?.trim()) { + throw new BadRequestException( + opts.requirePoa + ? "Name your Power of Attorney — a freight forwarder cannot operate without one." + : "Complete the Power of Attorney you named, or remove the representative.", + ); + } + return; + } + if (!state.poa.verified) { throw new BadRequestException( opts.requirePoa diff --git a/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts index a9988cd28..d41e61e8e 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts @@ -5,13 +5,15 @@ import { Company, CompanyNationality } from "../entities/company.entity"; import { ProfileType } from "../entities/company-profile.entity"; /** - * The two people a company is verified through — its owner and its Power of - * Attorney. "Owner" is not the same as the General Manager: a company's GM is - * a plain typed role (with a "same as owner" copy the portal offers), while - * the owner is the person this verification proves. They're very often the - * same human, which is exactly what the copy is for. + * The three people a company is verified through — its owner, its Power of + * Attorney and its General Manager. The owner is the person the company's + * existence is proven by; the other two are personnel it names. + * + * The GM is very often the owner, which is what the portal's "same as owner" + * copy is for: that path reuses the owner's verified identity outright rather + * than asking the same human to verify twice. */ -export const IDENTITY_SUBJECTS = ["owner", "poa"] as const; +export const IDENTITY_SUBJECTS = ["owner", "poa", "gm"] as const; export type IdentitySubject = (typeof IDENTITY_SUBJECTS)[number]; export class CompleteIdentityVerificationDto { @@ -73,6 +75,19 @@ export class CompanyIdentityStateDto { @ApiProperty({ type: IdentityVerificationStateDto }) poa!: IdentityVerificationStateDto; + @ApiProperty({ + type: IdentityVerificationStateDto, + description: + "General manager. `verified` is true both when the GM verified with Fayda in their own right and when the company declared the GM is the owner — in the latter case the owner's Fayda sub backs it.", + }) + gm!: IdentityVerificationStateDto; + + @ApiProperty({ + description: + "True when the GM's identity is the owner's, declared through the portal's \"same as owner\" copy rather than a separate verification.", + }) + gmSameAsOwner!: boolean; + @ApiProperty({ description: "False while a mandatory requirement (Fayda for Ethiopian, passport for foreign) is still outstanding.", @@ -81,11 +96,28 @@ export class CompanyIdentityStateDto { } /** `attributes` key prefix per person. */ -const PREFIX: Record = { +const PREFIX: Record = { owner: "owner", poa: "poa", + gm: "gm", }; +/** + * Typed GM fields, kept in step with the Fayda-written ones. + * + * The GM predates this verification: its details are plain company columns + * that three notifier services mail (booking-lifecycle, train-scheduling and + * contract notifiers all read `company.generalManagerEmail`). A verification + * therefore writes BOTH — the `gm*` attributes carry the proof, these carry + * the value everything else already reads — and an unverified company keeps + * showing whatever was typed before this existed. + */ +const GM_TYPED_KEYS = { + name: "generalManagerName", + email: "generalManagerEmail", + phone: "generalManagerPhone", +} as const; + /** company.attributes keys that together mean "a PoA was entered". */ const POA_KEYS = [ "poaName", @@ -101,7 +133,7 @@ function stateFor( ): IdentityVerificationStateDto { const p = PREFIX[subject]; const read = (key: string) => (attrs[key] as string | undefined) ?? null; - return { + const state: IdentityVerificationStateDto = { verified: Boolean(read(`${p}FaydaSub`)), name: read(`${p}Name`), phone: read(`${p}Phone`), @@ -111,6 +143,18 @@ function stateFor( birthdate: read(`${p}Birthdate`), gender: read(`${p}Gender`), }; + if (subject !== "gm") return state; + + // Companies onboarded before the GM was verifiable have typed details and no + // `gm*` attributes at all. Report those rather than a blank card — they are + // still what the notifiers mail — leaving `verified` false so the portal + // offers the upgrade instead of pretending the identity is proven. + return { + ...state, + name: state.name ?? read(GM_TYPED_KEYS.name), + email: state.email ?? read(GM_TYPED_KEYS.email), + phone: state.phone ?? read(GM_TYPED_KEYS.phone), + }; } /** @@ -144,14 +188,34 @@ export function buildCompanyIdentityState( (p) => p.type === ProfileType.freightForwarder, ) || POA_KEYS.some((k) => (attrs[k] as string | undefined)?.trim()); - // Only the *owner's* credential is nationality-specific. A Power of Attorney - // acts for the company inside Ethiopia whoever owns it, so the PoA is always - // proven with Fayda — a foreign company nominates a representative who holds - // one rather than typing a name nothing backs. + const gm = stateFor(attrs, "gm"); + const gmSameAsOwner = Boolean(attrs.gmSameAsOwner); + const ownerProven = faydaRequired ? owner.verified : !passportRequired || Boolean(owner.passportNumber); - const complete = ownerProven && (!poaDue || poa.verified); - return { faydaRequired, passportRequired, owner, poa, complete }; + // Fayda is an Ethiopian national ID, so only an Ethiopian company's + // personnel can be held to it. A foreign company may nominate a + // representative who holds one — and is offered the verification — but a + // typed name has to remain sufficient, or a foreign company whose PoA has no + // Fayda ID could never finish onboarding. + const poaProven = faydaRequired + ? poa.verified + : poa.verified || Boolean(poa.name?.trim()); + + // The GM is deliberately absent from this verdict: it names who to talk to, + // not what the company may do, and it has never gated trading. Capturing it + // through Fayda changes how it is collected, not whether it is required. + const complete = ownerProven && (!poaDue || poaProven); + + return { + faydaRequired, + passportRequired, + owner, + poa, + gm, + gmSameAsOwner, + complete, + }; } diff --git a/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts b/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts index cddc67b2d..ac021c322 100644 --- a/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts +++ b/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts @@ -1,62 +1,22 @@ import { Injectable, Logger } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import axios, { isAxiosError } from "axios"; import { NotificationStrategy } from "./notification.strategy"; +import { SmsClientService } from "../sms-client.service"; @Injectable() export class SmsNotificationStrategy implements NotificationStrategy { private readonly logger = new Logger(SmsNotificationStrategy.name); - constructor(private readonly configService: ConfigService) {} + constructor(private readonly smsClient: SmsClientService) {} async send(recipient: string, message: string): Promise { - const url = - this.configService.get("OZIKING_SMS_URL") ?? - "https://notification-dev.license.aafda.gov.et/api/sms-services/ozeking/sms"; - - const appKey = this.configService.get("OZIKING_APP_KEY") ?? ""; - if (!appKey) { - this.logger.warn("OZIKING_APP_KEY is not set — SMS may be rejected by the API"); - } - - this.logger.debug(`Sending SMS to ${recipient} via ${url}`); - - // axios defaults to no timeout — a hanging gateway would block the caller - // (and any transaction it sits in) indefinitely. Always bound the wait. - const timeout = Number(this.configService.get("SMS_TIMEOUT_MS") ?? 8000); - - try { - const response = await axios.post( - url, - { - to: recipient, - sourceId: this.configService.get("OZIKING_SOURCE_ID") ?? "EDR", - sourceName: this.configService.get("OZIKING_SOURCE_NAME") ?? "EDR Freight", - appKey, - text: message, - callbackUrl: "", - }, - { - timeout, - headers: { - accept: "*/*", - "Content-Type": "application/json", - }, - }, - ); - - this.logger.debug(`SMS API response: ${response.status} ${JSON.stringify(response.data)}`); - return true; - } catch (err) { - if (isAxiosError(err)) { - this.logger.error( - `SMS API error: ${err.message} | status=${err.response?.status} | body=${JSON.stringify(err.response?.data)}`, - ); - } else { - this.logger.error(`SMS send failed: ${String(err)}`); - } - throw err; + const { queued } = await this.smsClient.sendSms({ + to: recipient, + message, + }); + if (!queued) { + this.logger.error(`SMS to ${recipient} was not queued to RabbitMQ`); } + return queued; } } diff --git a/apps/edr-freight-api/src/modules/otp/otp.service.ts b/apps/edr-freight-api/src/modules/otp/otp.service.ts index d5688e1c2..a7361fbdd 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.service.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.service.ts @@ -265,10 +265,10 @@ export class OtpService { /** * SMS half of {@link dispatchEmail}; same swallow-and-report contract. Sent - * via NotificationsService's direct-HTTP Ozeking strategy — the same - * transport the notification system uses — rather than the RabbitMQ - * `SMS_SERVICE` queue, so `queued: true` here means the gateway accepted the - * request, not just that a broker took ownership of the message. + * via NotificationsService's `directSend`, which now routes through the + * same RabbitMQ `SMS_SERVICE` queue as every other SMS in freight-api, so + * `queued: true` here means the broker confirmed ownership of the message, + * not that the carrier delivered it. */ private async dispatchSms( phone: string, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index 6cdef8c05..c60dd14a6 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -91,7 +91,16 @@ export class BookingWindowService implements OnModuleInit { // 10-second cadence: every transition is derived from persisted timestamps // and applied idempotently, so a finer tick only shrinks the lag between a // deadline passing and the phase actually moving (was a full minute). - @Cron('*/10 * * * * *', { name: 'booking-window-tick', timeZone: BATCH_TIMEZONE }) + // + // Overridable because that lag is the integration suite's pacing floor: every + // window phase, wagon allocation and expiry it waits on lands on this tick, so + // a 10s cadence costs ~5s of pure latency per wait across a few hundred waits. + // The suite runs it at `*/1 * * * * *`. Read at class-definition time, so the + // env var must be set before the module is imported. + @Cron(process.env.BOOKING_WINDOW_TICK_CRON ?? '*/10 * * * * *', { + name: 'booking-window-tick', + timeZone: BATCH_TIMEZONE, + }) async tick(): Promise { if (this.ticking) return; this.ticking = true; diff --git a/apps/edr-freight-api/src/modules/verifayda/fayda-callback.controller.ts b/apps/edr-freight-api/src/modules/verifayda/fayda-callback.controller.ts index 1c5569750..7f19eb313 100644 --- a/apps/edr-freight-api/src/modules/verifayda/fayda-callback.controller.ts +++ b/apps/edr-freight-api/src/modules/verifayda/fayda-callback.controller.ts @@ -6,12 +6,14 @@ import { VerifaydaCallbackDto } from './verifayda.dto'; /** * Plain acknowledgement endpoint for the Fayda redirect_uri when it points at * the API instead of the web app (e.g. MOBILE clients or connectivity checks). - * Registered at /callback (excluded from the global /api prefix in main.ts). + * Registered at /fayda/callback (excluded by exact path from the global /api + * prefix in main.ts — the exclusion must NOT be widened to "fayda", or + * /api/fayda/verification/* loses its prefix too). * It does NOT consume the verification session — the client must still call * GET /api/fayda/verification/complete with the echoed code+state. */ @ApiTags('Fayda Verification') -@Controller('callback') +@Controller('fayda/callback') export class FaydaCallbackController { @Get() @IsPublic() diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 4dc1fb9e9..51fcd7e42 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -292,7 +292,10 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Locomotives", href: "/dashboard/locomotives", icon: , - permission: [FREIGHT_PERMS.locomotives.view, FREIGHT_PERMS.fleet.view], + permission: [ + FREIGHT_PERMS.locomotives.view, + FREIGHT_PERMS.fleet.view, + ], }, { label: "Train Builder", @@ -818,7 +821,7 @@ const App = () => { {UserManagementRoutes()} {/* } /> */} } /> - } /> + } /> } /> { } /> } /> } /> - } /> + } + /> } /> } /> } /> @@ -1173,7 +1179,9 @@ const App = () => { + } @@ -1181,7 +1189,12 @@ const App = () => { + } @@ -1189,7 +1202,9 @@ const App = () => { + } @@ -1197,7 +1212,9 @@ const App = () => { + } @@ -1205,7 +1222,9 @@ const App = () => { + } @@ -1213,7 +1232,9 @@ const App = () => { + } @@ -1221,7 +1242,9 @@ const App = () => { + } @@ -1242,7 +1265,12 @@ const App = () => { + } @@ -1250,7 +1278,12 @@ const App = () => { + } @@ -1336,7 +1369,9 @@ const App = () => { + } @@ -1424,7 +1459,12 @@ const App = () => { + } @@ -1432,7 +1472,9 @@ const App = () => { + } @@ -1440,7 +1482,9 @@ const App = () => { + } @@ -1448,7 +1492,9 @@ const App = () => { + } @@ -1456,7 +1502,9 @@ const App = () => { + } @@ -1464,7 +1512,9 @@ const App = () => { + } @@ -1485,7 +1535,12 @@ const App = () => { + } @@ -1493,7 +1548,12 @@ const App = () => { + } diff --git a/apps/edr-freight-web/backoffice/src/complaints/utils/complaintVerificationStorage.ts b/apps/edr-freight-web/backoffice/src/complaints/utils/complaintVerificationStorage.ts index f7a500cfc..44441a26f 100644 --- a/apps/edr-freight-web/backoffice/src/complaints/utils/complaintVerificationStorage.ts +++ b/apps/edr-freight-web/backoffice/src/complaints/utils/complaintVerificationStorage.ts @@ -47,7 +47,7 @@ export function isComplaintAuthContext(pathname = ""): boolean { pathname.startsWith("/complaints") || pathname === "/complaint-form" || pathname === "/follow-complaint" || - pathname === "/callback" + pathname === "/fayda/callback" ); } diff --git a/apps/edr-freight-web/backoffice/src/components/errors/ApiErrorModal.tsx b/apps/edr-freight-web/backoffice/src/components/errors/ApiErrorModal.tsx index 07c9a39d1..4f8b0186a 100644 --- a/apps/edr-freight-web/backoffice/src/components/errors/ApiErrorModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/errors/ApiErrorModal.tsx @@ -28,7 +28,7 @@ let listener: Listener | null = null; /** Current-page path patterns where the global modal must stay silent. */ const EXCLUDED_PATH_PATTERNS = [ /^\/auth/, - /^\/callback/, + /^\/fayda/, /warehouse/i, /first-mile/i, /last-mile/i, diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx index f0ccf0be0..b43c03f18 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx @@ -148,7 +148,7 @@ const FleetFormDialog = ({ }); }, [open, fields]); - // Receive the ?code&state relayed by the /callback popup, exchange it for + // Receive the ?code&state relayed by the /fayda/callback popup, exchange it for // the verified identity, and prefill the matching form fields. useEffect(() => { if (!open || !verifyWithFayda) return; diff --git a/apps/edr-freight-web/backoffice/src/pages/FaydaCallbackPage.tsx b/apps/edr-freight-web/backoffice/src/pages/FaydaCallbackPage.tsx index 5febb664a..48b9d32ce 100644 --- a/apps/edr-freight-web/backoffice/src/pages/FaydaCallbackPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/FaydaCallbackPage.tsx @@ -5,7 +5,7 @@ import type { FaydaCallbackMessage } from "@/services/verifayda.service"; /** * Landing page for the eSignet redirect_uri (FAYDA_WEB_REDIRECT_URI → - * http://localhost:5183/callback). Runs inside the verification popup: + * http://localhost:5183/fayda/callback). Runs inside the verification popup: * relays ?code&state (or ?error) to the window that opened it via * postMessage, then closes itself. The opener performs the /complete call * so the single-use session is only consumed once, in one place. diff --git a/apps/edr-freight-web/backoffice/src/services/verifayda.service.ts b/apps/edr-freight-web/backoffice/src/services/verifayda.service.ts index d02394278..064a4c78f 100644 --- a/apps/edr-freight-web/backoffice/src/services/verifayda.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/verifayda.service.ts @@ -17,7 +17,7 @@ export interface FaydaCompleteResult { userDataSaved?: boolean; } -/** Message posted from the /callback popup back to the opener window. */ +/** Message posted from the /fayda/callback popup back to the opener window. */ export interface FaydaCallbackMessage { type: 'fayda-callback'; code?: string; diff --git a/apps/edr-freight-web/backoffice/src/shared/components/FaydaCallbackDispatcher.tsx b/apps/edr-freight-web/backoffice/src/shared/components/FaydaCallbackDispatcher.tsx index 8f41a4b98..b4a14fddd 100644 --- a/apps/edr-freight-web/backoffice/src/shared/components/FaydaCallbackDispatcher.tsx +++ b/apps/edr-freight-web/backoffice/src/shared/components/FaydaCallbackDispatcher.tsx @@ -5,7 +5,7 @@ import ExternalPortalCallback from "@/external-portal/components/Registration/Ex /** * Single FAYDA OIDC callback entry point. - * Fayda only allows whitelisted redirect URIs (e.g. /callback) — route + * Fayda only allows whitelisted redirect URIs (e.g. /fayda/callback) — route * internally based on the `state` param sent during authorization. */ export default function FaydaCallbackDispatcher() { diff --git a/apps/edr-freight-web/backoffice/src/shared/services/sessionExpiry.ts b/apps/edr-freight-web/backoffice/src/shared/services/sessionExpiry.ts index e3da80b9c..af3c39dd5 100644 --- a/apps/edr-freight-web/backoffice/src/shared/services/sessionExpiry.ts +++ b/apps/edr-freight-web/backoffice/src/shared/services/sessionExpiry.ts @@ -11,7 +11,7 @@ const PUBLIC_PATHS = [ "/set-password", "/verify-otp", "/verification_page", - "/callback", + "/fayda/callback", "/complaints", "/complaint-form", "/follow-complaint", diff --git a/apps/edr-freight-web/backoffice/src/shared/utils/faydaOidc.ts b/apps/edr-freight-web/backoffice/src/shared/utils/faydaOidc.ts index 3f4693b2b..9d8376ec1 100644 --- a/apps/edr-freight-web/backoffice/src/shared/utils/faydaOidc.ts +++ b/apps/edr-freight-web/backoffice/src/shared/utils/faydaOidc.ts @@ -12,7 +12,7 @@ const DEFAULT_CODE_CHALLENGE = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"; const DEFAULT_NONCE = "g4DEuje5Fx57Vb64dO4oqLHXGT8L8G7g"; const DEFAULT_STATE = "ptOO76SD"; -/** OIDC state value that routes the shared /callback to the complaint flow (legacy sign-in). */ +/** OIDC state value that routes the shared /fayda/callback to the complaint flow (legacy sign-in). */ export const COMPLAINT_FLOW_STATE = "complaint_flow"; /** Complaint flow OIDC states — distinguish sign-in vs sign-up endpoints. */ @@ -66,9 +66,7 @@ export function startExternalPortalFaydaAuth(): void { export function generateFaydaAuthorizationUrl( options: FaydaOidcOptions = {}, ): string { - const redirectUri = - options.redirectUri || - getDefaultFaydaRedirectUri(); + const redirectUri = options.redirectUri || getDefaultFaydaRedirectUri(); const params = new URLSearchParams({ client_id: import.meta.env.VITE_CLIENT_ID || "", @@ -98,7 +96,7 @@ export function generateFaydaAuthorizationUrl( export function getDefaultFaydaRedirectUri(): string { return ( import.meta.env.VITE_REDIRECT_URI || - `${window.location.origin}/callback` + `${window.location.origin}/fayda/callback` ); } @@ -106,13 +104,12 @@ export function getDefaultFaydaRedirectUri(): string { * Returns the redirect URI registered with FAYDA for the complaint flow. * * Must exactly match a URI whitelisted in the FAYDA OIDC client — we reuse - * the same /callback path as external-portal registration and distinguish + * the same /fayda/callback path as external-portal registration and distinguish * flows via the `state` parameter (see COMPLAINT_FLOW_STATE). */ export function getComplaintFaydaRedirectUri(): string { return ( - import.meta.env.VITE_COMPLAINT_REDIRECT_URI || - getDefaultFaydaRedirectUri() + import.meta.env.VITE_COMPLAINT_REDIRECT_URI || getDefaultFaydaRedirectUri() ); } diff --git a/apps/edr-freight-web/portal/package.json b/apps/edr-freight-web/portal/package.json index 9582058ef..f3b493aca 100644 --- a/apps/edr-freight-web/portal/package.json +++ b/apps/edr-freight-web/portal/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "vite --port 5173 --clearScreen false", + "dev": "vite --port 3000 --clearScreen false", "build": "tsc -b && vite build", "preview": "vite preview --port 5173", "lint": "eslint src", diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 2f661af75..48cfe5203 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -253,113 +253,116 @@ const App = () => { {/* Global API error modal — shows the server's actual error message for every failed request (suppressed on onboarding/auth pages). */} - - {/* Public routes */} - } /> - } /> - } - /> - {/* Payment provider browser redirects (PAYMENT_RETURN_URL / PAYMENT_FAILURE_URL) */} - {/* Fayda (eSignet) redirect_uri — runs in the verification popup and - relays the code/state back to the form that opened it. */} - } /> - } /> - } /> + + {/* Public routes */} + } /> + } /> + } + /> + {/* Payment provider browser redirects (PAYMENT_RETURN_URL / PAYMENT_FAILURE_URL) */} + {/* Fayda (eSignet) redirect_uri — the whole tab lands here after + verification, completes the code/state exchange and navigates back + to the page that started it. Public on purpose: behind RequireAuth + the onboarding gate would redirect away before the exchange ran. */} + } /> + } /> + } /> + } /> - {/* Auth pages — inaccessible once logged in */} - }> - } /> - } /> - } /> - + {/* Auth pages — inaccessible once logged in */} + }> + } /> + } /> + } /> + - {/* Staff-issued reset links land here. Deliberately outside + {/* Staff-issued reset links land here. Deliberately outside RedirectIfAuthed: a customer with a stale session still needs the link to work, and the token — not the session — is what authorises it. */} - } /> + } /> - {/* Signup-flow pages; reached while a session already exists */} - } /> - } /> + {/* Signup-flow pages; reached while a session already exists */} + } /> + } /> - }> - }> - - - - } - > - } /> - {/* Bookings are created against a contract, but the full list is + }> + }> + + + + } + > + } /> + {/* Bookings are created against a contract, but the full list is browsable here. New-booking entry still routes via a contract. */} - } /> - } - /> - } /> - } /> - } - /> - } /> - } /> - } - /> - } - /> - } - /> - {/* Completion of an initiated (bare) booking after per-booking + } /> + } + /> + } /> + } /> + } + /> + } /> + } /> + } + /> + } + /> + } + /> + {/* Completion of an initiated (bare) booking after per-booking clearance — same form, submits to the complete endpoint. */} - } - /> - } - /> - } /> - } /> - } /> - } /> - {/* Profile was merged into Settings — keep old links working. */} - } - /> - } /> - } /> + } + /> + } + /> + } /> + } /> + } /> + } /> + {/* Profile was merged into Settings — keep old links working. */} + } + /> + } /> + } /> + - - } /> - + } /> + ); }; diff --git a/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx b/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx index b07d7352c..4e6921e14 100644 --- a/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx +++ b/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, type ReactNode } from "react"; +import { useState, type ReactNode } from "react"; import { Alert, Avatar, @@ -20,9 +20,8 @@ import { } from "lucide-react"; import { + stashPendingVerification, verifaydaService, - type CompanyIdentityState, - type FaydaCallbackMessage, type IdentitySubject, type IdentityVerificationState, } from "@/services/verifayda.service"; @@ -38,8 +37,6 @@ interface FaydaVerifyPanelProps { * on it, so the panel says so rather than nagging. */ required: boolean; - /** Called with the fresh company-wide state once a verification lands. */ - onVerified: (next: CompanyIdentityState) => void; disabled?: boolean; /** * True when a fresh verification for this person is already staged in a @@ -61,115 +58,45 @@ function getInitials(name: string | null): string { /** * Verify one of the company's people through Fayda and show what came back. * - * The identity is proved in an eSignet popup; that popup lands on /callback, - * which relays the code+state here by postMessage. This window then completes - * the exchange — once, in one place — and the API writes the person's name, - * phone, email and address from the verified payload. Nothing on this panel - * is typed. + * The identity is proved on eSignet, which the whole tab navigates to — no + * popup, because a popup opened after the /start round-trip has lost its user + * activation and iOS Safari blocks it outright. eSignet redirects back to + * /fayda/callback, which completes the exchange and returns the user here; the API + * writes the person's name, phone, email and address from the verified + * payload. Nothing on this panel is typed. */ export default function FaydaVerifyPanel({ subject, title, state, required, - onVerified, disabled, pendingReview, }: FaydaVerifyPanelProps) { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - // The listener closes over `subject`; keep it in a ref so remounting the - // panel between steps can't complete a verification against the wrong person. - const subjectRef = useRef(subject); - subjectRef.current = subject; - // FaydaCallbackPage posts its message from a StrictMode-double-invoked - // effect in dev, so the same one-time-use code+state can arrive twice. - // Track the last state we've started completing so the resend is a no-op. - const handledStateRef = useRef(null); - // Polls the popup so a manually-closed window (no postMessage ever sent) - // still clears `loading` instead of leaving the button spinning forever. - const pollRef = useRef(null); - - const stopPolling = () => { - if (pollRef.current !== null) { - window.clearInterval(pollRef.current); - pollRef.current = null; - } - }; - - useEffect(() => { - const onMessage = async (event: MessageEvent) => { - if (event.origin !== window.location.origin) return; - if (event.data?.type !== "fayda-callback") return; - - if (event.data.error) { - stopPolling(); - setLoading(false); - setError(event.data.errorDescription ?? event.data.error); - return; - } - if (!event.data.code || !event.data.state) return; - if (handledStateRef.current === event.data.state) return; - handledStateRef.current = event.data.state; - stopPolling(); - - try { - const next = await verifaydaService.completeIdentity( - subjectRef.current, - event.data.code, - event.data.state, - ); - setError(null); - onVerified(next); - } catch (err) { - setError( - (err as { response?: { data?: { message?: string } } })?.response?.data - ?.message ?? - (err instanceof Error ? err.message : "Verification failed"), - ); - } finally { - setLoading(false); - } - }; - window.addEventListener("message", onMessage); - return () => { - window.removeEventListener("message", onMessage); - stopPolling(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); const startVerification = async () => { setError(null); setLoading(true); - handledStateRef.current = null; try { const authorizationUrl = await verifaydaService.start(); - const popup = window.open( - authorizationUrl, - "fayda-verify", - "width=480,height=760,noopener=no", - ); - if (!popup) { - setLoading(false); - setError("Pop-up blocked — allow pop-ups for this site and try again."); - return; - } - // Loading stays on until the popup posts back — unless the user closes - // it by hand, which never sends a message; poll for that and clear - // loading ourselves so the button doesn't spin forever. - stopPolling(); - pollRef.current = window.setInterval(() => { - if (!popup.closed) return; - stopPolling(); - if (handledStateRef.current === null) setLoading(false); - }, 500); + // Record who is being verified and where to come back to before the tab + // leaves — /fayda/callback has no other way to know either. + stashPendingVerification({ + subject, + returnTo: + window.location.pathname + + window.location.search + + window.location.hash, + }); + window.location.assign(authorizationUrl); } catch (err) { setLoading(false); setError( (err as { response?: { data?: { message?: string } } })?.response?.data ?.message ?? - (err instanceof Error ? err.message : "Could not start verification"), + (err instanceof Error ? err.message : "Could not start verification"), ); } }; @@ -262,7 +189,13 @@ function DataRow({ icon, value }: { icon: ReactNode; value: string | null }) { if (!value) return null; return ( - + {icon} diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx index 466307fe9..fa9c5919b 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -209,7 +209,20 @@ export default function OnboardingWizardDialog({ nationality?: CompanyNationality; }) => api.companies.startOnboarding.call(vars), onSuccess: async () => { - await refreshInfo(); + // Nationality drives the server-resolved identity requirements (Fayda vs + // passport), the document set and the GM/PoA copy — all read from + // onboardingRequirements/profile. Re-entering role selection can change + // it, so both must be refetched alongside getInfo or the form step would + // keep rendering the previous nationality's requirements. + await Promise.all([ + refreshInfo(), + queryClient.invalidateQueries({ + queryKey: api.companies.onboardingRequirements.queryKey(), + }), + queryClient.invalidateQueries({ + queryKey: api.companies.getProfile.queryKey(), + }), + ]); setPhase("form"); }, onError: (err) => setStartError(extractApiError(err).message), diff --git a/apps/edr-freight-web/portal/src/pages/FaydaCallbackPage.tsx b/apps/edr-freight-web/portal/src/pages/FaydaCallbackPage.tsx index c25dc836a..50b3812ca 100644 --- a/apps/edr-freight-web/portal/src/pages/FaydaCallbackPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/FaydaCallbackPage.tsx @@ -1,51 +1,89 @@ -import { useEffect, useState } from "react"; -import { Center, Loader, Stack, Text } from "@mantine/core"; +import { useEffect, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { Button, Center, Loader, Stack, Text } from "@mantine/core"; -import type { FaydaCallbackMessage } from "@/services/verifayda.service"; +import { + takePendingVerification, + verifaydaService, +} from "@/services/verifayda.service"; /** * Landing page for the portal's eSignet redirect_uri - * (FAYDA_PORTAL_REDIRECT_URI → http://localhost:5173/callback). Runs inside the - * verification popup: relays ?code&state (or ?error) to the window that opened - * it via postMessage, then closes itself. The opener performs the completion - * call so the single-use session is only consumed once, in one place. + * (FAYDA_PORTAL_REDIRECT_URI → http://localhost:5173/fayda/callback). + * + * The verification is a full-page redirect, so the page that started it no + * longer exists: this page completes the code+state exchange itself against + * the subject FaydaVerifyPanel stashed, then sends the user back where they + * were. Everything mounts fresh on the way back, so the verified identity is + * fetched rather than pushed. */ export default function FaydaCallbackPage() { - const [standalone, setStandalone] = useState(false); + const navigate = useNavigate(); + const [error, setError] = useState(null); + const [returnTo, setReturnTo] = useState("/"); + // The code+state are single-use, so StrictMode's double-invoked effect must + // not exchange them twice — the second attempt would fail on a spent session. + const startedRef = useRef(false); useEffect(() => { - const params = new URLSearchParams(window.location.search); - const message: FaydaCallbackMessage = { - type: "fayda-callback", - code: params.get("code") ?? undefined, - state: params.get("state") ?? undefined, - error: params.get("error") ?? undefined, - errorDescription: params.get("error_description") ?? undefined, - }; + if (startedRef.current) return; + startedRef.current = true; - if (window.opener && window.opener !== window) { - (window.opener as Window).postMessage(message, window.location.origin); - window.close(); - } else { - // Opened as a full-page redirect instead of a popup — nothing to relay to. - setStandalone(true); + const params = new URLSearchParams(window.location.search); + const pending = takePendingVerification(); + if (pending) setReturnTo(pending.returnTo); + + const authError = params.get("error"); + if (authError) { + setError(params.get("error_description") ?? authError); + return; } - }, []); + + const code = params.get("code"); + const state = params.get("state"); + if (!code || !state) { + setError("This verification link is missing its code — start again."); + return; + } + if (!pending) { + // Landed here without the tab that started it — a bookmarked/copied + // callback URL, or sessionStorage cleared mid-flow. + setError("This verification was started somewhere else — start again."); + return; + } + + verifaydaService + .completeIdentity(pending.subject, code, state) + .then(() => navigate(pending.returnTo, { replace: true })) + .catch((err) => + setError( + (err as { response?: { data?: { message?: string } } })?.response + ?.data?.message ?? + (err instanceof Error ? err.message : "Verification failed"), + ), + ); + }, [navigate]); return (
- {standalone ? ( + {error ? ( <> - Verification window lost its parent page - - Close this tab and start the verification again from the form. + Verification could not be completed + + {error} + ) : ( <> - + Completing Fayda verification… diff --git a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx index ce357467e..df3511d8f 100644 --- a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx @@ -82,6 +82,12 @@ function tabIncomplete(tabId: SettingsTab, profile: ProfileResponse): boolean { case "contact": return !profile.contactPersonName || !profile.contactPersonPhone; case "gm": + // The GM is established through Fayda — verified in their own right or + // declared the same person as the owner — so the identity answers this, + // not the typed columns. A company that may still type them (foreign, + // whose manager may hold no Fayda ID) is judged on those instead. + if (profile.identity?.gm.verified) return false; + if (profile.identity?.faydaRequired) return true; return ( !profile.generalManagerName || !profile.generalManagerEmail || diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index 0dc02eaa4..17c88d792 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -42,6 +42,7 @@ import { toFormValues, } from "./companyProfileForm/helpers"; import FaydaVerifyPanel from "@/components/FaydaVerifyPanel"; +import { verifaydaService } from "@/services/verifayda.service"; import type { CompanyIdentityState } from "@/services/verifayda.service"; import { LinkCheckboxCard } from "./companyProfileForm/LinkCheckboxCard"; import ETradeCompanyCard from "./companyProfileForm/ETradeCompanyCard"; @@ -107,7 +108,11 @@ export default function CompanyProfileForm({ >; /** Fayda verification state for the owner and the PoA (undefined until loaded). */ identity?: CompanyIdentityState; - /** Refetch the profile + requirements once a verification lands. */ + /** + * Refetch the profile + requirements. Only the in-page identity actions need + * this — a Fayda verification navigates the whole tab away and comes back to + * a freshly booted app, so it has nothing to notify. + */ onIdentityChange?: () => void; }) { const [step, setStep] = useState(initialStep ?? "company"); @@ -342,7 +347,11 @@ export default function CompanyProfileForm({ // "Same as …" links. A checked card prefills the target step's fields from the // source step and disables them (kept mirrored while linked); unchecking clears // them and re-enables editing. - const [gmSameAsOwner, setGmSameAsOwner] = useState(false); + // Seeded from the server so a resumed draft reopens with the declaration the + // company already made, rather than an unticked box over a linked GM. + const [gmSameAsOwner, setGmSameAsOwner] = useState( + identity?.gmSameAsOwner ?? false, + ); const [contactSameAsGm, setContactSameAsGm] = useState(false); // General Manager source. The company step's email/phone are seeded from @@ -369,6 +378,10 @@ export default function CompanyProfileForm({ useEffect(() => { if (!gmSameAsOwner) return; + // A verified owner's identity is copied server-side and read back from + // `identity.gm`; mirroring it into form fields here would send typed + // values for something the API already owns. + if (identity?.owner.verified) return; setValue("generalManagerName", gmSourceName, { shouldValidate: true }); setValue("generalManagerEmail", gmSourceEmail, { shouldValidate: true }); setValue("generalManagerPhone", gmSourcePhone, { @@ -377,18 +390,78 @@ export default function CompanyProfileForm({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [gmSameAsOwner, gmSourceName, gmSourceEmail, gmSourcePhone]); - const toggleGmSameAsOwner = (checked: boolean) => { + /** + * "Same as owner" has two meanings depending on what backs the owner. + * + * A Fayda-verified owner is a proven identity, so the declaration is made + * server-side: the API copies that identity onto the GM and records what it + * did. Anything typed here would arrive wearing a verified badge it hadn't + * earned, which is exactly what the verification exists to prevent. + * + * A foreign company's owner is backed by a typed passport instead, so there + * is nothing proven to copy — that stays the local field-mirroring it has + * always been. + */ + const [gmLinkPending, setGmLinkPending] = useState(false); + const toggleGmSameAsOwner = async (checked: boolean) => { setGmSameAsOwner(checked); - if (!checked) { - setValue("generalManagerName", ""); - setValue("generalManagerEmail", ""); - setValue("generalManagerPhone", ""); + if (!identity?.owner.verified) { + if (!checked) { + setValue("generalManagerName", ""); + setValue("generalManagerEmail", ""); + setValue("generalManagerPhone", ""); + } + return; + } + + setGmLinkPending(true); + try { + if (checked) await verifaydaService.setGmSameAsOwner(); + else await verifaydaService.clearGmIdentity(); + onIdentityChange?.(); + } catch (err) { + setGmSameAsOwner(!checked); + setSaveError( + (err as { response?: { data?: { message?: string } } })?.response?.data + ?.message ?? + (err instanceof Error ? err.message : "Could not update the general manager"), + ); + } finally { + setGmLinkPending(false); } }; - const gmName = watch("generalManagerName"); - const gmEmail = watch("generalManagerEmail"); - const gmPhone = watch("generalManagerPhone"); + // Where the GM's details come from depends on how they were established: a + // Fayda verification (or a "same as owner" declaration) owns them outright, + // and only a company that may still type them falls back to form state. + const gmVerified = identity?.gm.verified ?? false; + const gmName = gmVerified + ? (identity?.gm.name ?? "") + : watch("generalManagerName"); + const gmEmail = gmVerified + ? (identity?.gm.email ?? "") + : watch("generalManagerEmail"); + const gmPhone = gmVerified + ? (identity?.gm.phone ?? "") + : watch("generalManagerPhone"); + + /** + * Whether the GM has been established at all — by verification, by the + * "same as owner" declaration, or (only where Fayda is optional) by typing. + * Fayda is an Ethiopian national ID, so a foreign company's GM may hold none. + */ + const gmTyped = Boolean( + watch("generalManagerName")?.trim() && + watch("generalManagerEmail")?.trim() && + watch("generalManagerPhone")?.trim(), + ); + const gmEstablished = + gmVerified || (identity ? !identity.faydaRequired && gmTyped : gmTyped); + + /** Same rule for the representative: verified, or typed where Fayda is optional. */ + const poaEstablished = + (identity?.poa.verified ?? false) || + (identity ? !identity.faydaRequired && Boolean(watch("poaName")?.trim()) : false); // While linked, mirror the source values into the (disabled) target fields so // the copy stays current even if the user goes back and edits the source. @@ -535,14 +608,16 @@ export default function CompanyProfileForm({ const currentIdx = stepOrder.indexOf(step); // The DARS delegation paper is what proves the representative was actually - // delegated, so it's required the moment a PoA exists — and unconditionally - // for a freight forwarder, whose PoA itself is mandatory. The API enforces - // the same rule on save, so skipping it here only costs the customer a + // delegated, so it's required the moment a PoA exists. The API enforces the + // same rule on save, so skipping it here only costs the customer a // round-trip. // A PoA exists exactly when one has been verified — the details are the // verification's output, so there is nothing else that could stand for one. + // Until then the upload is hidden: there is no representative for the paper + // to authorise, and a freight forwarder is held on the verification gate + // below rather than on a file field it cannot yet fill. const poaProvided = identity?.poa.verified ?? false; - const delegationRequired = requirePoa || poaProvided; + const delegationRequired = poaProvided; const delegationPresent = (uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) || (() => { @@ -628,9 +703,23 @@ export default function CompanyProfileForm({ setSaveError("Verify the company owner's identity with Fayda before continuing."); return; } - if (step === "poa" && requirePoa && !identity?.poa.verified) { + // The GM is established through Fayda now, so the step gates on the + // identity rather than on typed text — same strength as the old required + // fields, different evidence. A foreign company's GM may hold no Fayda ID, + // so typed details still satisfy it there. + if (step === "personnel" && !gmEstablished) { setSaveError( - "Freight forwarders act on other companies' behalf, so the Power of Attorney's identity must be verified with Fayda.", + identity?.faydaRequired + ? "Verify the general manager with Fayda, or tick “same as owner” if they are the company's owner." + : "Add the general manager's details, or verify them with Fayda.", + ); + return; + } + if (step === "poa" && requirePoa && !poaEstablished) { + setSaveError( + identity?.faydaRequired + ? "Freight forwarders act on other companies' behalf, so the Power of Attorney's identity must be verified with Fayda." + : "Freight forwarders act on other companies' behalf, so a Power of Attorney is required.", ); return; } @@ -714,7 +803,6 @@ export default function CompanyProfileForm({ title="Owner" state={identity.owner} required={identity.faydaRequired} - onVerified={() => onIdentityChange?.()} /> {identity.passportRequired && ( General Manager - {/* GM is a plain typed role, not the person the Fayda - verification proves — the owner is (see the Company step). - They're very often the same human, which "same as owner" is - for once the owner has verified. */} + {/* The GM is very often the owner. Where the owner is + Fayda-verified this reuses that proven identity outright + rather than making the same human verify twice; where the + owner is backed by a typed passport there is nothing proven + to copy, so it stays a local prefill. */} - - - - - + )} + + {/* Typed details survive only where Fayda cannot be required — + a foreign company's manager may hold no Fayda ID. Once + verified the API owns these fields, so they go away. */} + {!gmSameAsOwner && !gmVerified && !identity?.faydaRequired && ( + <> + + + + + + + )} )} @@ -872,19 +981,23 @@ export default function CompanyProfileForm({ title="Power of Attorney" state={identity.poa} required={requirePoa} - onVerified={() => onIdentityChange?.()} /> )} - {/* The city is the one field the Fayda address claim does not - reliably decompose into, so it stays typed. */} - + {/* The address comes from the Fayda claim along with the name, + so it is shown on the panel rather than typed. Only a company + whose representative may hold no Fayda ID still types it. */} + {!identity?.poa.verified && !identity?.faydaRequired && ( + + )} - {poaDocumentSetting && ( + {/* The paper authorises the representative the verification + named, so it only has meaning once one exists. */} + {poaProvided && poaDocumentSetting && ( <> !v || z.string().email().safeParse(v).success, + "Invalid Manager email", + ), generalManagerPhone: z .string() - .min(1, "Manager phone is required") - .refine(isValidPhone, "Enter a valid phone number"), + .optional() + .refine((v) => !v || isValidPhone(v), "Enter a valid phone number"), poaName: z.string().optional(), poaPhone: z .string() diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx index 47b863493..e42283674 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx @@ -379,11 +379,6 @@ export default function TabCompanyProfile({ required={identity.faydaRequired} disabled={mutation.isPending} pendingReview={pendingOwnerReview} - onVerified={() => - queryClient.invalidateQueries({ - queryKey: api.companies.getProfile.queryKey(), - }) - } /> {identity.passportRequired && ( !v || z.string().email().safeParse(v).success, + "Invalid GM email", + ), generalManagerPhone: z .string() - .min(1, "GM phone is required") - .refine(isValidPhone, "Enter a valid phone number"), + .optional() + .refine((v) => !v || isValidPhone(v), "Enter a valid phone number"), }); type FormData = z.infer; @@ -37,16 +50,20 @@ interface TabGeneralManagerProps { } /** - * The general manager is a plain typed role, not the person the Fayda - * verification proves — the owner is. They're very often the same human, - * which is what "Same as owner" is for: once the owner has verified, this - * copies their name/email/phone in rather than making the customer re-type - * data the company already proved. + * The general manager's identity comes from Fayda: either verified in their + * own right, or declared to be the owner — very often the same human, which is + * what "Same as owner" is for. Typed details survive only for a foreign + * company, whose manager may hold no Fayda ID at all. */ export default function TabGeneralManager({ profile, mode = "edit", onContinue }: TabGeneralManagerProps) { const queryClient = useQueryClient(); const owner = profile.identity?.owner; - const [gmSameAsOwner, setGmSameAsOwner] = useState(false); + const identity = profile.identity; + const gm = identity?.gm; + const faydaRequired = identity?.faydaRequired ?? false; + const [gmSameAsOwner, setGmSameAsOwner] = useState( + identity?.gmSameAsOwner ?? false, + ); const defaultValues = useMemo((): FormData => { return { @@ -68,25 +85,45 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue } values: defaultValues, }); - const toggleGmSameAsOwner = (checked: boolean) => { + /** + * With a Fayda-verified owner the declaration is made server-side — the API + * copies the proven identity onto the GM — so nothing is typed here. Without + * one (a foreign company, whose owner is backed by a passport) there is + * nothing proven to copy and this stays a local prefill. + */ + const [linkPending, setLinkPending] = useState(false); + const [linkError, setLinkError] = useState(null); + const toggleGmSameAsOwner = async (checked: boolean) => { setGmSameAsOwner(checked); - if (checked && owner) { - setValue("generalManagerName", owner.name ?? "", { shouldValidate: true }); - setValue("generalManagerEmail", owner.email ?? "", { shouldValidate: true }); - setValue("generalManagerPhone", owner.phone ?? "", { shouldValidate: true }); + setLinkError(null); + if (!owner?.verified) { + if (checked && owner) { + setValue("generalManagerName", owner.name ?? "", { shouldValidate: true }); + setValue("generalManagerEmail", owner.email ?? "", { shouldValidate: true }); + setValue("generalManagerPhone", owner.phone ?? "", { shouldValidate: true }); + } + return; + } + + setLinkPending(true); + try { + if (checked) await verifaydaService.setGmSameAsOwner(); + else await verifaydaService.clearGmIdentity(); + queryClient.invalidateQueries({ + queryKey: api.companies.getProfile.queryKey(), + }); + } catch (err) { + setGmSameAsOwner(!checked); + setLinkError( + (err as { response?: { data?: { message?: string } } })?.response?.data + ?.message ?? + (err instanceof Error ? err.message : "Could not update the general manager"), + ); + } finally { + setLinkPending(false); } }; - // Keep the copy live while the checkbox is on — e.g. the owner re-verifies - // with updated details. - useEffect(() => { - if (!gmSameAsOwner || !owner) return; - setValue("generalManagerName", owner.name ?? ""); - setValue("generalManagerEmail", owner.email ?? ""); - setValue("generalManagerPhone", owner.phone ?? ""); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [gmSameAsOwner, owner?.name, owner?.email, owner?.phone]); - const mutation = useMutation({ mutationFn: (data: FormData) => api.companies.updateProfile.call({ @@ -102,6 +139,11 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue } const onSubmit = (data: FormData) => mutation.mutate(data); + // Nothing to save when Fayda owns the details: the verification and the + // "same as owner" declaration both write server-side, so the form would be + // posting empty strings over a proven identity. + const typedFieldsInUse = !gmSameAsOwner && !gm?.verified && !faydaRequired; + return ( @@ -114,40 +156,70 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue }
- {owner?.verified && ( - - )} - - - + {linkError && ( + }> + {linkError} + + )} + + {/* Verifying a second person only means something when the manager + is someone other than the owner. */} + {!gmSameAsOwner && gm && ( + + )} + + {/* Typed details survive only where Fayda cannot be required — a + foreign company's manager may hold no Fayda ID. Once verified the + API owns these fields and refuses edits, so they go away. */} + {!gmSameAsOwner && !gm?.verified && !faydaRequired && ( + <> - - - - - + + + + + + + + + + + )} - {mode === "edit" && ( + {mode === "edit" && typedFieldsInUse && ( + {/* Saving only means something while the details are typed: under + Fayda both routes write server-side, so a submit would post + empty strings at an identity the API owns and refuses to + overwrite. Onboarding still needs a way forward, so the button + becomes a plain Continue rather than disappearing. */} + {typedFieldsInUse ? ( + + ) : ( + mode === "onboarding" && ( + + ) + )}
diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx index a3ef08078..4c48ea238 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx @@ -136,7 +136,12 @@ export default function TabPowerOfAttorney({ // has been verified. const identity = profile.identity; const poaProvided = identity?.poa.verified ?? false; - const letterRequired = requirePoa || poaProvided; + // The paper authorises the representative named above, so there is nothing + // for it to authorise until one has been verified — the upload is hidden + // until then, and requiring it while hidden would block the save on a + // control the customer cannot see. A freight forwarder is still held to + // having a PoA at all, by the verification gate on the panel and by the API. + const letterRequired = poaProvided; const letterMissing = letterRequired && !hasLetterAfterSave; const fileDirty = Boolean(pickedFile) || removeIds.length > 0; @@ -253,35 +258,33 @@ export default function TabPowerOfAttorney({ state={identity.poa} required={requirePoa} disabled={mutation.isPending} - onVerified={() => { - queryClient.invalidateQueries({ - queryKey: api.companies.getProfile.queryKey(), - }); - queryClient.invalidateQueries({ - queryKey: api.companies.poaDelegation.queryKey(), - }); - }} /> )}
- {/* Name, email, phone and address are all written by the Fayda - verification, so only the city — which the address claim does - not reliably decompose into — is typed. */} - - - - - + {/* Name, email, phone and address all come from the Fayda + verification and are shown on the panel above. Only a company + whose representative may hold no Fayda ID still types a + location. */} + {!poaProvided && !(identity?.faydaRequired ?? false) && ( + + + + + + )} {/* ------------------------ Delegation letter ------------------------ */} + {/* The paper authorises the representative the verification named, + so it only has meaning once one exists. */} + {poaProvided && ( @@ -429,6 +432,7 @@ export default function TabPowerOfAttorney({ }} /> + )} { + // The suite runs in node, not jsdom — a Map is all these two calls need. + beforeEach(() => { + const store = new Map(); + vi.stubGlobal("sessionStorage", { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => void store.set(k, v), + removeItem: (k: string) => void store.delete(k), + }); + }); + + it("round-trips and clears, so a spent code can't be replayed", () => { + stashPendingVerification({ subject: "poa", returnTo: "/settings?tab=poa" }); + + expect(takePendingVerification()).toEqual({ + subject: "poa", + returnTo: "/settings?tab=poa", + }); + expect(takePendingVerification()).toBeNull(); + }); + + it("returns null rather than throwing on missing or malformed entries", () => { + expect(takePendingVerification()).toBeNull(); + + sessionStorage.setItem("fayda-pending-verification", "not json"); + expect(takePendingVerification()).toBeNull(); + + sessionStorage.setItem("fayda-pending-verification", '{"returnTo":"/"}'); + expect(takePendingVerification()).toBeNull(); + }); +}); diff --git a/apps/edr-freight-web/portal/src/services/verifayda.service.ts b/apps/edr-freight-web/portal/src/services/verifayda.service.ts index 54a7084dd..38b225918 100644 --- a/apps/edr-freight-web/portal/src/services/verifayda.service.ts +++ b/apps/edr-freight-web/portal/src/services/verifayda.service.ts @@ -3,11 +3,12 @@ import { unwrap } from "@/utils/endpoint"; import type { ApiResponse } from "@/types/apiResponse"; /** - * Which of the company's people a verification is for. The owner is NOT the - * general manager — GM is a plain typed role the portal offers a "same as - * owner" copy for, but only the owner and the PoA are ever Fayda-verified. + * Which of the company's people a verification is for. The owner is who the + * company is proven through; the PoA and GM are personnel it names. The GM is + * very often the owner — "same as owner" reuses that verification rather than + * making the same human prove themselves twice. */ -export type IdentitySubject = "owner" | "poa"; +export type IdentitySubject = "owner" | "poa" | "gm"; /** One person's Fayda verification state, as the API reports it. */ export interface IdentityVerificationState { @@ -29,29 +30,67 @@ export interface OwnerIdentityState extends IdentityVerificationState { } export interface CompanyIdentityState { - /** True when Fayda verification of the owner (and PoA) is mandatory — Ethiopian companies only. */ + /** + * True when Fayda verification is mandatory — Ethiopian companies only. + * Doubles as "may this person be typed instead": Fayda is an Ethiopian + * national ID, so a foreign company's GM and PoA are offered the + * verification but fall back to typed details when they hold none. + */ faydaRequired: boolean; /** True when the owner's passport number is mandatory — foreign companies only. */ passportRequired: boolean; owner: OwnerIdentityState; poa: IdentityVerificationState; + /** + * General manager. `verified` covers both routes: the GM verifying in their + * own right, and the company declaring the GM is the owner (in which case + * `gmSameAsOwner` is set and the owner's Fayda sub backs it). + */ + gm: IdentityVerificationState; + gmSameAsOwner: boolean; complete: boolean; } -/** Message posted from the /callback popup back to the opener window. */ -export interface FaydaCallbackMessage { - type: "fayda-callback"; - code?: string; - state?: string; - error?: string; - errorDescription?: string; +/** + * What the panel was doing when it handed the tab over to eSignet. The + * verification is a full-page redirect, so the page that started it is gone by + * the time /fayda/callback runs — this is how that page knows whose identity + * the code+state belongs to and where to put the user back. + * + * sessionStorage, not localStorage: it is scoped to this tab, so two tabs + * verifying different people can't overwrite each other, and it dies with the + * tab rather than outliving an abandoned verification. + */ +const PENDING_KEY = "fayda-pending-verification"; + +export interface PendingVerification { + subject: IdentitySubject; + /** Path to return to once the verification completes. */ + returnTo: string; +} + +export function stashPendingVerification(pending: PendingVerification): void { + sessionStorage.setItem(PENDING_KEY, JSON.stringify(pending)); +} + +/** Read and clear — the code+state are single-use, so a retry needs a fresh start. */ +export function takePendingVerification(): PendingVerification | null { + const raw = sessionStorage.getItem(PENDING_KEY); + sessionStorage.removeItem(PENDING_KEY); + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as PendingVerification; + return parsed.subject ? parsed : null; + } catch { + return null; + } } export const verifaydaService = { /** - * Returns the eSignet authorize URL to open in a popup. `PORTAL` selects the - * portal's own registered redirect_uri — the backoffice and mobile clients - * have their own. + * Returns the eSignet authorize URL to navigate the tab to. `PORTAL` selects + * the portal's own registered redirect_uri — the backoffice and mobile + * clients have their own. */ start: async (): Promise => { const response = await client.post< @@ -80,6 +119,30 @@ export const verifaydaService = { return unwrap(response.data); }, + /** + * Declare the General Manager is the company's owner, reusing the owner's + * verified identity rather than making the same human verify twice. The copy + * happens server-side from the stored owner identity — the portal never + * supplies the values — and is refused until the owner is verified. + */ + setGmSameAsOwner: async (): Promise => { + const response = await client.post>( + "/api/companies/identity/gm/same-as-owner", + ); + return unwrap(response.data); + }, + + /** + * Clear the GM's identity — the "same as owner" declaration or a verification + * of their own — leaving them open to be re-established either way. + */ + clearGmIdentity: async (): Promise => { + const response = await client.delete>( + "/api/companies/identity/gm", + ); + return unwrap(response.data); + }, + /** * Drop the Power of Attorney — verified identity, details and delegation * paper together. A verified person's fields are locked, so blanking the form diff --git a/apps/edr-freight-web/portal/src/types/profile.ts b/apps/edr-freight-web/portal/src/types/profile.ts index 7b7ce692c..8f126ea1a 100644 --- a/apps/edr-freight-web/portal/src/types/profile.ts +++ b/apps/edr-freight-web/portal/src/types/profile.ts @@ -36,11 +36,15 @@ export interface ProfileResponse { generalManagerEmail: string | null; generalManagerPhone: string | null; /** - * Fayda verification state for the owner and the PoA — not the general - * manager, which stays a plain typed role. `identity.faydaRequired` / - * `identity.passportRequired` is the Ethiopian/foreign switch: an Ethiopian - * company verifies the owner (and PoA) with Fayda; a foreign one requires a - * typed passport number for the owner instead. + * Fayda verification state for the owner, the PoA and the general manager. + * `identity.faydaRequired` / `identity.passportRequired` is the + * Ethiopian/foreign switch: an Ethiopian company verifies all three with + * Fayda, while a foreign one proves its owner with a typed passport number + * and may type its GM and PoA, whose holders may have no Fayda ID. + * + * The `generalManager*` fields above are the same person's details written + * flat — a verification keeps them in step, since the booking, contract and + * train-scheduling notifiers mail `generalManagerEmail` directly. */ identity: CompanyIdentityState; poaName: string | null; diff --git a/docker-compose.e2e.yaml b/docker-compose.e2e.yaml index 4df2cc1e4..621919a56 100644 --- a/docker-compose.e2e.yaml +++ b/docker-compose.e2e.yaml @@ -235,8 +235,8 @@ services: PAYMENT_API_URL: http://payment-mock-e2e:4500 FAYDA_TOKEN_ENDPOINT: http://fayda-mock-e2e:4400/token FAYDA_USERINFO_ENDPOINT: http://fayda-mock-e2e:4400/userinfo - FAYDA_REDIRECT_URI: http://localhost:${E2E_PORTAL_PORT:-5373}/callback - FAYDA_PORTAL_REDIRECT_URI: http://localhost:${E2E_PORTAL_PORT:-5373}/callback + FAYDA_REDIRECT_URI: http://localhost:${E2E_PORTAL_PORT:-5373}/fayda/callback + FAYDA_PORTAL_REDIRECT_URI: http://localhost:${E2E_PORTAL_PORT:-5373}/fayda/callback # Throwaway e2e-only RSA JWK (client_assertion signing) — the mock # never verifies the signature, this just has to be well-formed. # Generated fresh per launch by e2e.mjs (fakeFaydaPrivateKeyBase64), diff --git a/docker-compose.it.yaml b/docker-compose.it.yaml index 8bb341f0b..33c6281af 100644 --- a/docker-compose.it.yaml +++ b/docker-compose.it.yaml @@ -1,12 +1,24 @@ -# EDR Freight — API integration stack (headless). +# EDR Freight — API integration stack (headless, sharded). # -# Overlay on docker-compose.e2e.yaml. Same base services (postgres, minio, -# mocks, freight-api), except the payment microservice is REAL here instead of -# `payment-mock-e2e`, and only the bank gateways are stubbed: +# Overlay on docker-compose.e2e.yaml, plus a GENERATED third file holding the +# per-shard services (integration/.it-shards.yaml, written by gen-shards.mjs). # -# freight-api-it ──HTTP──> payment-api-it ──HTTP──> gateway-mock-it -# ^ │ -# └────── RabbitMQ ───────┘ (outbox → payment.events → consumer) +# The freight API is NOT a container here — it boots inside each vitest worker, +# on the host, so a code change needs no image rebuild and a breakpoint works. +# Each worker is a full shard of the topology; nothing mutable is shared: +# +# shard i: +# freight app (in vitest worker, host :3111+i) +# │ ▲ +# │ └──── HTTP ─── payment-api-it-{i} :3131+i (inbound CBE bill query, +# │ │ via host.docker.internal) +# └── HTTP ──────────────► │ ──HTTP──> gateway-mock-it-{i} :4600+i +# ▲ │ +# └──── RabbitMQ vhost payment_s{i} ──┘ (outbox → payment.events → consumer) +# +# Shared by every shard: postgres (one container, one database per shard cloned +# from a seeded template), rabbitmq (one container, one vhost per shard), minio, +# and the one-shot migration. # # Its own compose project (`name:` below overrides the base) and its own host # ports, so it can run side by side with the Cypress e2e stack. @@ -15,13 +27,16 @@ # # Never start it with plain `docker compose -f docker-compose.it.yaml` — it is # an OVERLAY and needs the base file first: -# docker compose -f docker-compose.e2e.yaml -f docker-compose.it.yaml ... +# docker compose -f docker-compose.e2e.yaml -f docker-compose.it.yaml \ +# -f integration/.it-shards.yaml ... name: edr-freight-it services: # Outbox transport. The payment API publishes payment.succeeded/failed here # and freight consumes it — the production path. Copied from the passenger - # harness (e2e/docker-compose.yml). + # harness (e2e/docker-compose.yml). One broker, one vhost per shard + # (payment_s0, payment_s1, …) created by it.mjs — a shared vhost would let one + # shard's freight consumer eat another shard's settlement event. rabbitmq-it: image: rabbitmq:3-management environment: @@ -32,139 +47,14 @@ services: - "${IT_RABBIT_PORT:-5772}:5672" - "${IT_RABBIT_UI_PORT:-15772}:15672" healthcheck: - test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"] + # check_running, NOT ping: ping only proves the Erlang node answers, and + # it.mjs runs `rabbitmqctl add_vhost` the moment this goes healthy — which + # on a cold boot failed with "this command requires the 'rabbit' app to be + # running on the target node". check_running waits for the application. + test: ["CMD", "rabbitmq-diagnostics", "-q", "check_running"] interval: 5s - timeout: 5s + timeout: 10s retries: 20 - - # Stand-in for every bank/wallet gateway the payment API talks to, plus a - # control plane the tests drive (force a provider to fail/hang, fire a - # correctly-signed webhook, read back what was called). See - # integration/gateway-mock/server.js. - gateway-mock-it: - image: node:20-alpine - volumes: - - ./integration/gateway-mock:/app:ro - working_dir: /app - environment: - PORT: "4600" - # Same secrets the payment API gets — so webhooks the mock signs pass the - # API's REAL signature verification instead of bypassing it. - CBE_SECRET_KEY: it-cbe-secret - CBE_MERCHANT_ID: it-cbe-merchant - PAYMENT_API_URL: http://payment-api-it:3003 - command: ["node", "server.js"] - ports: - - "${IT_GATEWAY_PORT:-4600}:4600" - healthcheck: - test: - [ - "CMD", - "node", - "-e", - "fetch('http://localhost:4600/__control/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", - ] - interval: 3s - timeout: 3s - retries: 10 - - payment-api-it: - build: - context: . - dockerfile: apps/edr-payment-api/Dockerfile - secrets: - - npmrc - depends_on: - postgres-freight-e2e: - condition: service_healthy - rabbitmq-it: - condition: service_healthy - gateway-mock-it: - condition: service_healthy - environment: - PORT: "3003" - NODE_ENV: test - # Payment tables live in their own schema of the same throwaway DB; - # main.ts ensurePaymentSchema() creates it, migrationsRun does the rest. - DB_HOST: postgres-freight-e2e - DB_PORT: "5432" - DB_USER: edr_e2e - DB_PASSWORD: edr_e2e - DB_NAME: edr_freight_e2e - DB_SCHEMA: edr_payment - # Same token freight already uses in the base stack. - SERVICE_AUTH_TOKEN: e2e-service-token - PUBLISHER_TRANSPORT: rabbitmq - PAYMENT_RABBITMQ_URL: amqp://edr:edr_secret@rabbitmq-it:5672/payment - # HTTP fallback targets (only used with PUBLISHER_TRANSPORT=http). - PAYMENT_NOTIFY_FREIGHT_URL: http://freight-api-e2e:3001/api/internal/payments/mark-paid - # Fast relay + sweep so retry/reconciliation are observable inside a test - # rather than a minute later. - OUTBOX_RELAY_INTERVAL_MS: "1000" - RECONCILE_STALE_AFTER_MS: "5000" - # Every gateway points at the one mock. Paths are per-provider prefixes. - CBE_BASE_URL: http://gateway-mock-it:4600/cbe-birr - CBE_MERCHANT_ID: it-cbe-merchant - CBE_SECRET_KEY: it-cbe-secret - CBE_NOTIFY_URL: http://payment-api-it:3003/webhooks/cbe-birr - CBE_RETURN_URL: http://localhost/return - TELEBIRR_BASE_URL: http://gateway-mock-it:4600/telebirr - TELEBIRR_WEB_BASE_URL: http://gateway-mock-it:4600/telebirr/web - TELEBIRR_FABRIC_APP_ID: it-fabric - TELEBIRR_APP_SECRET: it-secret - TELEBIRR_MERCHANT_APP_ID: it-merchant-app - TELEBIRR_MERCHANT_CODE: "999999" - TELEBIRR_NOTIFY_URL: http://payment-api-it:3003/webhooks/telebirr - # Telebirr PSS-signs every request object — a throwaway key generated per - # launch by it.mjs (nothing key-shaped lives in git). - TELEBIRR_PRIVATE_KEY: ${IT_TELEBIRR_PRIVATE_KEY} - EBIRR_BASE_URL: http://gateway-mock-it:4600/ebirr - DMONEY_BASE_URL: http://gateway-mock-it:4600/dmoney - CARD_BASE_URL: http://gateway-mock-it:4600/card - WAAFI_BASE_URL: http://gateway-mock-it:4600/waafi - CAC_BASE_URL: http://gateway-mock-it:4600/cac - CAC_USERNAME: it-cac - CAC_PASSWORD: it-cac - CAC_APP_KEY: it-cac-key - CAC_API_KEY: it-cac-api - CAC_COMPANY_SERVICES_ID: "1" - # Inbound CBE Unified Bill — we are the biller; bill-query hops back into - # the freight API, so this direction runs real code on both sides. - CBE_BILL_ENABLED: "true" - CBE_BILL_CLIENT_ID: it-cbe-bill - CBE_BILL_CLIENT_SECRET: it-cbe-bill-secret - CBE_BILL_JWT_SECRET: it-cbe-bill-jwt - FREIGHT_API_BASE_URL: http://freight-api-e2e:3001/api - ports: - - "${IT_PAYMENT_PORT:-3113}:3003" - healthcheck: - test: - [ - "CMD", - "node", - "-e", - "fetch('http://localhost:3003/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", - ] - interval: 5s - timeout: 5s - retries: 12 - start_period: 40s - - # Base-stack service, re-pointed at the real payment API. - freight-api-e2e: - depends_on: - payment-api-it: - condition: service_healthy - environment: - PAYMENT_API_URL: http://payment-api-it:3003 - # Freight's payment module skips RabbitMQModule entirely when this is - # unset (payment.module.ts) — without it, outbox events never arrive. - PAYMENT_RABBITMQ_URL: amqp://edr:edr_secret@rabbitmq-it:5672/payment - # RABBITMQ_ENABLED stays "false" (base stack) — it only gates the SMS/email - # clients, which must remain off. The payment consumer is wired by - # PAYMENT_RABBITMQ_URL alone. - # Drain tail on every pay window. Production defaults to 5 minutes; a - # reservation here lives ~60s, so 5 would push every natural expiry past - # the suite's 180s timeouts. One minute keeps the tail real and observable - # (src/expired-invoice-late-settle.it.ts asserts both sides of it). - FREIGHT_PAYMENT_DRAIN_MINUTES: "1" +# The per-shard `gateway-mock-it-{i}` and `payment-api-it-{i}` services live in +# integration/.it-shards.yaml. The base file's `freight-api-e2e` is never +# started — the app runs in-process (integration/src/app.ts). diff --git a/e2e/freight/cypress/e2e/flows/onboarding.cy.ts b/e2e/freight/cypress/e2e/flows/onboarding.cy.ts index bb077df6b..437c0e92e 100644 --- a/e2e/freight/cypress/e2e/flows/onboarding.cy.ts +++ b/e2e/freight/cypress/e2e/flows/onboarding.cy.ts @@ -42,21 +42,44 @@ function latestOnboardJourney() { }); } -/** Fill a labelled Mantine input (label[for] → input id). */ +/** + * Fill a labelled Mantine input (label[for] → input id). + * + * The input is resolved fresh for every action rather than captured once. + * Each wizard step persists and re-seeds asynchronously, and when a field + * remounts Mantine mints a NEW generated id — so both a subject and an id + * captured a command earlier can be stale by the time the next command runs. + * Going label → for → element each time always addresses what's on the page + * now. + */ function fill(label: string | RegExp, value: string) { - cy.contains("label", label) - .invoke("attr", "for") - .then((id) => { - cy.get(`[id="${id}"]`).clear({ force: true }).type(value, { force: true }); - }); + const input = () => + cy + .contains("label", label) + .invoke("attr", "for") + .then((id) => cy.get(`[id="${id}"]`)); + + input().clear({ force: true }); + input().type(value, { force: true }); +} + +/** + * Fill an input that has no