mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 12:18:11 +00:00
fix(verifayda): format nested Fayda address objects into strings
This commit is contained in:
@@ -36,8 +36,7 @@
|
|||||||
"iam:migration:run": "pnpm run iam:typeorm:cli migration:run",
|
"iam:migration:run": "pnpm run iam:typeorm:cli migration:run",
|
||||||
"iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert",
|
"iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert",
|
||||||
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show",
|
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show",
|
||||||
"migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts",
|
"migration:run": "nest build && node dist/scripts/migrate.js",
|
||||||
"migration:run": "node dist/scripts/migrate.js",
|
|
||||||
"script": "ts-node -r tsconfig-paths/register src/scripts/main.ts"
|
"script": "ts-node -r tsconfig-paths/register src/scripts/main.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
// Data repair — not reversible (the original malformed shape isn't worth restoring).
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,12 +7,15 @@ export interface GenerateClientAssertionInput {
|
|||||||
expiresIn?: string;
|
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(
|
export async function generateClientAssertion(
|
||||||
input: GenerateClientAssertionInput,
|
input: GenerateClientAssertionInput,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const privateKey = await importJWK(input.privateJwk, 'RS256');
|
const privateKey = await importJWK(input.privateJwk, 'RS256');
|
||||||
return new SignJWT({})
|
return new SignJWT({})
|
||||||
.setProtectedHeader({ alg: 'RS256', typ: 'JWT' })
|
.setProtectedHeader({ alg: 'RS256' })
|
||||||
.setIssuer(input.clientId)
|
.setIssuer(input.clientId)
|
||||||
.setSubject(input.clientId)
|
.setSubject(input.clientId)
|
||||||
.setAudience(input.audience)
|
.setAudience(input.audience)
|
||||||
|
|||||||
@@ -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<string, unknown> | 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<string, string>();
|
||||||
|
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 {
|
private normalizeUserInfo(raw: FaydaUserInfo): NormalizedFaydaUserInfo {
|
||||||
const nameEn = raw['name#en'] as string | undefined;
|
const nameEn = raw['name#en'] as string | undefined;
|
||||||
const nameAm = raw['name#am'] as string | undefined;
|
const nameAm = raw['name#am'] as string | undefined;
|
||||||
const genderEn = raw['gender#en'] as string | undefined;
|
const genderEn = raw['gender#en'] as string | undefined;
|
||||||
const genderAm = raw['gender#am'] as string | undefined;
|
const genderAm = raw['gender#am'] as string | undefined;
|
||||||
const addressEn = raw['address#en'] as string | undefined;
|
const addressEn = this.formatFaydaAddress(
|
||||||
const addressAm = raw['address#am'] as string | undefined;
|
raw['address#en'] as Record<string, unknown> | undefined,
|
||||||
|
'en',
|
||||||
|
);
|
||||||
|
const addressAm = this.formatFaydaAddress(
|
||||||
|
raw['address#am'] as Record<string, unknown> | undefined,
|
||||||
|
'am',
|
||||||
|
);
|
||||||
const rawPhone = (raw.phone_number ?? raw['phone_number#en'] ?? raw['phone_number#am'] ?? raw.phone) as string | undefined;
|
const rawPhone = (raw.phone_number ?? raw['phone_number#en'] ?? raw['phone_number#am'] ?? raw.phone) as string | undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ export interface FaydaUserInfo {
|
|||||||
gender?: string;
|
gender?: string;
|
||||||
birthdate?: string;
|
birthdate?: string;
|
||||||
picture?: string;
|
picture?: string;
|
||||||
address?: Record<string, unknown>;
|
'address#en'?: Record<string, unknown>;
|
||||||
|
'address#am'?: Record<string, unknown>;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,20 @@ export default function FaydaVerifyPanel({
|
|||||||
// panel between steps can't complete a verification against the wrong person.
|
// panel between steps can't complete a verification against the wrong person.
|
||||||
const subjectRef = useRef(subject);
|
const subjectRef = useRef(subject);
|
||||||
subjectRef.current = 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(() => {
|
useEffect(() => {
|
||||||
const onMessage = async (event: MessageEvent<FaydaCallbackMessage>) => {
|
const onMessage = async (event: MessageEvent<FaydaCallbackMessage>) => {
|
||||||
@@ -79,11 +93,15 @@ export default function FaydaVerifyPanel({
|
|||||||
if (event.data?.type !== "fayda-callback") return;
|
if (event.data?.type !== "fayda-callback") return;
|
||||||
|
|
||||||
if (event.data.error) {
|
if (event.data.error) {
|
||||||
|
stopPolling();
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setError(event.data.errorDescription ?? event.data.error);
|
setError(event.data.errorDescription ?? event.data.error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!event.data.code || !event.data.state) return;
|
if (!event.data.code || !event.data.state) return;
|
||||||
|
if (handledStateRef.current === event.data.state) return;
|
||||||
|
handledStateRef.current = event.data.state;
|
||||||
|
stopPolling();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const next = await verifaydaService.completeIdentity(
|
const next = await verifaydaService.completeIdentity(
|
||||||
@@ -104,13 +122,17 @@ export default function FaydaVerifyPanel({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener("message", onMessage);
|
window.addEventListener("message", onMessage);
|
||||||
return () => window.removeEventListener("message", onMessage);
|
return () => {
|
||||||
|
window.removeEventListener("message", onMessage);
|
||||||
|
stopPolling();
|
||||||
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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(
|
const popup = window.open(
|
||||||
@@ -121,8 +143,17 @@ export default function FaydaVerifyPanel({
|
|||||||
if (!popup) {
|
if (!popup) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setError("Pop-up blocked — allow pop-ups for this site and try again.");
|
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) {
|
} catch (err) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setError(
|
setError(
|
||||||
|
|||||||
Reference in New Issue
Block a user