From 53accbc57b52f9f35aeade326852c57765bac262 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 4 Aug 2026 10:52:49 +0000 Subject: [PATCH] feat(freight-portal): verify Fayda via redirect, gate DARS on verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/components/FaydaVerifyPanel.tsx | 109 +++--------------- .../onboarding/OnboardingWizardDialog.tsx | 4 - .../portal/src/pages/FaydaCallbackPage.tsx | 94 ++++++++++----- .../src/pages/accounts/CompanyProfileForm.tsx | 19 ++- .../src/pages/settings/TabCompanyProfile.tsx | 5 - .../src/pages/settings/TabPowerOfAttorney.tsx | 19 +-- .../src/services/verifayda.pending.test.ts | 44 +++++++ .../portal/src/services/verifayda.service.ts | 46 ++++++-- 8 files changed, 183 insertions(+), 157 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/services/verifayda.pending.test.ts diff --git a/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx b/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx index b07d7352c..8ecb87854 100644 --- a/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx +++ b/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx @@ -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(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(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(null); - - const stopPolling = () => { - if (pollRef.current !== null) { - window.clearInterval(pollRef.current); - pollRef.current = null; - } - }; - - useEffect(() => { - const onMessage = async (event: MessageEvent) => { - 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( diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx index 466307fe9..d75b07412 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -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. diff --git a/apps/edr-freight-web/portal/src/pages/FaydaCallbackPage.tsx b/apps/edr-freight-web/portal/src/pages/FaydaCallbackPage.tsx index c25dc836a..16e7d43ac 100644 --- a/apps/edr-freight-web/portal/src/pages/FaydaCallbackPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/FaydaCallbackPage.tsx @@ -1,51 +1,89 @@ -import { useEffect, useState } from "react"; -import { Center, Loader, Stack, Text } from "@mantine/core"; +import { useEffect, useRef, useState } from "react"; +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 - * (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. + * (FAYDA_PORTAL_REDIRECT_URI → http://localhost:5173/callback). + * + * The verification is a full-page redirect, so the page that started it no + * 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() { - const [standalone, setStandalone] = useState(false); + const navigate = useNavigate(); + const [error, setError] = useState(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(() => { - 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 (startedRef.current) return; + startedRef.current = true; - 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); + const params = new URLSearchParams(window.location.search); + const pending = takePendingVerification(); + if (pending) setReturnTo(pending.returnTo); + + const authError = params.get("error"); + 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 (
- {standalone ? ( + {error ? ( <> - Verification window lost its parent page - - Close this tab and start the verification again from the form. + Verification could not be completed + + {error} + ) : ( <> - + Completing Fayda verification… diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index 0dc02eaa4..a122b5440 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -67,7 +67,6 @@ export default function CompanyProfileForm({ uploadedDocumentKeys, onUploadDocuments, identity, - onIdentityChange, }: { documentSettingCode: string; documentFiles?: Record; @@ -107,8 +106,6 @@ export default function CompanyProfileForm({ >; /** Fayda verification state for the owner and the PoA (undefined until loaded). */ identity?: CompanyIdentityState; - /** Refetch the profile + requirements once a verification lands. */ - onIdentityChange?: () => void; }) { const [step, setStep] = useState(initialStep ?? "company"); const [saving, setSaving] = useState(false); @@ -535,14 +532,16 @@ export default function CompanyProfileForm({ const currentIdx = stepOrder.indexOf(step); // 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. The API enforces - // the same rule on save, so skipping it here only costs the customer a + // delegated, so it's required the moment a PoA exists. The API enforces the + // same rule on save, so skipping it here only costs the customer a // round-trip. // 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. + // 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 delegationRequired = requirePoa || poaProvided; + const delegationRequired = poaProvided; const delegationPresent = (uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) || (() => { @@ -714,7 +713,6 @@ export default function CompanyProfileForm({ title="Owner" state={identity.owner} required={identity.faydaRequired} - onVerified={() => onIdentityChange?.()} /> {identity.passportRequired && ( onIdentityChange?.()} /> )} {/* The city is the one field the Fayda address claim does not @@ -884,7 +881,9 @@ export default function CompanyProfileForm({ {...register("poaLocation")} /> - {poaDocumentSetting && ( + {/* The paper authorises the representative the verification + named, so it only has meaning once one exists. */} + {poaProvided && poaDocumentSetting && ( <> - queryClient.invalidateQueries({ - queryKey: api.companies.getProfile.queryKey(), - }) - } /> {identity.passportRequired && ( {/* ------------------------ Delegation letter ------------------------ */} + {/* The paper authorises the representative the verification named, + so it only has meaning once one exists. */} + {poaProvided && ( @@ -429,6 +429,7 @@ export default function TabPowerOfAttorney({ }} /> + )} { + // The suite runs in node, not jsdom — a Map is all these two calls need. + beforeEach(() => { + const store = new Map(); + 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(); + }); +}); diff --git a/apps/edr-freight-web/portal/src/services/verifayda.service.ts b/apps/edr-freight-web/portal/src/services/verifayda.service.ts index 54a7084dd..c7521adda 100644 --- a/apps/edr-freight-web/portal/src/services/verifayda.service.ts +++ b/apps/edr-freight-web/portal/src/services/verifayda.service.ts @@ -38,20 +38,46 @@ export interface CompanyIdentityState { 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; +/** + * What the panel was doing when it handed the tab over to eSignet. The + * verification is a full-page redirect, so the page that started it is gone by + * the time /callback runs — this is how /callback knows whose identity the + * code+state belongs to and where to put the user back. + * + * 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 = { /** - * 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. + * Returns the eSignet authorize URL to navigate the tab to. `PORTAL` selects + * the portal's own registered redirect_uri — the backoffice and mobile + * clients have their own. */ start: async (): Promise => { const response = await client.post<