feat(freight-portal): verify Fayda via redirect, gate DARS on verification

The verification popup was opened after the /start round-trip, by which
point the click's user activation is spent — iOS Safari blocks it outright,
so mobile customers could never verify. Replace the popup with a full-page
redirect: the panel stashes {subject, returnTo} in sessionStorage and
navigates the tab to eSignet, and /callback completes the code+state
exchange itself before returning the user where they were.

This drops the postMessage listener, the popup-closed poller and the
pop-up-blocked branch. onVerified goes with them: the app boots fresh on
the way back, so the target page refetches rather than being pushed to.

Hide the DARS delegation upload until the PoA is Fayda-verified. The paper
authorises the representative the verification names, so it has nothing to
authorise before one exists — and it has to stop being required while
hidden, or the save blocks on a control the customer cannot see. Freight
forwarders are still held to having a PoA by the step's verification gate
and by the API. This also removes the one thing the redirect could not
carry across: a staged File cannot be serialised to sessionStorage, and
there is now never one pending before verification.

Unsaved text typed since the last step-save is still lost on redirect; the
wizard's per-step persistence covers everything already advanced past.
This commit is contained in:
Nathnael
2026-08-04 10:52:49 +00:00
parent 3a5126670f
commit 53accbc57b
8 changed files with 183 additions and 157 deletions

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, type ReactNode } from "react"; import { useState, type ReactNode } from "react";
import { import {
Alert, Alert,
Avatar, Avatar,
@@ -20,9 +20,8 @@ import {
} from "lucide-react"; } from "lucide-react";
import { import {
stashPendingVerification,
verifaydaService, verifaydaService,
type CompanyIdentityState,
type FaydaCallbackMessage,
type IdentitySubject, type IdentitySubject,
type IdentityVerificationState, type IdentityVerificationState,
} from "@/services/verifayda.service"; } from "@/services/verifayda.service";
@@ -38,8 +37,6 @@ interface FaydaVerifyPanelProps {
* on it, so the panel says so rather than nagging. * on it, so the panel says so rather than nagging.
*/ */
required: boolean; required: boolean;
/** Called with the fresh company-wide state once a verification lands. */
onVerified: (next: CompanyIdentityState) => void;
disabled?: boolean; disabled?: boolean;
/** /**
* True when a fresh verification for this person is already staged in a * True when a fresh verification for this person is already staged in a
@@ -61,109 +58,39 @@ function getInitials(name: string | null): string {
/** /**
* Verify one of the company's people through Fayda and show what came back. * 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, * The identity is proved on eSignet, which the whole tab navigates to — no
* which relays the code+state here by postMessage. This window then completes * popup, because a popup opened after the /start round-trip has lost its user
* the exchange — once, in one place — and the API writes the person's name, * activation and iOS Safari blocks it outright. eSignet redirects back to
* phone, email and address from the verified payload. Nothing on this panel * /callback, which completes the exchange and returns the user here; the API
* is typed. * writes the person's name, phone, email and address from the verified
* payload. Nothing on this panel is typed.
*/ */
export default function FaydaVerifyPanel({ export default function FaydaVerifyPanel({
subject, subject,
title, title,
state, state,
required, required,
onVerified,
disabled, disabled,
pendingReview, pendingReview,
}: FaydaVerifyPanelProps) { }: FaydaVerifyPanelProps) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); 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;
// FaydaCallbackPage posts its message from a StrictMode-double-invoked
// effect in dev, so the same one-time-use code+state can arrive twice.
// Track the last state we've started completing so the resend is a no-op.
const handledStateRef = useRef<string | null>(null);
// Polls the popup so a manually-closed window (no postMessage ever sent)
// still clears `loading` instead of leaving the button spinning forever.
const pollRef = useRef<number | null>(null);
const stopPolling = () => {
if (pollRef.current !== null) {
window.clearInterval(pollRef.current);
pollRef.current = null;
}
};
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) {
stopPolling();
setLoading(false);
setError(event.data.errorDescription ?? event.data.error);
return;
}
if (!event.data.code || !event.data.state) return;
if (handledStateRef.current === event.data.state) return;
handledStateRef.current = event.data.state;
stopPolling();
try {
const next = await verifaydaService.completeIdentity(
subjectRef.current,
event.data.code,
event.data.state,
);
setError(null);
onVerified(next);
} catch (err) {
setError(
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message ??
(err instanceof Error ? err.message : "Verification failed"),
);
} finally {
setLoading(false);
}
};
window.addEventListener("message", onMessage);
return () => {
window.removeEventListener("message", onMessage);
stopPolling();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const startVerification = async () => { const startVerification = async () => {
setError(null); setError(null);
setLoading(true); setLoading(true);
handledStateRef.current = null;
try { try {
const authorizationUrl = await verifaydaService.start(); const authorizationUrl = await verifaydaService.start();
const popup = window.open( // Record who is being verified and where to come back to before the tab
authorizationUrl, // leaves — /callback has no other way to know either.
"fayda-verify", stashPendingVerification({
"width=480,height=760,noopener=no", subject,
); returnTo:
if (!popup) { window.location.pathname +
setLoading(false); window.location.search +
setError("Pop-up blocked — allow pop-ups for this site and try again."); window.location.hash,
return; });
} window.location.assign(authorizationUrl);
// Loading stays on until the popup posts back — unless the user closes
// it by hand, which never sends a message; poll for that and clear
// loading ourselves so the button doesn't spin forever.
stopPolling();
pollRef.current = window.setInterval(() => {
if (!popup.closed) return;
stopPolling();
if (handledStateRef.current === null) setLoading(false);
}, 500);
} catch (err) { } catch (err) {
setLoading(false); setLoading(false);
setError( setError(

View File

@@ -437,10 +437,6 @@ export default function OnboardingWizardDialog({
// stays a plain typed role. Mandatory (Fayda) for an Ethiopian company; // stays a plain typed role. Mandatory (Fayda) for an Ethiopian company;
// a foreign one requires a typed passport number for the owner instead. // a foreign one requires a typed passport number for the owner instead.
identity: requirementsQuery.data?.identity, identity: requirementsQuery.data?.identity,
onIdentityChange: () => {
void profileQuery.refetch();
void requirementsQuery.refetch();
},
// Surface a failed final submit (license/document upload or complete) inside // 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 form — otherwise the server message (e.g. a 500) would be invisible on
// the submit step. // the submit step.

View File

@@ -1,51 +1,89 @@
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Center, Loader, Stack, Text } from "@mantine/core"; import { useNavigate } from "react-router-dom";
import { Button, Center, Loader, Stack, Text } from "@mantine/core";
import type { FaydaCallbackMessage } from "@/services/verifayda.service"; import {
takePendingVerification,
verifaydaService,
} from "@/services/verifayda.service";
/** /**
* Landing page for the portal's eSignet redirect_uri * Landing page for the portal's eSignet redirect_uri
* (FAYDA_PORTAL_REDIRECT_URI → http://localhost:5173/callback). Runs inside the * (FAYDA_PORTAL_REDIRECT_URI → http://localhost:5173/callback).
* verification popup: relays ?code&state (or ?error) to the window that opened *
* it via postMessage, then closes itself. The opener performs the completion * The verification is a full-page redirect, so the page that started it no
* call so the single-use session is only consumed once, in one place. * longer exists: this page completes the code+state exchange itself against
* the subject FaydaVerifyPanel stashed, then sends the user back where they
* were. Everything mounts fresh on the way back, so the verified identity is
* fetched rather than pushed.
*/ */
export default function FaydaCallbackPage() { export default function FaydaCallbackPage() {
const [standalone, setStandalone] = useState(false); const navigate = useNavigate();
const [error, setError] = useState<string | null>(null);
const [returnTo, setReturnTo] = useState("/");
// The code+state are single-use, so StrictMode's double-invoked effect must
// not exchange them twice — the second attempt would fail on a spent session.
const startedRef = useRef(false);
useEffect(() => { useEffect(() => {
const params = new URLSearchParams(window.location.search); if (startedRef.current) return;
const message: FaydaCallbackMessage = { startedRef.current = true;
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) { const params = new URLSearchParams(window.location.search);
(window.opener as Window).postMessage(message, window.location.origin); const pending = takePendingVerification();
window.close(); if (pending) setReturnTo(pending.returnTo);
} else {
// Opened as a full-page redirect instead of a popup — nothing to relay to. const authError = params.get("error");
setStandalone(true); if (authError) {
setError(params.get("error_description") ?? authError);
return;
} }
}, []);
const code = params.get("code");
const state = params.get("state");
if (!code || !state) {
setError("This verification link is missing its code — start again.");
return;
}
if (!pending) {
// Landed here without the tab that started it — a bookmarked/copied
// callback URL, or sessionStorage cleared mid-flow.
setError("This verification was started somewhere else — start again.");
return;
}
verifaydaService
.completeIdentity(pending.subject, code, state)
.then(() => navigate(pending.returnTo, { replace: true }))
.catch((err) =>
setError(
(err as { response?: { data?: { message?: string } } })?.response
?.data?.message ??
(err instanceof Error ? err.message : "Verification failed"),
),
);
}, [navigate]);
return ( return (
<Center h="100vh"> <Center h="100vh">
<Stack align="center" gap="sm"> <Stack align="center" gap="sm">
{standalone ? ( {error ? (
<> <>
<Text fw={600}>Verification window lost its parent page</Text> <Text fw={600}>Verification could not be completed</Text>
<Text size="sm" c="dimmed"> <Text size="sm" c="edr-muted" ta="center" maw={360}>
Close this tab and start the verification again from the form. {error}
</Text> </Text>
<Button
variant="light"
onClick={() => navigate(returnTo, { replace: true })}
>
Go back
</Button>
</> </>
) : ( ) : (
<> <>
<Loader size="sm" color="edr-green" /> <Loader size="sm" color="edr-green" />
<Text size="sm" c="dimmed"> <Text size="sm" c="edr-muted">
Completing Fayda verification Completing Fayda verification
</Text> </Text>
</> </>

View File

@@ -67,7 +67,6 @@ export default function CompanyProfileForm({
uploadedDocumentKeys, uploadedDocumentKeys,
onUploadDocuments, onUploadDocuments,
identity, identity,
onIdentityChange,
}: { }: {
documentSettingCode: string; documentSettingCode: string;
documentFiles?: Record<string, File | File[] | null>; documentFiles?: Record<string, File | File[] | null>;
@@ -107,8 +106,6 @@ export default function CompanyProfileForm({
>; >;
/** Fayda verification state for the owner and the PoA (undefined until loaded). */ /** Fayda verification state for the owner and the PoA (undefined until loaded). */
identity?: CompanyIdentityState; identity?: CompanyIdentityState;
/** Refetch the profile + requirements once a verification lands. */
onIdentityChange?: () => void;
}) { }) {
const [step, setStep] = useState<CompanyStep>(initialStep ?? "company"); const [step, setStep] = useState<CompanyStep>(initialStep ?? "company");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -535,14 +532,16 @@ export default function CompanyProfileForm({
const currentIdx = stepOrder.indexOf(step); const currentIdx = stepOrder.indexOf(step);
// The DARS delegation paper 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 // delegated, so it's required the moment a PoA exists. The API enforces the
// for a freight forwarder, whose PoA itself is mandatory. The API enforces // same rule on save, so skipping it here only costs the customer a
// the same rule on save, so skipping it here only costs the customer a
// round-trip. // round-trip.
// A PoA exists exactly when one has been verified — the details are the // A PoA exists exactly when one has been verified — the details are the
// verification's output, so there is nothing else that could stand for one. // verification's output, so there is nothing else that could stand for one.
// Until then the upload is hidden: there is no representative for the paper
// to authorise, and a freight forwarder is held on the verification gate
// below rather than on a file field it cannot yet fill.
const poaProvided = identity?.poa.verified ?? false; const poaProvided = identity?.poa.verified ?? false;
const delegationRequired = requirePoa || poaProvided; const delegationRequired = poaProvided;
const delegationPresent = const delegationPresent =
(uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) || (uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) ||
(() => { (() => {
@@ -714,7 +713,6 @@ export default function CompanyProfileForm({
title="Owner" title="Owner"
state={identity.owner} state={identity.owner}
required={identity.faydaRequired} required={identity.faydaRequired}
onVerified={() => onIdentityChange?.()}
/> />
{identity.passportRequired && ( {identity.passportRequired && (
<TextInput <TextInput
@@ -872,7 +870,6 @@ export default function CompanyProfileForm({
title="Power of Attorney" title="Power of Attorney"
state={identity.poa} state={identity.poa}
required={requirePoa} required={requirePoa}
onVerified={() => onIdentityChange?.()}
/> />
)} )}
{/* The city is the one field the Fayda address claim does not {/* The city is the one field the Fayda address claim does not
@@ -884,7 +881,9 @@ export default function CompanyProfileForm({
{...register("poaLocation")} {...register("poaLocation")}
/> />
{poaDocumentSetting && ( {/* The paper authorises the representative the verification
named, so it only has meaning once one exists. */}
{poaProvided && poaDocumentSetting && (
<> <>
<Divider my="sm" /> <Divider my="sm" />
<SmartFileInput <SmartFileInput

View File

@@ -379,11 +379,6 @@ export default function TabCompanyProfile({
required={identity.faydaRequired} required={identity.faydaRequired}
disabled={mutation.isPending} disabled={mutation.isPending}
pendingReview={pendingOwnerReview} pendingReview={pendingOwnerReview}
onVerified={() =>
queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(),
})
}
/> />
{identity.passportRequired && ( {identity.passportRequired && (
<TextInput <TextInput

View File

@@ -136,7 +136,12 @@ export default function TabPowerOfAttorney({
// has been verified. // has been verified.
const identity = profile.identity; const identity = profile.identity;
const poaProvided = identity?.poa.verified ?? false; const poaProvided = identity?.poa.verified ?? false;
const letterRequired = requirePoa || poaProvided; // The paper authorises the representative named above, so there is nothing
// for it to authorise until one has been verified — the upload is hidden
// until then, and requiring it while hidden would block the save on a
// control the customer cannot see. A freight forwarder is still held to
// having a PoA at all, by the verification gate on the panel and by the API.
const letterRequired = poaProvided;
const letterMissing = letterRequired && !hasLetterAfterSave; const letterMissing = letterRequired && !hasLetterAfterSave;
const fileDirty = Boolean(pickedFile) || removeIds.length > 0; const fileDirty = Boolean(pickedFile) || removeIds.length > 0;
@@ -253,14 +258,6 @@ export default function TabPowerOfAttorney({
state={identity.poa} state={identity.poa}
required={requirePoa} required={requirePoa}
disabled={mutation.isPending} disabled={mutation.isPending}
onVerified={() => {
queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(),
});
queryClient.invalidateQueries({
queryKey: api.companies.poaDelegation.queryKey(),
});
}}
/> />
)} )}
@@ -282,6 +279,9 @@ export default function TabPowerOfAttorney({
</Stack> </Stack>
{/* ------------------------ Delegation letter ------------------------ */} {/* ------------------------ Delegation letter ------------------------ */}
{/* The paper authorises the representative the verification named,
so it only has meaning once one exists. */}
{poaProvided && (
<Stack gap="sm" mt="xl"> <Stack gap="sm" mt="xl">
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<Group gap="sm"> <Group gap="sm">
@@ -429,6 +429,7 @@ export default function TabPowerOfAttorney({
}} }}
/> />
</Stack> </Stack>
)}
<Group <Group
justify="space-between" justify="space-between"

View File

@@ -0,0 +1,44 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
stashPendingVerification,
takePendingVerification,
} from "./verifayda.service";
/**
* The stash is the only thing that survives the full-page handoff to eSignet,
* so /callback completing against the wrong subject — or throwing on junk left
* behind by an older build — would either misfile a verified identity or dead-
* end the flow.
*/
describe("pending verification stash", () => {
// The suite runs in node, not jsdom — a Map is all these two calls need.
beforeEach(() => {
const store = new Map<string, string>();
vi.stubGlobal("sessionStorage", {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
});
});
it("round-trips and clears, so a spent code can't be replayed", () => {
stashPendingVerification({ subject: "poa", returnTo: "/settings?tab=poa" });
expect(takePendingVerification()).toEqual({
subject: "poa",
returnTo: "/settings?tab=poa",
});
expect(takePendingVerification()).toBeNull();
});
it("returns null rather than throwing on missing or malformed entries", () => {
expect(takePendingVerification()).toBeNull();
sessionStorage.setItem("fayda-pending-verification", "not json");
expect(takePendingVerification()).toBeNull();
sessionStorage.setItem("fayda-pending-verification", '{"returnTo":"/"}');
expect(takePendingVerification()).toBeNull();
});
});

View File

@@ -38,20 +38,46 @@ export interface CompanyIdentityState {
complete: boolean; complete: boolean;
} }
/** Message posted from the /callback popup back to the opener window. */ /**
export interface FaydaCallbackMessage { * What the panel was doing when it handed the tab over to eSignet. The
type: "fayda-callback"; * verification is a full-page redirect, so the page that started it is gone by
code?: string; * the time /callback runs — this is how /callback knows whose identity the
state?: string; * code+state belongs to and where to put the user back.
error?: string; *
errorDescription?: string; * sessionStorage, not localStorage: it is scoped to this tab, so two tabs
* verifying different people can't overwrite each other, and it dies with the
* tab rather than outliving an abandoned verification.
*/
const PENDING_KEY = "fayda-pending-verification";
export interface PendingVerification {
subject: IdentitySubject;
/** Path to return to once the verification completes. */
returnTo: string;
}
export function stashPendingVerification(pending: PendingVerification): void {
sessionStorage.setItem(PENDING_KEY, JSON.stringify(pending));
}
/** Read and clear — the code+state are single-use, so a retry needs a fresh start. */
export function takePendingVerification(): PendingVerification | null {
const raw = sessionStorage.getItem(PENDING_KEY);
sessionStorage.removeItem(PENDING_KEY);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as PendingVerification;
return parsed.subject ? parsed : null;
} catch {
return null;
}
} }
export const verifaydaService = { export const verifaydaService = {
/** /**
* Returns the eSignet authorize URL to open in a popup. `PORTAL` selects the * Returns the eSignet authorize URL to navigate the tab to. `PORTAL` selects
* portal's own registered redirect_uri — the backoffice and mobile clients * the portal's own registered redirect_uri — the backoffice and mobile
* have their own. * clients have their own.
*/ */
start: async (): Promise<string> => { start: async (): Promise<string> => {
const response = await client.post< const response = await client.post<