From 3a5126670ff71398c9141b8e6896b84711ff85ae Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Tue, 4 Aug 2026 15:32:49 +0300 Subject: [PATCH 1/7] fix: centralized on rabbitmq for sms --- .../strategies/notification.sms.strategy.ts | 58 +++---------------- .../src/modules/otp/otp.service.ts | 8 +-- 2 files changed, 13 insertions(+), 53 deletions(-) diff --git a/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts b/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts index cddc67b2d..ac021c322 100644 --- a/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts +++ b/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts @@ -1,62 +1,22 @@ import { Injectable, Logger } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import axios, { isAxiosError } from "axios"; import { NotificationStrategy } from "./notification.strategy"; +import { SmsClientService } from "../sms-client.service"; @Injectable() export class SmsNotificationStrategy implements NotificationStrategy { private readonly logger = new Logger(SmsNotificationStrategy.name); - constructor(private readonly configService: ConfigService) {} + constructor(private readonly smsClient: SmsClientService) {} async send(recipient: string, message: string): Promise { - const url = - this.configService.get("OZIKING_SMS_URL") ?? - "https://notification-dev.license.aafda.gov.et/api/sms-services/ozeking/sms"; - - const appKey = this.configService.get("OZIKING_APP_KEY") ?? ""; - if (!appKey) { - this.logger.warn("OZIKING_APP_KEY is not set — SMS may be rejected by the API"); - } - - this.logger.debug(`Sending SMS to ${recipient} via ${url}`); - - // axios defaults to no timeout — a hanging gateway would block the caller - // (and any transaction it sits in) indefinitely. Always bound the wait. - const timeout = Number(this.configService.get("SMS_TIMEOUT_MS") ?? 8000); - - try { - const response = await axios.post( - url, - { - to: recipient, - sourceId: this.configService.get("OZIKING_SOURCE_ID") ?? "EDR", - sourceName: this.configService.get("OZIKING_SOURCE_NAME") ?? "EDR Freight", - appKey, - text: message, - callbackUrl: "", - }, - { - timeout, - headers: { - accept: "*/*", - "Content-Type": "application/json", - }, - }, - ); - - this.logger.debug(`SMS API response: ${response.status} ${JSON.stringify(response.data)}`); - return true; - } catch (err) { - if (isAxiosError(err)) { - this.logger.error( - `SMS API error: ${err.message} | status=${err.response?.status} | body=${JSON.stringify(err.response?.data)}`, - ); - } else { - this.logger.error(`SMS send failed: ${String(err)}`); - } - throw err; + const { queued } = await this.smsClient.sendSms({ + to: recipient, + message, + }); + if (!queued) { + this.logger.error(`SMS to ${recipient} was not queued to RabbitMQ`); } + return queued; } } diff --git a/apps/edr-freight-api/src/modules/otp/otp.service.ts b/apps/edr-freight-api/src/modules/otp/otp.service.ts index d5688e1c2..a7361fbdd 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.service.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.service.ts @@ -265,10 +265,10 @@ export class OtpService { /** * SMS half of {@link dispatchEmail}; same swallow-and-report contract. Sent - * via NotificationsService's direct-HTTP Ozeking strategy — the same - * transport the notification system uses — rather than the RabbitMQ - * `SMS_SERVICE` queue, so `queued: true` here means the gateway accepted the - * request, not just that a broker took ownership of the message. + * via NotificationsService's `directSend`, which now routes through the + * same RabbitMQ `SMS_SERVICE` queue as every other SMS in freight-api, so + * `queued: true` here means the broker confirmed ownership of the message, + * not that the carrier delivered it. */ private async dispatchSms( phone: string, From 53accbc57b52f9f35aeade326852c57765bac262 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 4 Aug 2026 10:52:49 +0000 Subject: [PATCH 2/7] 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< From b3564338865395ff94ec4d679ccc09f389d4d0d8 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 4 Aug 2026 10:58:37 +0000 Subject: [PATCH 3/7] test(freight-e2e): fix stale onboarding selectors, tighten Fayda assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The onboarding journey had been failing at the company step for a while: that step was restructured into StepSection cards, so its TIN and VAT fields no longer have