diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx
index 72674363c..3a010663c 100644
--- a/apps/edr-freight-web/portal/src/App.tsx
+++ b/apps/edr-freight-web/portal/src/App.tsx
@@ -55,6 +55,7 @@ import NewShipmentPage from "./pages/contracts/NewShipmentPage";
import NewShipmentRequestPage from "./pages/contracts/NewShipmentRequestPage";
import CheckPaymentPage from "./pages/payments/CheckPaymentPage";
import PaymentFailurePage from "./pages/payments/PaymentFailurePage";
+import FaydaCallbackPage from "./pages/FaydaCallbackPage";
import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage";
import TrackingPage from "./pages/tracking/TrackingPage";
@@ -262,6 +263,9 @@ const App = () => {
element={}
/>
{/* 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. */}
+ } />
} />
} />
diff --git a/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx b/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx
new file mode 100644
index 000000000..6897a1a33
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx
@@ -0,0 +1,212 @@
+import { useEffect, useRef, useState } from "react";
+import {
+ Alert,
+ Badge,
+ Button,
+ Card,
+ Group,
+ SimpleGrid,
+ Stack,
+ Text,
+} from "@mantine/core";
+import { BadgeCheck, ShieldCheck, XCircle } from "lucide-react";
+
+import {
+ verifaydaService,
+ type CompanyIdentityState,
+ type FaydaCallbackMessage,
+ type IdentitySubject,
+ type IdentityVerificationState,
+} from "@/services/verifayda.service";
+
+interface FaydaVerifyPanelProps {
+ subject: IdentitySubject;
+ /** Heading — "General Manager" / "Power of Attorney". */
+ title: string;
+ /** What this person's verification is currently known to be. */
+ state?: IdentityVerificationState;
+ /**
+ * False for a foreign company: verification is offered but nothing is gated
+ * 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;
+}
+
+function formatDate(iso: string | null): string {
+ if (!iso) return "";
+ const d = new Date(iso);
+ return Number.isNaN(d.getTime()) ? "" : d.toLocaleDateString();
+}
+
+/**
+ * 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.
+ */
+export default function FaydaVerifyPanel({
+ subject,
+ title,
+ state,
+ required,
+ onVerified,
+ disabled,
+}: 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;
+
+ useEffect(() => {
+ const onMessage = async (event: MessageEvent) => {
+ if (event.origin !== window.location.origin) return;
+ if (event.data?.type !== "fayda-callback") return;
+
+ if (event.data.error) {
+ setLoading(false);
+ setError(event.data.errorDescription ?? event.data.error);
+ return;
+ }
+ if (!event.data.code || !event.data.state) return;
+
+ 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);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const startVerification = async () => {
+ setError(null);
+ setLoading(true);
+ 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.");
+ }
+ // Loading stays on until the popup posts back.
+ } catch (err) {
+ setLoading(false);
+ setError(
+ (err as { response?: { data?: { message?: string } } })?.response?.data
+ ?.message ??
+ (err instanceof Error ? err.message : "Could not start verification"),
+ );
+ }
+ };
+
+ const verified = state?.verified ?? false;
+
+ return (
+
+
+
+
+
+ {title} identity
+
+ {verified ? (
+ }
+ >
+ Fayda verified
+
+ ) : (
+ required && (
+
+ Verification required
+
+ )
+ )}
+
+
+
+
+ {!verified && (
+
+ {required
+ ? "Verify this person with Fayda. Their name, phone and address come from the verification — there is nothing to fill in by hand."
+ : "Optional for a foreign company. If this person holds a Fayda ID, verifying it fills in their details."}
+
+ )}
+
+ {verified && state && (
+
+
+
+
+
+
+
+ )}
+
+ {error && (
+ }>
+ {error}
+
+ )}
+
+ );
+}
+
+function VerifiedField({
+ label,
+ value,
+}: {
+ label: string;
+ value: string | null;
+}) {
+ if (!value) return null;
+ return (
+
+
+ {label}
+
+
+ {value}
+
+
+ );
+}
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 55c63ac52..4ac5f6c55 100644
--- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx
+++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx
@@ -424,6 +424,14 @@ export default function OnboardingWizardDialog({
onLicenseChange: setLicenseFiles,
uploadedDocumentKeys,
onUploadDocuments: handleUploadDocuments,
+ // Fayda verification state for the owner and the PoA — the general manager
+ // stays a plain typed role. Mandatory (Fayda) for an Ethiopian company;
+ // a foreign one requires a typed passport number for the owner instead.
+ identity: requirementsQuery.data?.identity,
+ onIdentityChange: () => {
+ void profileQuery.refetch();
+ void requirementsQuery.refetch();
+ },
// Surface a failed final submit (license/document upload or complete) inside
// the form — otherwise the server message (e.g. a 500) would be invisible on
// the submit step.
diff --git a/apps/edr-freight-web/portal/src/pages/FaydaCallbackPage.tsx b/apps/edr-freight-web/portal/src/pages/FaydaCallbackPage.tsx
new file mode 100644
index 000000000..c25dc836a
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/pages/FaydaCallbackPage.tsx
@@ -0,0 +1,56 @@
+import { useEffect, useState } from "react";
+import { Center, Loader, Stack, Text } from "@mantine/core";
+
+import type { FaydaCallbackMessage } 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.
+ */
+export default function FaydaCallbackPage() {
+ const [standalone, setStandalone] = useState(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 (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);
+ }
+ }, []);
+
+ return (
+
+
+ {standalone ? (
+ <>
+ Verification window lost its parent page
+
+ Close this tab and start the verification again from the form.
+
+ >
+ ) : (
+ <>
+
+
+ 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 755691607..5680499c7 100644
--- a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx
@@ -63,13 +63,22 @@ type SettingsTab =
/** A section is "incomplete" when its required fields aren't filled in yet. */
function tabIncomplete(tabId: SettingsTab, profile: ProfileResponse): boolean {
switch (tabId) {
- case "company":
+ case "company": {
+ // Identity proof lives here: the owner's Fayda verification for an
+ // Ethiopian company, or the owner's typed passport number for a foreign
+ // one.
+ const identity = profile.identity;
+ const identityIncomplete = identity
+ ? (identity.faydaRequired && !identity.owner.verified) ||
+ (identity.passportRequired && !identity.owner.passportNumber)
+ : false;
return (
!profile.companyEmail ||
!profile.companyPhone ||
!profile.companyAddress ||
- !profile.fanNumber
+ identityIncomplete
);
+ }
case "contact":
return !profile.contactPersonName || !profile.contactPersonPhone;
case "gm":
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 dd68c7006..b068c0a6f 100644
--- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx
+++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx
@@ -9,11 +9,10 @@ import {
Stack,
Text,
TextInput,
- Tooltip,
} from "@mantine/core";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
-import { AlertCircle, ArrowLeft, ArrowRight, Info } from "lucide-react";
+import { AlertCircle, ArrowLeft, ArrowRight } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Controller, useForm } from "react-hook-form";
@@ -43,6 +42,8 @@ import {
stepPayload,
toFormValues,
} from "./companyProfileForm/helpers";
+import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
+import type { CompanyIdentityState } from "@/services/verifayda.service";
import { LinkCheckboxCard } from "./companyProfileForm/LinkCheckboxCard";
import { ReadOnlyField } from "./companyProfileForm/ReadOnlyField";
@@ -65,6 +66,8 @@ export default function CompanyProfileForm({
submitError,
uploadedDocumentKeys,
onUploadDocuments,
+ identity,
+ onIdentityChange,
}: {
documentSettingCode: string;
documentFiles?: Record;
@@ -102,6 +105,10 @@ export default function CompanyProfileForm({
onUploadDocuments?: () => Promise<
{ ok: true } | { ok: false; error: string }
>;
+ /** Fayda verification state for the owner and the PoA (undefined until loaded). */
+ identity?: CompanyIdentityState;
+ /** Refetch the profile + requirements once a verification lands. */
+ onIdentityChange?: () => void;
}) {
const [step, setStep] = useState(initialStep ?? "company");
const [saving, setSaving] = useState(false);
@@ -161,10 +168,14 @@ export default function CompanyProfileForm({
);
// A freight forwarder signs on other companies' behalf, so its Power of
- // Attorney (details + delegation letter) is mandatory rather than optional.
+ // Attorney (details + DARS delegation paper) is mandatory rather than optional.
const requirePoa = (roleProfiles ?? []).some(
(p) => p.type === "freight_forwarder",
);
+ // Fayda is an Ethiopian national ID: an Ethiopian company verifies its owner
+ // and PoA instead of typing their details, a foreign one keeps the typed
+ // forms (plus a mandatory owner passport number).
+ const verifiedIdentity = identity?.faydaRequired === true;
const {
register,
@@ -175,7 +186,13 @@ export default function CompanyProfileForm({
setValue,
formState: { errors },
} = useForm({
- resolver: zodResolver(buildOnboardingSchema(requirePoa)),
+ resolver: zodResolver(
+ buildOnboardingSchema(
+ requirePoa,
+ verifiedIdentity,
+ identity?.passportRequired === true,
+ ),
+ ),
defaultValues: {
companyName: "",
companyEmail: "",
@@ -184,7 +201,7 @@ export default function CompanyProfileForm({
companyAddress: "",
tinNumber: "",
vatNumber: "",
- fanNumber: "",
+ ownerPassportNumber: "",
licenceNumber: "",
statusDescription: "",
dateRegistered: "",
@@ -302,10 +319,19 @@ export default function CompanyProfileForm({
// field of its own, so it falls back to the registering user's account name.
const companyEmail = watch("companyEmail");
const companyPhone = watch("companyPhone");
- const gmSourceName = etradeOwner?.name ?? user.name?.en ?? "";
- const gmSourceEmail = companyEmail || user.email || "";
+ // A Fayda-verified owner outranks eTrade's registered owner — it's the
+ // higher-trust source, and the whole point of proving identity is to stop
+ // trusting typed/looked-up data for this.
+ const gmSourceName =
+ identity?.owner.name ?? etradeOwner?.name ?? user.name?.en ?? "";
+ const gmSourceEmail =
+ identity?.owner.email ?? (companyEmail || user.email || "");
const gmSourcePhone =
- companyPhone || etradeOwner?.phone || toEthiopianE164(user.phoneNumber) || "";
+ identity?.owner.phone ??
+ companyPhone ??
+ etradeOwner?.phone ??
+ toEthiopianE164(user.phoneNumber) ??
+ "";
useEffect(() => {
if (!gmSameAsOwner) return;
@@ -387,10 +413,11 @@ export default function CompanyProfileForm({
}
};
- // The delegation letter is seeded into the same nationality document set as
- // the rest, but belongs on the PoA step next to the details it evidences —
- // so it's split out here and the Documents step renders the remainder. Both
- // halves share `documentFiles`, so the existing bulk upload still carries it.
+ // The DARS delegation paper ships in the same nationality document set as the
+ // rest (the API guarantees it is there), but belongs on the PoA step next to
+ // the details it evidences — so it's split out here and the Documents step
+ // renders the remainder. Both halves share `documentFiles`, so the existing
+ // bulk upload still carries it.
const poaDocumentField = uploadSetting?.fields?.find(
(f) => f.fileKey === POA_DELEGATION_FILE_KEY,
);
@@ -507,13 +534,13 @@ export default function CompanyProfileForm({
];
const currentIdx = stepOrder.indexOf(step);
- // The delegation letter is what proves the representative was actually
+ // 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. Skipped entirely
- // when the document set predates the field (seeder not yet re-run).
+ // 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
+ // round-trip.
const poaProvided = hasPoaDetails(watch());
- const delegationRequired =
- Boolean(poaDocumentField) && (requirePoa || poaProvided);
+ const delegationRequired = requirePoa || poaProvided;
const delegationPresent =
(uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) ||
(() => {
@@ -572,15 +599,41 @@ export default function CompanyProfileForm({
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
return;
}
+ // The owner's identity is proved outside the form state too, so it gates
+ // here rather than through zod.
+ if (
+ step === "company" &&
+ identity &&
+ ((identity.faydaRequired && !identity.owner.verified) ||
+ (identity.passportRequired && !identity.owner.passportNumber))
+ ) {
+ setSaveError(
+ identity.faydaRequired
+ ? "Verify the company owner's identity with Fayda before continuing."
+ : "Add the company owner's passport number before continuing.",
+ );
+ return;
+ }
+ if (
+ step === "poa" &&
+ verifiedIdentity &&
+ requirePoa &&
+ !identity?.poa.verified
+ ) {
+ setSaveError(
+ "Freight forwarders act on other companies' behalf, so the Power of Attorney's identity must be verified with Fayda.",
+ );
+ return;
+ }
// The PoA step also gates on a file, which lives outside the form state.
if (step === "poa" && delegationRequired && !delegationPresent) {
setDocumentFieldErrors({
- [POA_DELEGATION_FILE_KEY]: "Delegation letter is required",
+ [POA_DELEGATION_FILE_KEY]: "DARS delegation paper is required",
});
setSaveError(
requirePoa
- ? "Freight forwarders must provide Power of Attorney details and a delegation letter."
- : "Upload the delegation letter for the Power of Attorney you entered, or clear the PoA details to skip.",
+ ? "Freight forwarders must provide Power of Attorney details and the DARS delegation paper."
+ : "Upload the DARS delegation paper for the Power of Attorney you entered, or clear the PoA details to skip.",
);
// Fall through to validate the text fields too, so every problem shows at once.
await trigger(stepFields.poa);
@@ -639,39 +692,34 @@ export default function CompanyProfileForm({
error={errors.companyLocation?.message}
{...register("companyLocation")}
/>
-
-
-
- FAN Number (16 digits)
-
-
-
-
- }
- placeholder="1234567890123456"
- maxLength={16}
- error={errors.fanNumber?.message}
- {...register("fanNumber")}
- />
-
+
+
+ {identity && (
+ <>
+ onIdentityChange?.()}
+ />
+ {identity.passportRequired && (
+
+ )}
+ >
+ )}
{hasRegistrationDetails && (
<>
@@ -778,14 +826,24 @@ export default function CompanyProfileForm({
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. */}
{requirePoa
- ? "As a freight forwarder you act on other companies' behalf, so Power of Attorney details and a delegation letter are required."
- : "Power of Attorney details are optional. Fill them in if you have them, or skip to continue. If you do enter a representative, upload the delegation letter authorising them."}
+ ? "As a freight forwarder you act on other companies' behalf, so Power of Attorney details and the DARS delegation paper are required."
+ : "Power of Attorney details are optional. Fill them in if you have them, or skip to continue. If you do enter a representative, upload the delegation paper authenticated by DARS."}
- {watch("contactPersonName") && (
+ {identity && (
+ onIdentityChange?.()}
+ />
+ )}
+ {!verifiedIdentity && watch("contactPersonName") && (
)}
+ {!verifiedIdentity && (
+ <>
+ >
+ )}
+ {/* The city is the one field the Fayda address claim does not
+ reliably decompose into, so it stays typed either way. */}
+ {verifiedIdentity && (
+
+ )}
{poaDocumentSetting && (
<>
diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts
index 8f8a24eca..b6b7fbbf0 100644
--- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts
+++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts
@@ -29,8 +29,8 @@ export function buildPayload(
companyAddress: data.companyAddress,
tin: data.tinNumber,
vatNumber: data.vatNumber,
- fanNumber: data.fanNumber,
attributes: {
+ ownerPassportNumber: data.ownerPassportNumber || undefined,
contactPersonName: data.contactPersonName,
contactPersonPosition: data.contactPersonPosition || undefined,
contactPersonEmail: data.contactPersonEmail || undefined,
@@ -62,7 +62,7 @@ export function stepPayload(
companyAddress: d.companyAddress,
tin: d.tinNumber,
vatNumber: d.vatNumber,
- fanNumber: d.fanNumber,
+ ownerPassportNumber: d.ownerPassportNumber || undefined,
licenceNumber: d.licenceNumber,
statusDescription: d.statusDescription,
dateRegistered: d.dateRegistered,
@@ -114,7 +114,7 @@ export function toFormValues(p: ProfileResponse): FormData {
companyAddress: p.companyAddress ?? "",
tinNumber: tin,
vatNumber: p.vatNumber ?? "",
- fanNumber: p.fanNumber ?? "",
+ ownerPassportNumber: p.identity?.owner.passportNumber ?? "",
licenceNumber: p.licenceNumber ?? "",
statusDescription: p.statusDescription ?? "",
dateRegistered: p.dateRegistered ?? "",
diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts
index 275b8a4fa..dab536bba 100644
--- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts
+++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts
@@ -27,7 +27,10 @@ export const onboardingSchema = z.object({
.string()
.min(1, "VAT number is required")
.length(10, "VAT number must be exactly 10 digits"),
- fanNumber: z.string().length(16, "FAN must be exactly 16 digits"),
+ // The owner's passport number — the foreign-company identity credential
+ // (Fayda is an Ethiopian national ID). Required only for a foreign company;
+ // enforced in buildOnboardingSchema since that depends on `nationality`.
+ ownerPassportNumber: z.string().optional(),
licenceNumber: z.string().optional(),
statusDescription: z.string().optional(),
dateRegistered: z.string().optional(),
@@ -109,14 +112,35 @@ export const hasPoaDetails = (d: Partial) =>
* delegation-letter upload is enforced alongside this, in CompanyProfileForm,
* since files live outside the form state).
*/
-export function buildOnboardingSchema(requirePoa: boolean) {
- if (!requirePoa) return onboardingSchema;
+export function buildOnboardingSchema(
+ requirePoa: boolean,
+ /**
+ * True when the PoA's identity fields come from a Fayda verification rather
+ * than the form (Ethiopian companies). Requiring them here would fail
+ * validation against inputs the step no longer renders — the verification
+ * itself is what the step gates on instead.
+ */
+ faydaOwnedPoa = false,
+ /** True for a foreign company: the owner's passport number is mandatory. */
+ passportRequired = false,
+) {
+ const poaRequired = requirePoa && !faydaOwnedPoa;
+ if (!poaRequired && !passportRequired) return onboardingSchema;
return onboardingSchema.superRefine((d, ctx) => {
- const required: [keyof FormData, string][] = [
- ["poaName", "PoA name is required for freight forwarders"],
- ["poaEmail", "PoA email is required for freight forwarders"],
- ["poaPhone", "PoA phone is required for freight forwarders"],
- ];
+ const required: [keyof FormData, string][] = [];
+ if (poaRequired) {
+ required.push(
+ ["poaName", "PoA name is required for freight forwarders"],
+ ["poaEmail", "PoA email is required for freight forwarders"],
+ ["poaPhone", "PoA phone is required for freight forwarders"],
+ );
+ }
+ if (passportRequired) {
+ required.push([
+ "ownerPassportNumber",
+ "The owner's passport number is required",
+ ]);
+ }
for (const [path, message] of required) {
if (!d[path]?.trim()) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: [path], message });
@@ -134,7 +158,7 @@ export const stepFields: Record = {
"companyAddress",
"tinNumber",
"vatNumber",
- "fanNumber",
+ "ownerPassportNumber",
"licenceNumber",
"statusDescription",
"dateRegistered",
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 597dfa509..57d589bdc 100644
--- a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx
+++ b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx
@@ -22,6 +22,7 @@ import { useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import OnboardingRoleSelect from "./OnboardingRoleSelect";
+import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
export const COMPANY_PROFILE_SCHEMA = z.object({
companyName: z.string().min(1, "Company name is required"),
@@ -33,13 +34,13 @@ export const COMPANY_PROFILE_SCHEMA = z.object({
companyLocation: z.string().min(1, "Location is required"),
companyAddress: z.string().min(1, "Address is required"),
tinNumber: z.string().regex(/^\d{10}$/, "TIN must be exactly 10 digits"),
- fanNumber: z.string().length(16, "FAN must be exactly 16 digits"),
vatNumber: z
.string()
.trim()
.max(20, "VAT number is too long")
.optional()
.or(z.literal("")),
+ ownerPassportNumber: z.string().optional(),
});
export type CompanyProfileFormData = z.infer;
@@ -68,8 +69,8 @@ export default function TabCompanyProfile({
companyLocation: profile.companyLocation,
companyAddress: profile.companyAddress ?? "",
tinNumber: profile.tinNumber,
- fanNumber: profile.fanNumber ?? "",
vatNumber: profile.vatNumber ?? "",
+ ownerPassportNumber: profile.identity?.owner.passportNumber ?? "",
};
}
return {
@@ -79,8 +80,8 @@ export default function TabCompanyProfile({
companyLocation: "",
companyAddress: "",
tinNumber: "",
- fanNumber: "",
vatNumber: "",
+ ownerPassportNumber: "",
};
}, [profile]);
@@ -104,8 +105,10 @@ export default function TabCompanyProfile({
companyLocation: data.companyLocation,
companyAddress: data.companyAddress,
tin: data.tinNumber,
- fanNumber: data.fanNumber,
vatNumber: data.vatNumber ?? "",
+ ...(data.ownerPassportNumber !== undefined
+ ? { ownerPassportNumber: data.ownerPassportNumber }
+ : {}),
};
if (isCreate) {
@@ -222,18 +225,6 @@ export default function TabCompanyProfile({
{...register("tinNumber")}
/>
-
-
-
-
-
-
+
+ {profile?.identity && (
+ <>
+
+ queryClient.invalidateQueries({
+ queryKey: api.companies.getProfile.queryKey(),
+ })
+ }
+ />
+ {profile.identity.passportRequired && (
+
+ )}
+ >
+ )}
+
void;
}
+/**
+ * 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.
+ */
export default function TabGeneralManager({ profile, mode = "edit", onContinue }: TabGeneralManagerProps) {
const queryClient = useQueryClient();
+ const owner = profile.identity?.owner;
+ const [gmSameAsOwner, setGmSameAsOwner] = useState(false);
const defaultValues = useMemo((): FormData => {
return {
@@ -51,12 +61,32 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue }
control,
handleSubmit,
reset,
+ setValue,
formState: { errors, isDirty },
} = useForm({
resolver: zodResolver(schema),
values: defaultValues,
});
+ const toggleGmSameAsOwner = (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 });
+ }
+ };
+
+ // 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({
@@ -84,6 +114,14 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue }
{requirePoa
- ? "As a freight forwarder you act on other companies' behalf, so a Power of Attorney and its delegation letter are required."
- : "Power of Attorney details are optional. If you name a representative, upload the delegation letter authorising them."}
+ ? "As a freight forwarder you act on other companies' behalf, so a Power of Attorney and its DARS delegation paper are required."
+ : "Power of Attorney details are optional. If you name a representative, upload the DARS delegation paper authorising them."}
+ {identity && (
+ {
+ queryClient.invalidateQueries({
+ queryKey: api.companies.getProfile.queryKey(),
+ });
+ queryClient.invalidateQueries({
+ queryKey: api.companies.poaDelegation.queryKey(),
+ });
+ }}
+ />
+ )}
+