mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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:
@@ -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 />} />
|
||||
|
||||
|
||||
212
apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx
Normal file
212
apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
56
apps/edr-freight-web/portal/src/pages/FaydaCallbackPage.tsx
Normal file
56
apps/edr-freight-web/portal/src/pages/FaydaCallbackPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
@@ -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,39 +692,34 @@ export default function CompanyProfileForm({
|
||||
error={errors.companyLocation?.message}
|
||||
{...register("companyLocation")}
|
||||
/>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label="VAT Number"
|
||||
placeholder="VAT-12345"
|
||||
maxLength={10}
|
||||
error={errors.vatNumber?.message}
|
||||
{...register("vatNumber")}
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
}
|
||||
placeholder="1234567890123456"
|
||||
maxLength={16}
|
||||
error={errors.fanNumber?.message}
|
||||
{...register("fanNumber")}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<TextInput
|
||||
label="VAT Number"
|
||||
placeholder="VAT-12345"
|
||||
maxLength={10}
|
||||
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="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")}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasRegistrationDetails && (
|
||||
<>
|
||||
@@ -778,14 +826,24 @@ 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
|
||||
? "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."
|
||||
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."
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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 ?? "",
|
||||
|
||||
@@ -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][] = [
|
||||
["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<CompanyStep, (keyof FormData)[]> = {
|
||||
"companyAddress",
|
||||
"tinNumber",
|
||||
"vatNumber",
|
||||
"fanNumber",
|
||||
"ownerPassportNumber",
|
||||
"licenceNumber",
|
||||
"statusDescription",
|
||||
"dateRegistered",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
// 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({
|
||||
</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">
|
||||
<TextInput
|
||||
label="PoA Full Name"
|
||||
placeholder="Authorized Representative Name"
|
||||
error={errors.poaName?.message}
|
||||
{...register("poaName")}
|
||||
/>
|
||||
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
{/* 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 Email"
|
||||
type="email"
|
||||
placeholder="poa@company.com"
|
||||
error={errors.poaEmail?.message}
|
||||
{...register("poaEmail")}
|
||||
label="PoA Full Name"
|
||||
placeholder="Authorized Representative Name"
|
||||
error={errors.poaName?.message}
|
||||
{...register("poaName")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="poaPhone"
|
||||
label="PoA Phone"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label="PoA Email"
|
||||
type="email"
|
||||
placeholder="poa@company.com"
|
||||
error={errors.poaEmail?.message}
|
||||
{...register("poaEmail")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="poaPhone"
|
||||
label="PoA Phone"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
@@ -275,14 +338,16 @@ export default function TabPowerOfAttorney({
|
||||
{...register("poaLocation")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label="PoA Address"
|
||||
placeholder="Full Address"
|
||||
error={errors.poaAddress?.message}
|
||||
{...register("poaAddress")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
{!verifiedIdentity && (
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label="PoA Address"
|
||||
placeholder="Full Address"
|
||||
error={errors.poaAddress?.message}
|
||||
{...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.
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user