diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index b3ed1c802..d10f19f10 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -36,8 +36,7 @@ "iam:migration:run": "pnpm run iam:typeorm:cli migration:run", "iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert", "iam:migration:show": "pnpm run iam:typeorm:cli migration:show", - "migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts", - "migration:run": "node dist/scripts/migrate.js", + "migration:run": "nest build && node dist/scripts/migrate.js", "script": "ts-node -r tsconfig-paths/register src/scripts/main.ts" }, "dependencies": { diff --git a/apps/edr-freight-api/src/migrations/3150000000000-FixFaydaAddressShape.ts b/apps/edr-freight-api/src/migrations/3150000000000-FixFaydaAddressShape.ts new file mode 100644 index 000000000..7a8923f3c --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3150000000000-FixFaydaAddressShape.ts @@ -0,0 +1,81 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * A normalizeUserInfo bug in VerifaydaService read the raw `address#en` / + * `address#am` claim objects (e.g. `{ "zone#en": "...", "region#en": "...", + * "woreda#en": "..." }`) straight through as if they were strings, so any + * company verified before the fix has `attributes.ownerAddress` / + * `poaAddress` stored as that raw object instead of a formatted string — + * which crashes the portal when it tries to render it as text. + * + * Reformats every affected row's ownerAddress/poaAddress into + * "woreda, zone, region" (falling back to whatever #en fields are present, + * in that preferred order, then any leftover fields), mirroring + * VerifaydaService.formatFaydaAddress. Only touches rows where the field is + * still a jsonb object, so it's idempotent and a no-op once repaired. + */ +export class FixFaydaAddressShape3150000000000 implements MigrationInterface { + name = "FixFaydaAddressShape3150000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE OR REPLACE FUNCTION pg_temp.format_fayda_address(addr jsonb) + RETURNS text AS $$ + DECLARE + field_order text[] := ARRAY['houseNumber','kebele','woreda','city','subCity','zone','region','postalCode','country']; + f text; + v text; + parts text[] := '{}'; + used_keys text[] := '{}'; + kv record; + BEGIN + IF addr IS NULL OR jsonb_typeof(addr) != 'object' THEN + RETURN NULL; + END IF; + + FOREACH f IN ARRAY field_order LOOP + v := addr ->> (f || '#en'); + IF v IS NOT NULL AND trim(v) != '' THEN + parts := array_append(parts, trim(v)); + used_keys := array_append(used_keys, f || '#en'); + END IF; + END LOOP; + + FOR kv IN SELECT * FROM jsonb_each_text(addr) LOOP + IF kv.key LIKE '%#en' AND NOT (kv.key = ANY(used_keys)) + AND kv.value IS NOT NULL AND trim(kv.value) != '' THEN + parts := array_append(parts, trim(kv.value)); + END IF; + END LOOP; + + IF array_length(parts, 1) IS NULL THEN + RETURN NULL; + END IF; + RETURN array_to_string(parts, ', '); + END; + $$ LANGUAGE plpgsql; + + UPDATE freight.companies + SET attributes = jsonb_set( + attributes, + '{ownerAddress}', + to_jsonb(pg_temp.format_fayda_address(attributes -> 'ownerAddress')) + ) + WHERE jsonb_typeof(attributes -> 'ownerAddress') = 'object'; + + UPDATE freight.companies + SET attributes = jsonb_set( + attributes, + '{poaAddress}', + to_jsonb(pg_temp.format_fayda_address(attributes -> 'poaAddress')) + ) + WHERE jsonb_typeof(attributes -> 'poaAddress') = 'object'; + + DROP FUNCTION pg_temp.format_fayda_address(jsonb); + `); + } + + public async down(): Promise { + // Data repair — not reversible (the original malformed shape isn't worth restoring). + } +} diff --git a/apps/edr-freight-api/src/modules/verifayda/utils/client-assertion.util.ts b/apps/edr-freight-api/src/modules/verifayda/utils/client-assertion.util.ts index dc3558ccc..d4f50eb62 100644 --- a/apps/edr-freight-api/src/modules/verifayda/utils/client-assertion.util.ts +++ b/apps/edr-freight-api/src/modules/verifayda/utils/client-assertion.util.ts @@ -7,12 +7,15 @@ export interface GenerateClientAssertionInput { expiresIn?: string; } +// Mirrors the National ID Program's own reference implementation +// (fayda-auth-python): plain {alg: RS256} header, no kid, no jti — eSignet +// resolves the verification key from client_id alone. export async function generateClientAssertion( input: GenerateClientAssertionInput, ): Promise { const privateKey = await importJWK(input.privateJwk, 'RS256'); return new SignJWT({}) - .setProtectedHeader({ alg: 'RS256', typ: 'JWT' }) + .setProtectedHeader({ alg: 'RS256' }) .setIssuer(input.clientId) .setSubject(input.clientId) .setAudience(input.audience) diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts index 16441bd0d..af627f25a 100644 --- a/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts +++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts @@ -396,13 +396,63 @@ export class VerifaydaService { ); } + // Like name/gender, address is flattened by eSignet into top-level + // `address#en` / `address#am` keys — but since it's a structured claim, + // each of those is itself an object whose *leaf* fields carry the same + // locale suffix again, e.g. + // `address#en: { "zone#en": "...", "region#en": "...", "woreda#en": "..." }`. + private static readonly ADDRESS_FIELD_ORDER = [ + 'houseNumber', + 'kebele', + 'woreda', + 'city', + 'subCity', + 'zone', + 'region', + 'postalCode', + 'country', + ]; + + private formatFaydaAddress( + address: Record | undefined, + locale: 'en' | 'am', + ): string | undefined { + if (!address) return undefined; + + const formatted = address[`formatted#${locale}`]; + if (typeof formatted === 'string' && formatted.trim()) return formatted; + + const suffix = `#${locale}`; + const byField = new Map(); + for (const [key, value] of Object.entries(address)) { + if (!key.endsWith(suffix) || typeof value !== 'string' || !value.trim()) continue; + byField.set(key.slice(0, -suffix.length), value); + } + + const ordered = VerifaydaService.ADDRESS_FIELD_ORDER.filter((f) => + byField.has(f), + ).map((f) => byField.get(f)!); + const rest = [...byField.entries()] + .filter(([f]) => !VerifaydaService.ADDRESS_FIELD_ORDER.includes(f)) + .map(([, v]) => v); + + const parts = [...ordered, ...rest]; + return parts.length > 0 ? parts.join(', ') : undefined; + } + private normalizeUserInfo(raw: FaydaUserInfo): NormalizedFaydaUserInfo { const nameEn = raw['name#en'] as string | undefined; const nameAm = raw['name#am'] as string | undefined; const genderEn = raw['gender#en'] as string | undefined; const genderAm = raw['gender#am'] as string | undefined; - const addressEn = raw['address#en'] as string | undefined; - const addressAm = raw['address#am'] as string | undefined; + const addressEn = this.formatFaydaAddress( + raw['address#en'] as Record | undefined, + 'en', + ); + const addressAm = this.formatFaydaAddress( + raw['address#am'] as Record | undefined, + 'am', + ); const rawPhone = (raw.phone_number ?? raw['phone_number#en'] ?? raw['phone_number#am'] ?? raw.phone) as string | undefined; return { diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.types.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.types.ts index 442a22d2f..03e52d5b0 100644 --- a/apps/edr-freight-api/src/modules/verifayda/verifayda.types.ts +++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.types.ts @@ -21,7 +21,8 @@ export interface FaydaUserInfo { gender?: string; birthdate?: string; picture?: string; - address?: Record; + 'address#en'?: Record; + 'address#am'?: Record; [key: string]: unknown; } diff --git a/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx b/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx index fc00cfabe..236f8b47a 100644 --- a/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx +++ b/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx @@ -72,6 +72,20 @@ export default function FaydaVerifyPanel({ // 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) => { @@ -79,11 +93,15 @@ export default function FaydaVerifyPanel({ 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( @@ -104,13 +122,17 @@ export default function FaydaVerifyPanel({ } }; window.addEventListener("message", onMessage); - return () => window.removeEventListener("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( @@ -121,8 +143,17 @@ export default function FaydaVerifyPanel({ 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. + // 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) { setLoading(false); setError(