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