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 }
+ {owner?.verified && ( + + )} 0; // A freight forwarder signs on other companies' behalf, so its PoA — details - // and delegation letter both — is mandatory rather than optional. + // and DARS delegation paper both — is mandatory rather than optional. const requirePoa = profile.companyProfiles.some( (p) => p.type === "freight_forwarder", ); + // An Ethiopian company does not type its representative's details — they + // come from the Fayda verification. A foreign company keeps the typed form: + // its representative may hold no Fayda ID. + const identity = profile.identity; + const verifiedIdentity = identity?.faydaRequired === true; + const poaValues = watch([ "poaName", "poaEmail", @@ -147,7 +155,9 @@ export default function TabPowerOfAttorney({ "poaLocation", "poaAddress", ]); - const poaProvided = poaValues.some((v) => v?.trim()); + const poaProvided = verifiedIdentity + ? (identity?.poa.verified ?? false) + : poaValues.some((v) => v?.trim()); const letterRequired = requirePoa || poaProvided; const letterMissing = letterRequired && !hasLetterAfterSave; @@ -155,22 +165,32 @@ export default function TabPowerOfAttorney({ const mutation = useMutation({ mutationFn: async (data: FormData) => { - // A fresh upload already stages the removal of every live letter, so the - // explicit removals only need applying when no replacement was picked. + // Every identity field except the city is written by the verification, so + // an Ethiopian company only ever saves the paper and the location here. + const fields = verifiedIdentity + ? { poaLocation: data.poaLocation || undefined } + : { + poaName: data.poaName || undefined, + poaPhone: data.poaPhone || undefined, + poaEmail: data.poaEmail || undefined, + poaLocation: data.poaLocation || undefined, + poaAddress: data.poaAddress || undefined, + }; + // A fresh upload already stages the removal of every paper on file, so + // the explicit removals only need applying when no replacement was + // picked. Saving the details after it means the API sees the new paper. if (pickedFile) { await companiesService.uploadPoaDelegation(pickedFile); - } else { - for (const fileId of removeIds) { - await companiesService.removePoaDelegation(fileId); - } + return api.companies.updateProfile.call(fields); } - return api.companies.updateProfile.call({ - poaName: data.poaName || undefined, - poaPhone: data.poaPhone || undefined, - poaEmail: data.poaEmail || undefined, - poaLocation: data.poaLocation || undefined, - poaAddress: data.poaAddress || undefined, - }); + // Nothing replacing it, so the details go first: the API judges a removal + // against the PoA the customer is keeping, and clearing both together is + // the only way it will let the paper go. + const saved = await api.companies.updateProfile.call(fields); + for (const fileId of removeIds) { + await companiesService.removePoaDelegation(fileId); + } + return saved; }, onSuccess: () => { setPickedFile(null); @@ -185,6 +205,24 @@ export default function TabPowerOfAttorney({ }, }); + /** + * A verified representative cannot be removed by blanking the form — their + * fields are owned by the verification — so removal is its own action that + * clears the identity and the delegation paper together. + */ + const removeMutation = useMutation({ + mutationFn: () => verifaydaService.removePoa(), + onSuccess: () => { + resetAll(); + queryClient.invalidateQueries({ + queryKey: api.companies.getProfile.queryKey(), + }); + queryClient.invalidateQueries({ + queryKey: api.companies.poaDelegation.queryKey(), + }); + }, + }); + const onSubmit = (data: FormData) => { // The letter lives outside the form state, so it's gated here rather than // in the zod resolver. @@ -234,37 +272,62 @@ export default function TabPowerOfAttorney({ {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(), + }); + }} + /> + )} + - - - - + {/* Name, email, phone and address are written by the Fayda + verification for an Ethiopian company, so only the city — which + the address claim does not reliably decompose into — is typed. */} + {!verifiedIdentity && ( + <> - - - - - + + + + + + + + + + + )} @@ -275,14 +338,16 @@ export default function TabPowerOfAttorney({ {...register("poaLocation")} /> - - - + {!verifiedIdentity && ( + + + + )} @@ -292,7 +357,7 @@ export default function TabPowerOfAttorney({ - Delegation letter + DARS delegation paper - The signed letter in which the General Manager delegates the - representative above. Submitted to EDR for review together with the - details; it takes effect once approved. + The delegation paper issued by the Documents Authentication and + Registration Service (DARS) for the representative above — the + authenticated copy, not a plain letter. Submitted to EDR for review + together with the details; it takes effect once approved. {saveBlocked && letterMissing && ( @@ -326,8 +392,8 @@ export default function TabPowerOfAttorney({ icon={} > {requirePoa - ? "Upload the delegation letter before saving — it is required for freight forwarders." - : "Upload the delegation letter for the representative you named, or clear the PoA details."} + ? "Upload the DARS delegation paper before saving — it is required for freight forwarders." + : "Upload the DARS delegation paper for the representative you named, or clear the PoA details."} )} @@ -345,7 +411,7 @@ export default function TabPowerOfAttorney({ }} > - No delegation letter uploaded. + No DARS delegation paper uploaded. ) : ( @@ -397,7 +463,7 @@ export default function TabPowerOfAttorney({ setPickedFile(null)} > @@ -414,7 +480,7 @@ export default function TabPowerOfAttorney({ - Awaiting EDR review — this letter takes effect once approved. + Awaiting EDR review — this paper takes effect once approved. )} @@ -456,6 +522,20 @@ export default function TabPowerOfAttorney({ )} + {mode === "edit" && + verifiedIdentity && + identity?.poa.verified && + !requirePoa && ( + + )} {mode === "edit" && (