feat(freight-portal): fayda verify ui, owner/gm split, foreign passport

- FaydaVerifyPanel + /callback popup flow for owner and poa
- general manager is a plain typed role again, offers "same as
  verified owner" copy instead of being fayda-verified itself
- company step gates on owner verification (ethiopian) or typed
  passport number (foreign); poa step gates on poa verification
- settings tabs (company profile, general manager, poa) updated to
  match

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nathnael
2026-07-28 13:44:51 +00:00
parent 68ef8dc5d7
commit f1431a8353
15 changed files with 790 additions and 151 deletions

View File

@@ -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={<CheckPaymentPage />}
/>
{/* 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. */}
<Route path="/callback" element={<FaydaCallbackPage />} />
<Route path="/payment/success" element={<PaymentSuccessPage />} />
<Route path="/payment/failure" element={<PaymentFailurePage />} />

View File

@@ -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<string | null>(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<FaydaCallbackMessage>) => {
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 (
<Card padding="md" radius="md" withBorder>
<Group justify="space-between" align="center" mb={verified ? "md" : "xs"}>
<Group gap="sm">
<ShieldCheck size={18} />
<Text fw={600} c="edr-text">
{title} identity
</Text>
{verified ? (
<Badge
size="sm"
variant="light"
color="green"
leftSection={<BadgeCheck size={11} />}
>
Fayda verified
</Badge>
) : (
required && (
<Badge size="sm" variant="light" color="amber">
Verification required
</Badge>
)
)}
</Group>
<Button
type="button"
variant="light"
size="xs"
loading={loading}
disabled={disabled}
onClick={startVerification}
>
{verified ? "Re-verify with Fayda" : "Verify with Fayda"}
</Button>
</Group>
{!verified && (
<Text c="edr-muted" size="xs">
{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."}
</Text>
)}
{verified && state && (
<SimpleGrid cols={2} spacing="xs">
<VerifiedField label="Name" value={state.name} />
<VerifiedField label="Phone" value={state.phone} />
<VerifiedField label="Email" value={state.email} />
<VerifiedField label="Address" value={state.address} />
<VerifiedField label="Verified" value={formatDate(state.verifiedAt)} />
</SimpleGrid>
)}
{error && (
<Alert mt="sm" color="red" variant="light" icon={<XCircle size={18} />}>
{error}
</Alert>
)}
</Card>
);
}
function VerifiedField({
label,
value,
}: {
label: string;
value: string | null;
}) {
if (!value) return null;
return (
<Stack gap={0}>
<Text size="xs" c="edr-muted">
{label}
</Text>
<Text size="sm" fw={500} c="edr-text">
{value}
</Text>
</Stack>
);
}

View File

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

View File

@@ -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 (
<Center h="100vh">
<Stack align="center" gap="sm">
{standalone ? (
<>
<Text fw={600}>Verification window lost its parent page</Text>
<Text size="sm" c="dimmed">
Close this tab and start the verification again from the form.
</Text>
</>
) : (
<>
<Loader size="sm" color="edr-green" />
<Text size="sm" c="dimmed">
Completing Fayda verification
</Text>
</>
)}
</Stack>
</Center>
);
}

View File

@@ -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":

View File

@@ -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<string, File | File[] | null>;
@@ -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<CompanyStep>(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<FormData>({
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,7 +692,6 @@ export default function CompanyProfileForm({
error={errors.companyLocation?.message}
{...register("companyLocation")}
/>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="VAT Number"
placeholder="VAT-12345"
@@ -647,31 +699,27 @@ export default function CompanyProfileForm({
error={errors.vatNumber?.message}
{...register("vatNumber")}
/>
{identity && (
<>
<FaydaVerifyPanel
subject="owner"
title="Company owner"
state={identity.owner}
required={identity.faydaRequired}
onVerified={() => onIdentityChange?.()}
/>
{identity.passportRequired && (
<TextInput
label={
<Group gap={6} align="center" wrap="nowrap">
<span>FAN Number (16 digits)</span>
<Tooltip
label="The FAN must belong to the person with power of attorney. If the company has no power of attorney, use the general manager's FAN."
multiline
w={260}
withArrow
position="top-start"
>
<Info
size={14}
color="var(--mantine-color-gray-6)"
className="cursor-help"
label="Owner Passport Number"
placeholder="P1234567"
description="Fayda is an Ethiopian national ID, so a foreign company's owner is identified by passport instead."
error={errors.ownerPassportNumber?.message}
{...register("ownerPassportNumber")}
/>
</Tooltip>
</Group>
}
placeholder="1234567890123456"
maxLength={16}
error={errors.fanNumber?.message}
{...register("fanNumber")}
/>
</SimpleGrid>
)}
</>
)}
{hasRegistrationDetails && (
<>
@@ -778,12 +826,22 @@ export default function CompanyProfileForm({
<Text fw={600} size="sm" c="edr-text">
General Manager
</Text>
{/* 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. */}
<LinkCheckboxCard
checked={gmSameAsOwner}
onToggle={toggleGmSameAsOwner}
title="Same as business owner"
title={
identity?.owner.verified
? "Same as verified owner"
: "Same as business owner"
}
description={
etradeOwner
identity?.owner.verified
? "Reuse the Fayda-verified owner's name, email and phone. Uncheck to enter different details."
: etradeOwner
? "Reuse the eTrade-registered owner's name, plus the company email and phone as you entered them. Uncheck to enter different details."
: "Reuse your account's name and the company email and phone as you entered them. Uncheck to enter different details."
}
@@ -861,10 +919,19 @@ export default function CompanyProfileForm({
<>
<Text size="sm" c="edr-muted">
{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."}
</Text>
{watch("contactPersonName") && (
{identity && (
<FaydaVerifyPanel
subject="poa"
title="Power of Attorney"
state={identity.poa}
required={identity.faydaRequired}
onVerified={() => onIdentityChange?.()}
/>
)}
{!verifiedIdentity && watch("contactPersonName") && (
<LinkCheckboxCard
checked={poaSameAsContact}
onToggle={togglePoaSameAsContact}
@@ -872,6 +939,8 @@ export default function CompanyProfileForm({
description="Reuse the contact person's name, email and phone, plus the company's location and address. Uncheck to enter different details."
/>
)}
{!verifiedIdentity && (
<>
<TextInput
label="PoA Name"
placeholder="Authorized Representative Name"
@@ -906,6 +975,18 @@ export default function CompanyProfileForm({
{...register("poaAddress")}
/>
</SimpleGrid>
</>
)}
{/* The city is the one field the Fayda address claim does not
reliably decompose into, so it stays typed either way. */}
{verifiedIdentity && (
<TextInput
label="PoA Location"
placeholder="City, Country"
error={errors.poaLocation?.message}
{...register("poaLocation")}
/>
)}
{poaDocumentSetting && (
<>

View File

@@ -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 ?? "",

View File

@@ -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<FormData>) =>
* 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][] = [
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<CompanyStep, (keyof FormData)[]> = {
"companyAddress",
"tinNumber",
"vatNumber",
"fanNumber",
"ownerPassportNumber",
"licenceNumber",
"statusDescription",
"dateRegistered",

View File

@@ -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<typeof COMPANY_PROFILE_SCHEMA>;
@@ -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")}
/>
</Grid.Col>
<Grid.Col span={6}>
<TextInput
label="FAN Number (16 digits)"
placeholder="1234567890123456"
maxLength={16}
error={errors.fanNumber?.message}
{...register("fanNumber")}
/>
</Grid.Col>
</Grid>
<Grid>
<Grid.Col span={6}>
<TextInput
label="VAT Number (optional)"
@@ -244,6 +235,32 @@ export default function TabCompanyProfile({
/>
</Grid.Col>
</Grid>
{profile?.identity && (
<>
<FaydaVerifyPanel
subject="owner"
title="Company owner"
state={profile.identity.owner}
required={profile.identity.faydaRequired}
onVerified={() =>
queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(),
})
}
/>
{profile.identity.passportRequired && (
<TextInput
label="Owner Passport Number"
placeholder="P1234567"
description="The owner's identity credential — Fayda is an Ethiopian national ID, so a foreign company's owner is identified by passport instead."
error={errors.ownerPassportNumber?.message}
{...register("ownerPassportNumber")}
/>
)}
</>
)}
</Stack>
<Group

View File

@@ -64,7 +64,7 @@ function documentSettingCode(nationality: string | null | undefined): string {
}
/**
* The delegation letter ships in the same nationality document set, but it is
* The DARS delegation paper ships in the same nationality document set, but it is
* edited on the Power of Attorney tab (where it is staged for review alongside
* the PoA details), so it is excluded from this tab's uploader.
*/

View File

@@ -1,4 +1,4 @@
import { useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -16,6 +16,7 @@ import {
} from "@mantine/core";
import { api } from "@/services/api";
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import { LinkCheckboxCard } from "@/pages/accounts/companyProfileForm/LinkCheckboxCard";
import type { ProfileResponse } from "@/types/profile";
const schema = z.object({
@@ -35,8 +36,17 @@ interface TabGeneralManagerProps {
onContinue?: () => 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<FormData>({
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 }
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
{owner?.verified && (
<LinkCheckboxCard
checked={gmSameAsOwner}
onToggle={toggleGmSameAsOwner}
title="Same as verified owner"
description="Reuse the Fayda-verified owner's name, email and phone. Uncheck to enter different details."
/>
)}
<TextInput
label="Full Name"
placeholder="Abebe Bikila"

View File

@@ -40,6 +40,8 @@ import {
type LicenseFileStatus,
} from "@/services/companies.service";
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
import { verifaydaService } from "@/services/verifayda.service";
import type { ProfileResponse } from "@/types/profile";
const schema = z.object({
@@ -136,10 +138,16 @@ export default function TabPowerOfAttorney({
const hasLetterAfterSave = Boolean(pickedFile) || remainingLetters.length > 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.
if (pickedFile) {
await companiesService.uploadPoaDelegation(pickedFile);
} else {
for (const fileId of removeIds) {
await companiesService.removePoaDelegation(fileId);
}
}
return api.companies.updateProfile.call({
// 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);
return api.companies.updateProfile.call(fields);
}
// 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,12 +272,35 @@ export default function TabPowerOfAttorney({
</Group>
<Text c="edr-muted" size="sm" mb="lg">
{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."}
</Text>
{identity && (
<FaydaVerifyPanel
subject="poa"
title="Power of Attorney"
state={identity.poa}
required={identity.faydaRequired}
disabled={mutation.isPending}
onVerified={() => {
queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(),
});
queryClient.invalidateQueries({
queryKey: api.companies.poaDelegation.queryKey(),
});
}}
/>
)}
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
{/* 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 && (
<>
<TextInput
label="PoA Full Name"
placeholder="Authorized Representative Name"
@@ -265,6 +326,8 @@ export default function TabPowerOfAttorney({
/>
</Grid.Col>
</Grid>
</>
)}
<Grid>
<Grid.Col span={6}>
@@ -275,6 +338,7 @@ export default function TabPowerOfAttorney({
{...register("poaLocation")}
/>
</Grid.Col>
{!verifiedIdentity && (
<Grid.Col span={6}>
<TextInput
label="PoA Address"
@@ -283,6 +347,7 @@ export default function TabPowerOfAttorney({
{...register("poaAddress")}
/>
</Grid.Col>
)}
</Grid>
</Stack>
@@ -292,7 +357,7 @@ export default function TabPowerOfAttorney({
<Group gap="sm">
<FileText size={18} />
<Text fw={600} c="edr-text">
Delegation letter
DARS delegation paper
</Text>
</Group>
<Button
@@ -309,14 +374,15 @@ export default function TabPowerOfAttorney({
disabled={mutation.isPending}
onClick={() => uploadInputRef.current?.click()}
>
{hasLetterAfterSave ? "Replace letter" : "Upload letter"}
{hasLetterAfterSave ? "Replace paper" : "Upload paper"}
</Button>
</Group>
<Text c="edr-muted" size="xs">
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.
</Text>
{saveBlocked && letterMissing && (
@@ -326,8 +392,8 @@ export default function TabPowerOfAttorney({
icon={<XCircle size={18} />}
>
{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."}
</Alert>
)}
@@ -345,7 +411,7 @@ export default function TabPowerOfAttorney({
}}
>
<Text size="sm" c="edr-muted" ta="center">
No delegation letter uploaded.
No DARS delegation paper uploaded.
</Text>
</Card>
) : (
@@ -397,7 +463,7 @@ export default function TabPowerOfAttorney({
<ActionIcon
variant="subtle"
color="red"
aria-label="Discard selected letter"
aria-label="Discard selected paper"
disabled={mutation.isPending}
onClick={() => setPickedFile(null)}
>
@@ -414,7 +480,7 @@ export default function TabPowerOfAttorney({
<Group gap={6} c="edr-amber-text">
<Clock size={13} />
<Text size="xs" fw={500}>
Awaiting EDR review this letter takes effect once approved.
Awaiting EDR review this paper takes effect once approved.
</Text>
</Group>
)}
@@ -456,6 +522,20 @@ export default function TabPowerOfAttorney({
)}
</Group>
<Group gap="md">
{mode === "edit" &&
verifiedIdentity &&
identity?.poa.verified &&
!requirePoa && (
<Button
type="button"
variant="outline"
color="red"
loading={removeMutation.isPending}
onClick={() => removeMutation.mutate()}
>
Remove representative
</Button>
)}
{mode === "edit" && (
<Button
type="button"
@@ -484,7 +564,7 @@ export default function TabPowerOfAttorney({
}
/**
* One letter already on file. `pending_add` / `pending_remove` reflect a change
* One paper already on file. `pending_add` / `pending_remove` reflect a change
* request the backoffice hasn't ruled on yet; `markedForRemoval` and
* `supersededBy` are this session's unsaved edits.
*/

View File

@@ -2,6 +2,7 @@ import { client } from "@/utils/api";
import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS";
import type { ApiResponse } from "@/types/apiResponse";
import type { CompanyIdentityState } from "./verifayda.service";
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
import { isAxiosError } from "axios";
@@ -160,6 +161,8 @@ export interface OnboardingPoaState {
required: boolean;
provided: boolean;
delegationLetterUploaded: boolean;
/** True when a reviewer sent the DARS delegation paper back for correction. */
delegationLetterFlagged: boolean;
missingFields: { key: string; label: string }[];
complete: boolean;
}
@@ -179,6 +182,8 @@ export interface OnboardingRequirements {
documents: OnboardingDocumentField[];
licenseProfiles: OnboardingLicenseProfile[];
poa: OnboardingPoaState;
/** Fayda verification state; `required` is false for a foreign company. */
identity: CompanyIdentityState;
progress: { completed: number; total: number };
isComplete: boolean;
onboardingCompleted: boolean;

View File

@@ -0,0 +1,94 @@
import { client } from "@/utils/api";
import { unwrap } from "@/utils/endpoint";
import type { ApiResponse } from "@/types/apiResponse";
/**
* Which of the company's people a verification is for. The owner is NOT the
* general manager — GM is a plain typed role the portal offers a "same as
* owner" copy for, but only the owner and the PoA are ever Fayda-verified.
*/
export type IdentitySubject = "owner" | "poa";
/** One person's Fayda verification state, as the API reports it. */
export interface IdentityVerificationState {
verified: boolean;
name: string | null;
phone: string | null;
email: string | null;
address: string | null;
verifiedAt: string | null;
}
export interface OwnerIdentityState extends IdentityVerificationState {
/**
* Typed passport number — the foreign-company identity credential.
* Independent of Fayda: never written by a verification, and still required
* even if the owner also verifies.
*/
passportNumber: string | null;
}
export interface CompanyIdentityState {
/** True when Fayda verification of the owner (and PoA) is mandatory — Ethiopian companies only. */
faydaRequired: boolean;
/** True when the owner's passport number is mandatory — foreign companies only. */
passportRequired: boolean;
owner: OwnerIdentityState;
poa: IdentityVerificationState;
complete: boolean;
}
/** Message posted from the /callback popup back to the opener window. */
export interface FaydaCallbackMessage {
type: "fayda-callback";
code?: string;
state?: string;
error?: string;
errorDescription?: string;
}
export const verifaydaService = {
/**
* Returns the eSignet authorize URL to open in a popup. `PORTAL` selects the
* portal's own registered redirect_uri — the backoffice and mobile clients
* have their own.
*/
start: async (): Promise<string> => {
const response = await client.post<
ApiResponse<{ authorizationUrl: string }>
>("/api/fayda/verification/start", {
purpose: "VERIFY",
platform: "PORTAL",
});
return unwrap(response.data).authorizationUrl;
},
/**
* Exchange the callback code+state for a verified identity and bind it to one
* of the company's people. The API writes that person's name, phone, email
* and address from the Fayda payload — none of it is typed here.
*/
completeIdentity: async (
subject: IdentitySubject,
code: string,
state: string,
): Promise<CompanyIdentityState> => {
const response = await client.post<ApiResponse<CompanyIdentityState>>(
"/api/companies/identity/fayda/complete",
{ subject, code, state },
);
return unwrap(response.data);
},
/**
* Drop the Power of Attorney — verified identity, details and delegation
* paper together. A verified person's fields are locked, so blanking the form
* is no longer a way to remove them. Refused for a freight forwarder.
*/
removePoa: async (): Promise<CompanyIdentityState> => {
const response = await client.delete<ApiResponse<CompanyIdentityState>>(
"/api/companies/identity/fayda/poa",
);
return unwrap(response.data);
},
};

View File

@@ -1,4 +1,5 @@
import type { CompanyProfileResponse } from "@/services/companies.service";
import type { CompanyIdentityState } from "@/services/verifayda.service";
export interface ProfileResponse {
companyId: string;
@@ -34,6 +35,14 @@ export interface ProfileResponse {
generalManagerName: string | null;
generalManagerEmail: string | null;
generalManagerPhone: string | null;
/**
* Fayda verification state for the owner and the PoA — not the general
* manager, which stays a plain typed role. `identity.faydaRequired` /
* `identity.passportRequired` is the Ethiopian/foreign switch: an Ethiopian
* company verifies the owner (and PoA) with Fayda; a foreign one requires a
* typed passport number for the owner instead.
*/
identity: CompanyIdentityState;
poaName: string | null;
poaPhone: string | null;
poaEmail: string | null;
@@ -85,4 +94,6 @@ export interface UpdateProfilePayload {
poaEmail?: string;
poaLocation?: string;
poaAddress?: string;
/** The owner's passport number — the foreign-company identity credential. */
ownerPassportNumber?: string;
}