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 {
Alert,
Avatar,
@@ -20,9 +20,8 @@ import {
} from "lucide-react";
import {
stashPendingVerification,
verifaydaService,
type CompanyIdentityState,
type FaydaCallbackMessage,
type IdentitySubject,
type IdentityVerificationState,
} from "@/services/verifayda.service";
@@ -38,8 +37,6 @@ interface FaydaVerifyPanelProps {
* 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;
/**
* 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.
*
* 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.
* The identity is proved on eSignet, which the whole tab navigates to — no
* popup, because a popup opened after the /start round-trip has lost its user
* activation and iOS Safari blocks it outright. eSignet redirects back to
* /callback, which completes the exchange and returns the user here; 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,
pendingReview,
}: 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;
// 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 () => {
setError(null);
setLoading(true);
handledStateRef.current = null;
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.");
return;
}
// 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);
// Record who is being verified and where to come back to before the tab
// leaves — /callback has no other way to know either.
stashPendingVerification({
subject,
returnTo:
window.location.pathname +
window.location.search +
window.location.hash,
});
window.location.assign(authorizationUrl);
} catch (err) {
setLoading(false);
setError(

View File

@@ -437,10 +437,6 @@ export default function OnboardingWizardDialog({
// 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.