From c2375a53069903b918577620a21f2fd98b4001ee Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 28 Jul 2026 12:27:14 +0000 Subject: [PATCH 01/66] fix(backoffice): scope copy-permissions-from to the selected unit (EDRFREIGHT-308) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Copy Permissions From" dropdown in the position-type form only filtered candidates by the selected organization's full unit set, ignoring the form's own "Select Unit" field entirely — so switching units never re-scoped the list, unlike the equivalent unit filter on the position-management table. Co-Authored-By: Claude Sonnet 5 --- .../src/locales/am/translation.json | 1 + .../src/locales/en/translation.json | 1 + .../src/locales/fr/translation.json | 1 + .../CreatePositionForm.tsx | 46 +++++++++---------- 4 files changed, 26 insertions(+), 23 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/locales/am/translation.json b/apps/edr-freight-web/backoffice/src/locales/am/translation.json index 99c1e0329..1353a9b33 100644 --- a/apps/edr-freight-web/backoffice/src/locales/am/translation.json +++ b/apps/edr-freight-web/backoffice/src/locales/am/translation.json @@ -2653,6 +2653,7 @@ "copyPermissionsHint": "የነበረ የቦታ አይነት ይምረጡ፤ ፍቃዶቹ አስቀድመው ይሞላሉ፣ ከታች ማስተካከል ይችላሉ።", "copyPermissionsFailed": "ፍቃዶችን መቅዳት አልተቻለም", "selectOrganizationToCopy": "መቅዳት የሚችሏቸውን የቦታ ዓይነቶች ለማየት መጀመሪያ ድርጅት ይምረጡ", + "selectUnitToCopy": "መቅዳት የሚችሏቸውን የቦታ ዓይነቶች ለማየት መጀመሪያ ክፍል ይምረጡ", "cannotClearAllPermissions": "ተቀምጧል። ፍቃዶቹ አልተቀየሩም — ይህ የቦታ ዓይነት ቢያንስ አንድ ፍቃድ ሊኖረው ይገባል።", "permissionsSelected": "{{count}} ተመርጠዋል", "positionTypeCreated": "የቦታ ዓይነት ተፈጥሯል", diff --git a/apps/edr-freight-web/backoffice/src/locales/en/translation.json b/apps/edr-freight-web/backoffice/src/locales/en/translation.json index f1357c2bf..1a2a95244 100644 --- a/apps/edr-freight-web/backoffice/src/locales/en/translation.json +++ b/apps/edr-freight-web/backoffice/src/locales/en/translation.json @@ -2762,6 +2762,7 @@ "copyPermissionsHint": "Pick an existing position type to pre-fill its permissions, then edit below.", "copyPermissionsFailed": "Failed to copy permissions", "selectOrganizationToCopy": "Select an organization to see the position types you can copy from", + "selectUnitToCopy": "Select a unit to see the position types you can copy from", "cannotClearAllPermissions": "Saved. Permissions were left unchanged — this position type must keep at least one permission.", "permissionsSelected": "{{count}} selected", "positionTypeCreated": "Position type created", diff --git a/apps/edr-freight-web/backoffice/src/locales/fr/translation.json b/apps/edr-freight-web/backoffice/src/locales/fr/translation.json index bd5e17fae..5019a2fb8 100644 --- a/apps/edr-freight-web/backoffice/src/locales/fr/translation.json +++ b/apps/edr-freight-web/backoffice/src/locales/fr/translation.json @@ -1888,6 +1888,7 @@ "copyPermissionsHint": "Choisissez un type de poste existant pour préremplir ses autorisations, puis modifiez ci-dessous.", "copyPermissionsFailed": "Échec de la copie des autorisations", "selectOrganizationToCopy": "Sélectionnez une organisation pour voir les types de poste que vous pouvez copier", + "selectUnitToCopy": "Sélectionnez une unité pour voir les types de poste que vous pouvez copier", "cannotClearAllPermissions": "Enregistré. Les autorisations n'ont pas été modifiées — ce type de poste doit conserver au moins une autorisation.", "permissionsSelected": "{{count}} sélectionné(s)", "positionTypeCreated": "Type de poste créé", diff --git a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/CreatePositionForm.tsx b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/CreatePositionForm.tsx index f8fc0cfdd..29ac1486f 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/CreatePositionForm.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/CreatePositionForm.tsx @@ -115,6 +115,7 @@ export const CreatePositionForm = ({ }); const selectedOrganizationId = form.watch("organizationId"); + const selectedUnitId = form.watch("unitId"); const { organizationsResponse, isLoading: isLoadingOrgs } = useOrganizations( "Org", @@ -163,36 +164,32 @@ export const CreatePositionForm = ({ enabled: mode === "edit" && !!positionTypeId, }); - // A position type belongs to a unit, and a unit to an organization — IAM has - // no organizationId on the type itself and no organization-scoped route, so - // the picked org narrows the list through its units. isSystem types are the - // shared "commons" and stay available to every organization. - const orgUnitIds = useMemo( - () => - new Set( - (unitsResponse?.data?.items ?? []).map((unit: UnitDto) => unit.id), - ), - [unitsResponse], - ); - + // A position type belongs to a single unit — scope copy sources to the + // selected unit, same as the "Select Unit" filter on the position list page. + // isSystem types are the shared "commons" and stay available everywhere. const copyFromOptions = useMemo(() => { - if (!selectedOrganizationId) return []; + if (!selectedUnitId) return []; return positionTypes.filter( (type: PositionTypeDto) => type.id !== positionTypeId && - (type.isSystem || (!!type.unitId && orgUnitIds.has(type.unitId))), + (type.isSystem || type.unitId === selectedUnitId), ); - }, [positionTypes, orgUnitIds, selectedOrganizationId, positionTypeId]); + }, [positionTypes, selectedUnitId, positionTypeId]); // Reset the selected unit when the organization changes so a unit from a - // different org can't be submitted by mistake. The copy source is cleared - // too — it is scoped to the old organization. + // different org can't be submitted by mistake. useEffect(() => { if (mode === "edit") return; form.setValue("unitId", ""); - setCopyFromPositionId(""); }, [selectedOrganizationId, mode, form]); + // The copy source is scoped to the selected unit — clear it whenever the + // unit changes (including as a side effect of the org reset above) so a + // stale selection from a different unit can't be submitted. + useEffect(() => { + setCopyFromPositionId(""); + }, [selectedUnitId]); + useEffect(() => { if (mode !== "edit" || !initialValues || !positionTypeId) return; if (hasLoadedEditData.current) return; @@ -335,11 +332,13 @@ export const CreatePositionForm = ({ const copyFromPlaceholder = !selectedOrganizationId ? t("contentManagement.selectOrganizationToCopy") - : isCopying || isLoadingPositionTypes || isLoadingUnits - ? t("common.loading") - : isErrorPositionTypes - ? t("contentManagement.failedToLoadPositionTypes") - : t("contentManagement.selectPositionToCopy"); + : !selectedUnitId + ? t("contentManagement.selectUnitToCopy") + : isCopying || isLoadingPositionTypes || isLoadingUnits + ? t("common.loading") + : isErrorPositionTypes + ? t("contentManagement.failedToLoadPositionTypes") + : t("contentManagement.selectPositionToCopy"); return (
@@ -454,6 +453,7 @@ export const CreatePositionForm = ({ onValueChange={handleCopyFrom} disabled={ !selectedOrganizationId || + !selectedUnitId || isLoadingPositionTypes || isLoadingUnits || isCopying From b1cb149c44ceebcc3512fca25a0b1ba4e9b64d26 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 28 Jul 2026 12:27:44 +0000 Subject: [PATCH 02/66] feat(backoffice): add organization admin permission assignment page (EDRFREIGHT-242) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There was no UI to assign permissions to the Organization Admin role, even though the backend's Role→Permission endpoints (@tria-plc/iamapi-common's role-permissions routes) were already live and unused. Adds a dedicated page — application picker + the existing permission checklist — that assigns/reads permissions for the fixed Organization Admin role, linked from the Organization Admins page. Co-Authored-By: Claude Sonnet 5 --- .../src/locales/am/translation.json | 10 + .../src/locales/en/translation.json | 10 + .../org-admins/OrgAdminPermissionsPage.tsx | 199 ++++++++++++++++++ .../components/org-admins/OrgAdminsPage.tsx | 32 ++- .../services/api/rolePermissionService.ts | 25 +++ .../super-admin/services/api/roleService.ts | 20 ++ .../backoffice/src/user-management/route.tsx | 5 + 7 files changed, 294 insertions(+), 7 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminPermissionsPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/super-admin/services/api/rolePermissionService.ts create mode 100644 apps/edr-freight-web/backoffice/src/super-admin/services/api/roleService.ts diff --git a/apps/edr-freight-web/backoffice/src/locales/am/translation.json b/apps/edr-freight-web/backoffice/src/locales/am/translation.json index 1353a9b33..09acb8c62 100644 --- a/apps/edr-freight-web/backoffice/src/locales/am/translation.json +++ b/apps/edr-freight-web/backoffice/src/locales/am/translation.json @@ -7492,12 +7492,22 @@ "loadError": "አስተዳዳሪዎችን መጫን አልተሳካም።", "pickerError": "ድርጅቶችን መጫን አልተሳካም።", "addAdmin": "አስተዳዳሪ ጨምር", + "managePermissions": "ፍቃዶችን ያስተዳድሩ", "add": { "title": "አስተዳዳሪ ጨምር", "description": "የተጠቃሚ መለያ ይፍጠሩ እና በዚህ ድርጅት ውስጥ የአስተዳዳሪ መዳረሻ ይስጡ።", "submit": "አስተዳዳሪ ጨምር", "inviteNote": "ተጠቃሚው ይፈጠራል እና የይለፍ ቃሉን እንዲያዘጋጅ የኤስኤምኤስ ግብዣ ይደርሰዋል።", "noUnitsOrgAdmin": "ይህ ድርጅት ክፍሎች የሉትም — አስተዳዳሪው እንደ የድርጅት አስተዳዳሪ ይጨመራል።" + }, + "permissions": { + "title": "የድርጅት አስተዳዳሪ ፍቃዶች", + "subtitle": "እያንዳንዱ የድርጅት አስተዳዳሪ በመድረኩ ላይ ምን ማድረግ እንደሚችል ይምረጡ።", + "backToAdmins": "ወደ ድርጅት አስተዳዳሪዎች ይመለሱ", + "roleNotFound": "የድርጅት አስተዳዳሪ ሚና ማግኘት አልተቻለም።", + "saved": "የድርጅት አስተዳዳሪ ፍቃዶች ተዘምነዋል።", + "saveFailed": "የድርጅት አስተዳዳሪ ፍቃዶችን ማዘመን አልተቻለም።", + "cannotClearAll": "ተቀምጧል። ፍቃዶቹ አልተቀየሩም — የድርጅት አስተዳዳሪ ሚና ቢያንስ አንድ ፍቃድ ሊኖረው ይገባል።" } } } diff --git a/apps/edr-freight-web/backoffice/src/locales/en/translation.json b/apps/edr-freight-web/backoffice/src/locales/en/translation.json index 1a2a95244..3fa0f0ee3 100644 --- a/apps/edr-freight-web/backoffice/src/locales/en/translation.json +++ b/apps/edr-freight-web/backoffice/src/locales/en/translation.json @@ -7493,12 +7493,22 @@ "loadError": "Failed to load admins.", "pickerError": "Failed to load organizations.", "addAdmin": "Add Admin", + "managePermissions": "Manage Permissions", "add": { "title": "Add Admin", "description": "Create a user account and grant admin access in this organization.", "submit": "Add Admin", "inviteNote": "The user is created and receives an SMS invitation to set their password.", "noUnitsOrgAdmin": "This organization has no units — the admin will be added as an organization admin." + }, + "permissions": { + "title": "Organization Admin Permissions", + "subtitle": "Choose what every Organization Admin can do across the platform.", + "backToAdmins": "Back to Organization Admins", + "roleNotFound": "Could not find the Organization Admin role.", + "saved": "Organization Admin permissions updated.", + "saveFailed": "Failed to update Organization Admin permissions.", + "cannotClearAll": "Saved. Permissions were left unchanged — the Organization Admin role must keep at least one permission." } } } diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminPermissionsPage.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminPermissionsPage.tsx new file mode 100644 index 000000000..a98d38143 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminPermissionsPage.tsx @@ -0,0 +1,199 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { Link } from "react-router-dom"; +import { ArrowLeft } from "lucide-react"; +import { Button } from "@/shared/common/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/common/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/common/ui/select"; +import { useLocalizedName } from "@/shared/common/localizedName"; +import { useApplications } from "@/user-management/hooks/useApplications"; +import { PermissionSearch } from "@/user-management/components/position-management/PermissionSearch"; +import { getRoles } from "@/super-admin/services/api/roleService"; +import { + assignPermissionsToRole, + getPermissionsByRoleId, +} from "@/super-admin/services/api/rolePermissionService"; +import { ORG_ADMIN_ROLE_KEY } from "./OrgAdminsColumnDefn"; + +// The Organization Admin role is a fixed, singleton role (unlike position +// types, which are org/unit-scoped) — so this page has no picker, just the +// one role's permission set. +export default function OrgAdminPermissionsPage() { + const { t } = useTranslation(); + const localizedName = useLocalizedName(); + const queryClient = useQueryClient(); + + const [selectedApplicationId, setSelectedApplicationId] = useState(""); + const [permissions, setPermissions] = useState([]); + const hasLoadedPermissions = useRef(false); + // Permissions the role had when the page opened. Needed because the API + // cannot represent "no permissions" (see the save handler). + const loadedPermissionCount = useRef(0); + + const { applications, isLoading: isLoadingApplications } = useApplications(); + + const { + data: rolesResponse, + isLoading: isLoadingRoles, + isError: isRolesError, + } = useQuery({ queryKey: ["roles"], queryFn: getRoles }); + + const orgAdminRole = useMemo( + () => rolesResponse?.data?.items?.find((r) => r.key === ORG_ADMIN_ROLE_KEY), + [rolesResponse], + ); + + const { + data: rolePermissionsResponse, + isSuccess: isPermissionsSuccess, + isError: isPermissionsError, + isLoading: isLoadingPermissions, + } = useQuery({ + queryKey: ["role-permissions", orgAdminRole?.id], + queryFn: () => getPermissionsByRoleId(orgAdminRole!.id), + enabled: !!orgAdminRole?.id, + }); + + useEffect(() => { + if (hasLoadedPermissions.current) return; + if (!isPermissionsSuccess && !isPermissionsError) return; + const ids = rolePermissionsResponse?.data?.items?.map((p) => p.id) ?? []; + loadedPermissionCount.current = ids.length; + setPermissions(ids); + hasLoadedPermissions.current = true; + }, [isPermissionsSuccess, isPermissionsError, rolePermissionsResponse]); + + const handlePermissionChange = (permissionId: string, checked: boolean) => { + setPermissions((prev) => + checked ? [...prev, permissionId] : prev.filter((id) => id !== permissionId), + ); + }; + + const mustClearAll = + permissions.length === 0 && loadedPermissionCount.current > 0; + + const { mutate: save, isPending: isSaving } = useMutation({ + mutationFn: async () => { + if (!orgAdminRole?.id || permissions.length === 0) return; + await assignPermissionsToRole({ + firstId: orgAdminRole.id, + secondIds: permissions, + }); + }, + onSuccess: () => { + loadedPermissionCount.current = permissions.length; + queryClient.invalidateQueries({ queryKey: ["role-permissions"] }); + toast[mustClearAll ? "warning" : "success"]( + t( + mustClearAll + ? "orgAdmins.permissions.cannotClearAll" + : "orgAdmins.permissions.saved", + ), + ); + }, + onError: () => { + toast.error(t("orgAdmins.permissions.saveFailed")); + }, + }); + + const selectedPermissionCount = permissions.length; + + return ( +
+ + + + + {t("orgAdmins.permissions.backToAdmins")} + + + {t("orgAdmins.permissions.title")} + +

+ {t("orgAdmins.permissions.subtitle")} +

+
+ + {isLoadingRoles ? ( +
+ {t("common.loading")} +
+ ) : isRolesError || !orgAdminRole ? ( +
+ {t("orgAdmins.permissions.roleNotFound")} +
+ ) : ( + <> +
+ + +
+ +
+ + +
+ +
+ +
+ + )} +
+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsPage.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsPage.tsx index d5844a603..53ac19841 100644 --- a/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsPage.tsx @@ -1,7 +1,15 @@ import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; -import { Building2, Loader2, Plus, UserPlus, Users2 } from "lucide-react"; +import { Link } from "react-router-dom"; +import { + Building2, + Loader2, + Plus, + ShieldCheck, + UserPlus, + Users2, +} from "lucide-react"; import { Button } from "@/shared/common/ui/button"; import { Card, @@ -190,12 +198,22 @@ export default function OrgAdminsPage() {
- - {t("orgAdmins.title")} - -

- {t("orgAdmins.subtitle")} -

+
+
+ + {t("orgAdmins.title")} + +

+ {t("orgAdmins.subtitle")} +

+
+ +
{/* Org selector + summary */} diff --git a/apps/edr-freight-web/backoffice/src/super-admin/services/api/rolePermissionService.ts b/apps/edr-freight-web/backoffice/src/super-admin/services/api/rolePermissionService.ts new file mode 100644 index 000000000..1d96edc1f --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/super-admin/services/api/rolePermissionService.ts @@ -0,0 +1,25 @@ +import { withHeaders } from "@/record-management/services/api/withHeaders"; +import axiosInstance from "@/shared/services/axiosInstance"; +import { PermissionListResponse } from "@/user-management/dto/permissions/permissonDto"; +import { AxiosResponse } from "axios"; + +export interface AssignRolePermissionsPayload { + firstId: string; + secondIds: string[]; +} + +// GET /role-permissions/given-first/{roleId} +export const getPermissionsByRoleId = async ( + roleId: string, +): Promise> => + axiosInstance.get(`/role-permissions/given-first/${roleId}`, { + headers: withHeaders(), + }); + +// POST /role-permissions/assign-seconds-for-first +export const assignPermissionsToRole = async ( + payload: AssignRolePermissionsPayload, +): Promise> => + axiosInstance.post("/role-permissions/assign-seconds-for-first", payload, { + headers: withHeaders(), + }); diff --git a/apps/edr-freight-web/backoffice/src/super-admin/services/api/roleService.ts b/apps/edr-freight-web/backoffice/src/super-admin/services/api/roleService.ts new file mode 100644 index 000000000..6ede479d8 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/super-admin/services/api/roleService.ts @@ -0,0 +1,20 @@ +import { withHeaders } from "@/record-management/services/api/withHeaders"; +import axiosInstance from "@/shared/services/axiosInstance"; +import { AxiosResponse } from "axios"; + +export interface RoleDto { + id: string; + name: { am: string; en: string }; + key: string; +} + +export interface RoleListResponse { + count: number; + items: RoleDto[]; +} + +export const getRoles = async (): Promise> => + axiosInstance.get("/roles", { + headers: withHeaders(), + params: { take: 100 }, + }); diff --git a/apps/edr-freight-web/backoffice/src/user-management/route.tsx b/apps/edr-freight-web/backoffice/src/user-management/route.tsx index 76d3182ad..af29cdc5e 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/route.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/route.tsx @@ -19,6 +19,7 @@ import { AppLayout } from "./Applayout"; import ActivityLogPage from "@/pages/ActivityLogPage"; import AdminRegistrationPage from "@/pages/Organizations/AdminRegistrationPage"; import OrganizationAdminsPage from "@/pages/OrganizationAdminsPage"; +import OrgAdminPermissionsPage from "@/super-admin/components/org-admins/OrgAdminPermissionsPage"; import UserProfileEditPage from "@/pages/UserProfileEditPage"; import UploadedDocumentViewPage from "@/pages/UploadedDocumentViewPage"; import EditOrganizationPage from "@/pages/Organizations/EditOrganizationPage"; @@ -197,6 +198,10 @@ export function UserManagementRoutes(): ReactElement { path="user-management/organization_admins" element={} /> + } + /> } From f39ae774019c23f57ae63313c03a986048c62d2f Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 28 Jul 2026 12:28:11 +0000 Subject: [PATCH 03/66] fix(backoffice): map raw IAM error codes to friendly messages (EDRFREIGHT-223, EDRFREIGHT-225) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @tria-plc/iamapi-common throws BadRequestException with a raw, untranslated code as the message (e.g. "user_role_not_found", "unit_employee_limit_reached:5") — useErrorHandler's extractMessage returned that string verbatim, so removing an org/unit admin whose role row didn't match, or inviting past a unit's employee cap, toasted the raw backend code instead of an explanation. Adds a small code→i18n map checked before the raw-message fallback, shared by both the async and sync error handlers so every caller (org-admin removal, employee invites, etc.) picks it up for free. Co-Authored-By: Claude Sonnet 5 --- .../src/locales/am/translation.json | 2 + .../src/locales/en/translation.json | 2 + .../src/locales/fr/translation.json | 2 + .../src/shared/hooks/useErrorHandler.ts | 38 +++++++++++++++---- 4 files changed, 36 insertions(+), 8 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/locales/am/translation.json b/apps/edr-freight-web/backoffice/src/locales/am/translation.json index 09acb8c62..f8e26a07a 100644 --- a/apps/edr-freight-web/backoffice/src/locales/am/translation.json +++ b/apps/edr-freight-web/backoffice/src/locales/am/translation.json @@ -1649,6 +1649,8 @@ "notFoundError": "የፈለጉትን መረጃ አልተገኘም።", "fileTooLarge": "ፋይሉ በጣም ትልቅ ነው። እባክዎ ፋይሉን አሳንሰው ዳግም ይሞክሩ።", "serverError": "ከአገልጋይ በኩል ችግር አለ። እባክዎ ዳግመኛ ይሞክሩ።", + "userRoleNotFound": "ይህ የአስተዳዳሪ ሚና ምደባ አልተገኘም — ቀደም ብሎ ተወግዶ ሊሆን ይችላል።", + "unitEmployeeLimitReached": "ይህ ክፍል የ{{limit}} ሰራተኞች ገደብ ላይ ደርሷል።", "attachmentDeleted": "አባሪው በተሳካ ሁኔታ ተሰርዟል።", "replyAdded": "ምላሹ በተሳካ ሁኔታ ታክሏል!", "replyError": "ምላሹን በመጨመር ላይ ስህተት አጋጥሟል።", diff --git a/apps/edr-freight-web/backoffice/src/locales/en/translation.json b/apps/edr-freight-web/backoffice/src/locales/en/translation.json index 3fa0f0ee3..66deb4395 100644 --- a/apps/edr-freight-web/backoffice/src/locales/en/translation.json +++ b/apps/edr-freight-web/backoffice/src/locales/en/translation.json @@ -1669,6 +1669,8 @@ "notFoundError": "We couldn't find what you were looking for.", "fileTooLarge": "The file is too large. Please reduce the file size and try again.", "serverError": "Something went wrong on our side. Please try again in a moment.", + "userRoleNotFound": "This admin role assignment could not be found — it may have already been removed.", + "unitEmployeeLimitReached": "This unit has reached its limit of {{limit}} employees.", "attachmentDeleted": "Attachment deleted successfully.", "replyAdded": "Reply added successfully!", "replyError": "An error occurred while adding the reply.", diff --git a/apps/edr-freight-web/backoffice/src/locales/fr/translation.json b/apps/edr-freight-web/backoffice/src/locales/fr/translation.json index 5019a2fb8..b888a2419 100644 --- a/apps/edr-freight-web/backoffice/src/locales/fr/translation.json +++ b/apps/edr-freight-web/backoffice/src/locales/fr/translation.json @@ -1169,6 +1169,8 @@ "notFoundError": "Nous n’avons pas trouvé ce que vous cherchiez.", "fileTooLarge": "Le fichier est trop volumineux. Veuillez réduire sa taille et réessayer.", "serverError": "Un problème est survenu de notre côté. Veuillez réessayer dans un instant.", + "userRoleNotFound": "Cette attribution de rôle d'administrateur est introuvable — elle a peut-être déjà été supprimée.", + "unitEmployeeLimitReached": "Cette unité a atteint sa limite de {{limit}} employés.", "attachmentDeleted": "Pièce jointe supprimée avec succès.", "replyAdded": "Réponse ajoutée avec succès !", "replyError": "Une erreur s’est produite lors de l’ajout de la réponse.", diff --git a/apps/edr-freight-web/backoffice/src/shared/hooks/useErrorHandler.ts b/apps/edr-freight-web/backoffice/src/shared/hooks/useErrorHandler.ts index f5d37d18d..9c42868ba 100644 --- a/apps/edr-freight-web/backoffice/src/shared/hooks/useErrorHandler.ts +++ b/apps/edr-freight-web/backoffice/src/shared/hooks/useErrorHandler.ts @@ -54,6 +54,28 @@ const extractMessage = (value: unknown): string | null => { } }; +// IAM (@tria-plc/iamapi-common) throws BadRequestException with a raw, +// untranslated code string as the message — no i18n on that side — so it +// would otherwise reach the UI verbatim (e.g. "user_role_not_found"). Map +// known codes to a friendly, translated message before falling back to the +// raw text. `unit_employee_limit_reached` carries its configured limit after +// a colon (e.g. "unit_employee_limit_reached:5"). +const UNIT_EMPLOYEE_LIMIT_PREFIX = "unit_employee_limit_reached:"; + +const mapIamErrorCode = ( + raw: string | null, + t: (key: string, options?: Record) => string, +): string | null => { + if (!raw) return null; + if (raw.startsWith(UNIT_EMPLOYEE_LIMIT_PREFIX)) { + return t("msg.unitEmployeeLimitReached", { + limit: raw.slice(UNIT_EMPLOYEE_LIMIT_PREFIX.length), + }); + } + if (raw === "user_role_not_found") return t("msg.userRoleNotFound"); + return null; +}; + // Maps an HTTP status code to the i18n key used when no backend message is available. const statusKeyFor = (status: number | undefined): string => { if (status === 400 || status === 422) return "msg.validationError"; @@ -111,7 +133,7 @@ const parseBlobBody = async (blob: Blob): Promise => { }; export const useErrorHandler = ( - t: (key: string) => string, + t: (key: string, options?: Record) => string, ) => { const getErrorMessage = useCallback( async (err: unknown): Promise => { @@ -134,15 +156,15 @@ export const useErrorHandler = ( const fromException = extractMessage((data as any)?.exception?.response) ?? extractMessage((data as any)?.exception); - if (fromException) return fromException; + if (fromException) return mapIamErrorCode(fromException, t) ?? fromException; const fromData = extractMessage(data); - if (fromData) return fromData; + if (fromData) return mapIamErrorCode(fromData, t) ?? fromData; } if (err instanceof Error) { const fromError = extractMessage(err.message); - if (fromError) return fromError; + if (fromError) return mapIamErrorCode(fromError, t) ?? fromError; } return t(statusKeyFor(status)); @@ -186,7 +208,7 @@ export const useErrorHandler = ( }; export const useClientErrorHandler = ( - t: (key: string) => string, + t: (key: string, options?: Record) => string, ) => { const getErrorMessage = useCallback( (err: unknown): string => { @@ -205,13 +227,13 @@ export const useClientErrorHandler = ( const fromException = extractMessage(data?.exception?.response) ?? extractMessage(data?.exception); - if (fromException) return fromException; + if (fromException) return mapIamErrorCode(fromException, t) ?? fromException; const fromData = extractMessage(data); - if (fromData) return fromData; + if (fromData) return mapIamErrorCode(fromData, t) ?? fromData; const fromError = extractMessage((err as any).message); - if (fromError) return fromError; + if (fromError) return mapIamErrorCode(fromError, t) ?? fromError; } return t(statusKeyFor(status)); From 9c16579f547af659ce7ce4b6718aef05aa31c10d Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 28 Jul 2026 12:36:01 +0000 Subject: [PATCH 04/66] fix(backoffice): scope unit-admin role matching to the selected org (EDRFREIGHT-223) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getAdminRoleInfo picked a user's unit_admin role by role key alone — a unit-admin grant carries only a unitId, no organizationId, so a user who is unit-admin in one org and (say) org-admin in another had the wrong unit attached to their row wherever both orgs share the viewer's admin list. That wrong unitId then flowed into the remove action, which deletes by exact {userId, roleId, unitId} match — a legitimate 0-match, surfaced as "user_role_not_found", removal silently failing. Fetches the selected org's unit ids (same pattern as the position-type form's org→units scoping) and requires the unit-admin grant's unitId to be one of them before treating it as this org's grant. Co-Authored-By: Claude Sonnet 5 --- .../org-admins/OrgAdminsColumnDefn.tsx | 19 +++++++++++------ .../components/org-admins/OrgAdminsPage.tsx | 21 ++++++++++++++++++- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsColumnDefn.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsColumnDefn.tsx index 97c3bfbc6..8bbe7c587 100644 --- a/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsColumnDefn.tsx +++ b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsColumnDefn.tsx @@ -32,13 +32,15 @@ export interface AdminRoleInfo { /** * all-admins/:id returns users who are org admins of the org OR unit admins of * one of its units; userRoles carries every role of the user, so match the org - * explicitly for the org-admin grant. + * explicitly for the org-admin grant. The unit-admin grant carries no + * organizationId of its own, only a unitId, so orgUnitIds (every unit that + * belongs to the selected org) is required to tell a same-org unit-admin + * grant apart from a same-user unit-admin grant in a different org. */ -// ponytail: unit relation isn't loaded, so a unit_admin grant from another org -// can't be told apart — acceptable, the server only returns admins of this org. export function getAdminRoleInfo( admin: OrgAdminUser, selectedOrgId: string, + orgUnitIds: Set, ): AdminRoleInfo { const roles = admin.userRoles ?? []; const isOrgAdmin = roles.some( @@ -47,7 +49,10 @@ export function getAdminRoleInfo( r.organizationId === selectedOrgId, ); const unitRole = roles.find( - (r) => r.role?.key === UNIT_ADMIN_ROLE_KEY && r.unitId, + (r) => + r.role?.key === UNIT_ADMIN_ROLE_KEY && + !!r.unitId && + orgUnitIds.has(r.unitId), ); return { isOrgAdmin, @@ -58,6 +63,7 @@ export function getAdminRoleInfo( interface ColumnCallbacks { selectedOrgId: string; + orgUnitIds: Set; localizedName: (name?: { am?: string; en?: string }) => string; onEdit: (admin: OrgAdminUser) => void; onResend: (admin: OrgAdminUser) => void; @@ -67,6 +73,7 @@ interface ColumnCallbacks { export function getOrgAdminsColumnDefn({ selectedOrgId, + orgUnitIds, localizedName, onEdit, onResend, @@ -118,7 +125,7 @@ export function getOrgAdminsColumnDefn({ id: "role", header: () => t("orgAdmins.columns.role"), cell: ({ row }) => { - const info = getAdminRoleInfo(row.original, selectedOrgId); + const info = getAdminRoleInfo(row.original, selectedOrgId, orgUnitIds); return (
{info.isOrgAdmin && ( @@ -179,7 +186,7 @@ export function getOrgAdminsColumnDefn({ enableHiding: false, cell: ({ row }) => { const admin = row.original; - const roleInfo = getAdminRoleInfo(admin, selectedOrgId); + const roleInfo = getAdminRoleInfo(admin, selectedOrgId, orgUnitIds); return ( diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsPage.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsPage.tsx index 53ac19841..adebd98a5 100644 --- a/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsPage.tsx @@ -31,6 +31,8 @@ import { Badge } from "@/shared/common/ui/badge"; import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable"; import { useLocalizedName } from "@/shared/common/localizedName"; import { OrganizationDto } from "@/shared/dto/organization/organizationDto"; +import { useUnit } from "@/user-management/hooks/useUnit"; +import { UnitDto } from "@/user-management/dto/unit/unitDto"; import { OrgAdminUser, useOrgAdmins, @@ -89,6 +91,22 @@ export default function OrgAdminsPage() { skip: pageIndex * pageSize, }); + const { getList: getUnitList } = useUnit(); + // A unit-admin grant only carries a unitId, no organizationId — this is the + // set that tells "unit_admin of this org" apart from "unit_admin of some + // other org the same user also administers" (see getAdminRoleInfo). + const { data: orgUnitsResponse } = getUnitList(selectedOrg?.id ?? "", { + take: 3000, + skip: 0, + }); + const orgUnitIds = useMemo( + () => + new Set( + (orgUnitsResponse?.data?.items ?? []).map((unit: UnitDto) => unit.id), + ), + [orgUnitsResponse], + ); + useEffect(() => { setPageIndex(0); }, [selectedOrg?.id, pageSize]); @@ -178,6 +196,7 @@ export default function OrgAdminsPage() { () => getOrgAdminsColumnDefn({ selectedOrgId: selectedOrg?.id ?? "", + orgUnitIds, localizedName: localizedName as (name?: { am?: string; en?: string; @@ -191,7 +210,7 @@ export default function OrgAdminsPage() { onRemove: (admin, roleInfo) => setRemoveTarget({ admin, roleInfo }), }), // eslint-disable-next-line react-hooks/exhaustive-deps - [selectedOrg?.id], + [selectedOrg?.id, orgUnitIds], ); return ( From dcda8d7d3765306e90ff973d6336e4984f9d129f Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 28 Jul 2026 13:44:06 +0000 Subject: [PATCH 05/66] feat(freight-api): drop fan claim from fayda verification esignet userinfo carries no national id number. keep sub/name/email/ phone/address, remove fanClaims config and the hard-fail gate that would've blocked every real verification. Co-Authored-By: Claude Sonnet 5 --- apps/edr-freight-api/.env.example | 3 +++ .../src/config/fayda.config.ts | 9 +++++++-- .../src/modules/verifayda/verifayda.dto.ts | 19 +++++++++++++++---- .../modules/verifayda/verifayda.service.ts | 18 ++++++++++++++---- 4 files changed, 39 insertions(+), 10 deletions(-) diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 58fb60b18..d6a39bfed 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -93,6 +93,9 @@ FAYDA_PRIVATE_KEY_BASE64= FAYDA_REDIRECT_URI=http://localhost:3001/api/fayda/verification/complete # OAuth redirect_uri for WEB clients. Defaults to FAYDA_REDIRECT_URI when unset. FAYDA_WEB_REDIRECT_URI=http://localhost:3000/callback +# OAuth redirect_uri for the customer portal (its own origin — must also be +# registered with eSignet). Defaults to FAYDA_WEB_REDIRECT_URI when unset. +FAYDA_PORTAL_REDIRECT_URI=http://localhost:5173/callback CLIENT_ASSERTION_TYPE=urn:ietf:params:oauth:client-assertion-type:jwt-bearer FAYDA_SCOPE=openid profile email phone address FAYDA_ACR_VALUES=mosip:idp:acr:generated-code diff --git a/apps/edr-freight-api/src/config/fayda.config.ts b/apps/edr-freight-api/src/config/fayda.config.ts index a25289159..30525dd01 100644 --- a/apps/edr-freight-api/src/config/fayda.config.ts +++ b/apps/edr-freight-api/src/config/fayda.config.ts @@ -15,7 +15,7 @@ export interface FaydaJwk { qi?: string; } -export type FaydaPlatform = 'WEB' | 'MOBILE'; +export type FaydaPlatform = 'WEB' | 'MOBILE' | 'PORTAL'; export interface FaydaConfig { enabled: boolean; @@ -25,8 +25,10 @@ export interface FaydaConfig { userInfoEndpoint: string; /** OAuth redirect_uri sent to eSignet for MOBILE clients. */ redirectUri: string; - /** OAuth redirect_uri sent to eSignet for WEB clients. Falls back to `redirectUri`. */ + /** OAuth redirect_uri sent to eSignet for WEB (backoffice) clients. Falls back to `redirectUri`. */ webRedirectUri: string; + /** OAuth redirect_uri sent to eSignet for the customer portal. Falls back to `webRedirectUri`. */ + portalRedirectUri: string; privateJwk: FaydaJwk; scope: string; acrValues: string; @@ -77,6 +79,7 @@ export default registerAs('fayda', (): FaydaConfig => { const sessionTtl = Number.parseInt(process.env.FAYDA_SESSION_TTL_MINUTES ?? '10', 10); const redirectUri = process.env.FAYDA_REDIRECT_URI ?? ''; const webRedirectUri = process.env.FAYDA_WEB_REDIRECT_URI || redirectUri; + const portalRedirectUri = process.env.FAYDA_PORTAL_REDIRECT_URI || webRedirectUri; if (!enabled) { return { enabled: false, @@ -86,6 +89,7 @@ export default registerAs('fayda', (): FaydaConfig => { userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT ?? '', redirectUri, webRedirectUri, + portalRedirectUri, privateJwk: { kty: 'RSA', n: '', e: '', d: '' }, scope, acrValues, @@ -117,6 +121,7 @@ export default registerAs('fayda', (): FaydaConfig => { userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT!, redirectUri, webRedirectUri, + portalRedirectUri, privateJwk: decodePrivateJwk(process.env.FAYDA_PRIVATE_KEY_BASE64!), scope, acrValues, diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.dto.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.dto.ts index 1885b11e9..f9ffe04cb 100644 --- a/apps/edr-freight-api/src/modules/verifayda/verifayda.dto.ts +++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.dto.ts @@ -13,14 +13,14 @@ export class StartVerificationDto { purpose?: 'LOGIN' | 'VERIFY'; @ApiPropertyOptional({ - enum: ['WEB', 'MOBILE'], + enum: ['WEB', 'MOBILE', 'PORTAL'], default: 'WEB', description: - 'Client platform. Selects which OAuth redirect_uri is sent to eSignet: WEB uses FAYDA_WEB_REDIRECT_URI, MOBILE uses FAYDA_REDIRECT_URI. Both land on the same /complete endpoint with identical handling.', + 'Client platform. Selects which OAuth redirect_uri is sent to eSignet: WEB (backoffice) uses FAYDA_WEB_REDIRECT_URI, PORTAL uses FAYDA_PORTAL_REDIRECT_URI, MOBILE uses FAYDA_REDIRECT_URI. All land on the same /complete handling.', }) @IsOptional() - @IsIn(['WEB', 'MOBILE']) - platform?: 'WEB' | 'MOBILE'; + @IsIn(['WEB', 'MOBILE', 'PORTAL']) + platform?: 'WEB' | 'MOBILE' | 'PORTAL'; @ApiPropertyOptional({ type: Boolean, @@ -57,6 +57,12 @@ export class CompleteVerificationResultDto { agentId?: string; }; + @ApiPropertyOptional({ + description: + 'Fayda OIDC subject — the stable key a verified identity is stored under (VERIFY flow). Pairwise pseudonymous.', + }) + sub?: string; + @ApiPropertyOptional({ description: 'Verified full name from Fayda (VERIFY flow).' }) fullName?: string; @@ -74,6 +80,11 @@ export class CompleteVerificationResultDto { @ApiPropertyOptional({ description: 'Verified gender from Fayda (VERIFY flow).' }) gender?: string; + @ApiPropertyOptional({ + description: 'Verified address from Fayda, English rendering (VERIFY flow).', + }) + address?: string; + @ApiPropertyOptional({ description: 'Whether the verified identity was saved to IAM. False if the IAM write failed.' }) userDataSaved?: boolean; 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 6e3e2e095..16441bd0d 100644 --- a/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts +++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts @@ -56,11 +56,15 @@ export interface CompleteVerificationResult { promptPasswordSetup?: boolean; iamUserId?: string; user?: FaydaUserSummary; + /** Fayda OIDC subject — the stable key a verified identity is stored under. */ + sub?: string; fullName?: string; email?: string; phoneNumber?: string; birthdate?: string; gender?: string; + /** Verified address, English rendering (falls back to Amharic). */ + address?: string; userDataSaved?: boolean; } @@ -125,11 +129,15 @@ export class VerifaydaService { }); } - /** WEB clients use `webRedirectUri`; MOBILE uses the base `redirectUri`. */ + /** + * Each client lands on its own registered redirect_uri: MOBILE on the base + * one, the customer portal on its own origin, everything else (backoffice) on + * the web one. All three must be registered with eSignet. + */ private redirectUriForPlatform(platform?: FaydaPlatform): string { - return platform === 'MOBILE' - ? this.faydaConfig.redirectUri - : this.faydaConfig.webRedirectUri; + if (platform === 'MOBILE') return this.faydaConfig.redirectUri; + if (platform === 'PORTAL') return this.faydaConfig.portalRedirectUri; + return this.faydaConfig.webRedirectUri; } async completeVerification( @@ -210,11 +218,13 @@ export class VerifaydaService { result = { purpose: 'VERIFY', verified: true, + sub: normalized.sub, fullName: normalized.fullName, email: normalized.email, phoneNumber: normalized.phoneNumber, birthdate: normalized.birthdate, gender: normalized.gender, + address: normalized.addressEn ?? normalized.addressAm, userDataSaved, iamUserId: iamUserId ?? undefined, token: sessionToken?.token, From 68f6ec8c5f64b107de1d2de96b8b5b4ad50fc923 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 28 Jul 2026 13:44:23 +0000 Subject: [PATCH 06/66] feat(freight-api): gate poa paper, fayda identity, foreign passport - dars delegation paper mandatory wherever poa state changes (named, removed, forwarder role applied for/approved), not just onboarding - ethiopian companies verify owner (and poa, once named) via fayda; identity, not general manager, is the verified subject - foreign companies require a typed owner passport number instead, independent of an optional fayda verification - fanNumber removed from client-writable dtos; server-derived only Co-Authored-By: Claude Sonnet 5 --- .../modules/companies/companies.controller.ts | 30 + .../src/modules/companies/companies.module.ts | 3 + .../modules/companies/companies.service.ts | 601 ++++++++++++++++-- .../dto/complete-identity-verification.dto.ts | 148 +++++ .../onboarding-requirements-response.dto.ts | 16 +- .../companies/dto/profile-response.dto.ts | 15 + .../companies/dto/update-profile.dto.ts | 19 +- .../file-upload-settings.service.ts | 21 + .../poa-delegation.constants.ts | 52 ++ .../src/seed/file-upload-settings.seeder.ts | 30 +- 10 files changed, 871 insertions(+), 64 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts create mode 100644 apps/edr-freight-api/src/modules/file-upload-settings/poa-delegation.constants.ts diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 8e3be4d1a..825c332e5 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -35,6 +35,10 @@ import { CreateExternalProfileDto } from "./dto/create-external-profile.dto"; import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto"; import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto"; import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto"; +import { + CompanyIdentityStateDto, + CompleteIdentityVerificationDto, +} from "./dto/complete-identity-verification.dto"; import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto"; import { StartOnboardingDto } from "./dto/start-onboarding.dto"; import { DashboardQueryDto } from "./dto/dashboard-query.dto"; @@ -378,6 +382,32 @@ export class CompaniesController { return this.companiesService.removePoaDelegationLetter(user.id, fileId); } + @Post("identity/fayda/complete") + @ApiOperation({ + summary: + "Bind a completed Fayda verification to the company's owner or Power of Attorney. " + + "Start the flow with POST /fayda/verification/start (platform=PORTAL), then post the returned code+state here. " + + "The verified name, phone, email and address are written from the Fayda payload; on an approved company the change is staged for backoffice review.", + }) + async completeIdentityVerification( + @CurrentUser() user: CurrentIamUser, + @Body() dto: CompleteIdentityVerificationDto, + ): Promise { + return this.companiesService.completeIdentityVerification(user.id, dto); + } + + @Delete("identity/fayda/poa") + @ApiOperation({ + summary: + "Remove the company's Power of Attorney — the verified identity, its details and the delegation paper together. " + + "Refused while the company holds a freight forwarder role, which cannot operate without a representative.", + }) + async removePoaIdentity( + @CurrentUser() user: CurrentIamUser, + ): Promise { + return this.companiesService.removePoaIdentity(user.id); + } + @Patch("onboarding-step") @ApiOperation({ summary: "Persist the user's current onboarding wizard step" }) @HttpCode(HttpStatus.NO_CONTENT) diff --git a/apps/edr-freight-api/src/modules/companies/companies.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts index 73826689a..450222657 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.module.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -20,6 +20,7 @@ import { CompanyProfileRepository } from "./company-profile.repository"; import { CompanyChangeRequestRepository } from "./company-change-request.repository"; import { ETradeService } from "./services/etrade.service"; import { CompanyNotifierService } from "./company-notifier.service"; +import { VerifaydaModule } from "../verifayda/verifayda.module"; @Module({ imports: [ @@ -38,6 +39,8 @@ import { CompanyNotifierService } from "./company-notifier.service"; // imports this module back for portal recipient targeting, hence forwardRef. NotificationsModule, forwardRef(() => NotificationInboxModule), + // Fayda identity verification for the company's owner and PoA. + VerifaydaModule, ], controllers: [CompaniesController], providers: [ diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 6650dfca5..cf03d8d50 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -17,6 +17,18 @@ import { import { FilesService } from "../files/files.service"; import { FileRecord } from "../files/entities/file.entity"; import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service"; +import { + POA_DELEGATION_FILE_KEY, + POA_DELEGATION_LABEL, + POA_DELEGATION_PENDING_CODE, +} from "../file-upload-settings/poa-delegation.constants"; +import { VerifaydaService } from "../verifayda/verifayda.service"; +import { + buildCompanyIdentityState, + CompanyIdentityStateDto, + CompleteIdentityVerificationDto, + IdentitySubject, +} from "./dto/complete-identity-verification.dto"; import { ETradeService } from "./services/etrade.service"; import { CompanyNotifierService } from "./company-notifier.service"; import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto"; @@ -58,10 +70,6 @@ const LICENSE_CODE = "business_license"; /** Code for a license file staged in an open change request (not yet live). */ const LICENSE_PENDING_CODE = "business_license_pending"; -/** Mirrors the field seeded in seed/file-upload-settings.seeder.ts. */ -const POA_DELEGATION_FILE_KEY = "poa_delegation_letter"; -/** Code for a PoA letter staged in an open change request (not yet live). */ -const POA_DELEGATION_PENDING_CODE = "poa_delegation_letter_pending"; /** FileRecord resource that company-level documents are stored under. */ const COMPANY_RESOURCE = "companies"; /** company.attributes keys that together mean "a PoA was entered". */ @@ -79,6 +87,40 @@ const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [ { key: "poaPhone", label: "PoA phone" }, ]; +/** + * `attributes` key prefix per verifiable person. The owner is NOT the general + * manager — GM is a plain typed role (the portal offers a "same as owner" copy + * once the owner is verified), while the owner is who this verification + * actually proves. They're very often the same human; that's what the copy is + * for. + */ +const IDENTITY_PREFIX: Record = { + owner: "owner", + poa: "poa", +}; + +const IDENTITY_LABEL: Record = { + owner: "owner", + poa: "Power of Attorney", +}; + +/** + * Identity fields a Fayda verification owns outright, per person. Once verified + * these can no longer be typed — the government IdP is the source, so an edit + * that disagrees with it is either a mistake or an attempt to launder the + * guarantee away. The GM fields are deliberately absent: GM is never itself + * Fayda-verified, so it stays freely editable regardless of the owner's state. + */ +const IDENTITY_OWNED_FIELDS: Record = { + owner: ["ownerName", "ownerEmail", "ownerPhone", "ownerAddress"], + poa: ["poaName", "poaEmail", "poaPhone", "poaAddress"], +}; + +/** The attributes a verification writes, for one person. */ +interface VerifiedIdentityAttributes { + [key: string]: unknown; +} + export interface UserIdentity { userId: string; firstName: string; @@ -100,6 +142,7 @@ export class CompaniesService { private readonly etradeService: ETradeService, private readonly companyNotifier: CompanyNotifierService, private readonly dataSource: DataSource, + private readonly verifaydaService: VerifaydaService, ) { } /** @@ -599,7 +642,9 @@ export class CompaniesService { */ private mapProfileDtoToCompanyUpdates( company: Company, - dto: Partial, + dto: Partial & { + faydaIdentity?: VerifiedIdentityAttributes; + }, ): Record { const companyUpdates: Record = {}; const attrUpdates: Record = { ...(company.attributes ?? {}) }; @@ -617,7 +662,6 @@ export class CompaniesService { if (dto.tin !== undefined && dto.tin !== company.tin) companyUpdates.tin = dto.tin; if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber; - if (dto.fanNumber !== undefined) companyUpdates.fanNumber = dto.fanNumber; if (dto.contactPersonName !== undefined) attrUpdates.contactPersonName = dto.contactPersonName; @@ -661,6 +705,47 @@ export class CompaniesService { if (dto.etradePhone !== undefined) companyUpdates.etradePhone = normalizeE164(dto.etradePhone); + // A plain typed field — never Fayda-verified, so no lock ever applies to + // it. Independent of the owner's verification: still required for a + // foreign company even if the owner also verifies with Fayda. + if (dto.ownerPassportNumber !== undefined) + attrUpdates.ownerPassportNumber = dto.ownerPassportNumber; + + // A verified identity overwrites the person's details. `faydaIdentity` + // never comes off the wire — the global validation pipe runs with + // forbidNonWhitelisted, so a client that sends it is rejected outright; it + // only reaches here from completeIdentityVerification, directly or through + // a staged snapshot. + if (dto.faydaIdentity) { + Object.assign(attrUpdates, dto.faydaIdentity); + } + + // Renaming a Fayda-verified person by hand would launder the guarantee + // away, so the fields the verification owns are refused once it exists. + for (const subject of ["owner", "poa"] as IdentitySubject[]) { + if (!attrUpdates[`${IDENTITY_PREFIX[subject]}FaydaSub`]) continue; + for (const field of IDENTITY_OWNED_FIELDS[subject]) { + const incoming = (dto as Record)[field]; + if (incoming === undefined) continue; + // The verification itself is allowed to write them; anything else is + // compared against what is already stored, not against the value this + // same call just copied into the patch. Phones are compared normalized: + // a form that re-renders +251911000000 as 0911000000 is echoing the + // stored value back, not trying to change it. + if (dto.faydaIdentity && field in dto.faydaIdentity) continue; + const stored = company.attributes?.[field]; + const same = field.endsWith("Phone") + ? normalizeE164(String(incoming)) === + normalizeE164(String(stored ?? "")) + : incoming === stored; + if (!same) { + throw new BadRequestException( + `${field} is set by the Fayda verification of this company's ${IDENTITY_LABEL[subject]} and cannot be edited. Re-verify to change it.`, + ); + } + } + } + companyUpdates.attributes = attrUpdates; return companyUpdates; } @@ -702,6 +787,19 @@ export class CompaniesService { ): Promise { const { profile, company } = await this.getCompanyInfoByUserId(userId); + // Naming (or renaming) a Power of Attorney is one of the writes that can + // leave the company with a representative and nothing evidencing them, so + // it is gated here. Edits that don't touch the PoA are left alone — a + // company carrying legacy details must not be locked out of every other + // field until it produces a paper. + if (POA_ATTRIBUTES.some((k) => dto[k] !== undefined)) { + const attributes = this.mapProfileDtoToCompanyUpdates(company, dto) + .attributes as Record; + await this.assertPoaDelegationSatisfied(company.id, attributes, { + requirePoa: await this.isFreightForwarder(company.id), + }); + } + if (company.status !== CompanyStatus.Active) { await this.assertTinAvailable(company, dto.tin); const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, dto); @@ -1127,11 +1225,24 @@ export class CompaniesService { // blacklist skip all this — staff must always be able to act against a bad // account. return this.dataSource.transaction(async (manager) => { - await manager.findOne(Company, { + const company = await manager.findOne(Company, { where: { id: existing.companyId }, lock: { mode: "pessimistic_write" }, }); + // Putting a forwarder into service without a Power of Attorney backed by + // a DARS paper is the thing EDRFREIGHT-358 forbids, so the approval is + // the last place it has to be checked — the role may have been applied + // for before the paper was withdrawn. + if (company && existing.type === ProfileType.freightForwarder) { + this.assertIdentityVerified(company, { requirePoa: true }); + await this.assertPoaDelegationSatisfied( + company.id, + company.attributes, + { requirePoa: true }, + ); + } + const [companyDocs, profileDocs] = await Promise.all([ this.filesService.findWithOpenChangeRequest( [existing.companyId], @@ -1382,6 +1493,18 @@ export class CompaniesService { ); if (existing) continue; + // A forwarder signs on other companies' behalf, so it cannot be taken on + // without a Power of Attorney and its DARS paper — checked here so the + // customer is told at the point of asking, not at review. + if (type === ProfileType.freightForwarder) { + this.assertIdentityVerified(company, { requirePoa: true }); + await this.assertPoaDelegationSatisfied( + companyId, + await this.effectivePoaAttributes(company), + { requirePoa: true }, + ); + } + // Self-service role adds start Pending and carry no reference — a reference // is minted only when a backoffice reviewer approves the role. await this.companyProfilesRepo.create({ @@ -1419,6 +1542,14 @@ export class CompaniesService { } let created = await this.companyProfilesRepo.findByType(companyId, type); + if (!created && type === ProfileType.freightForwarder) { + this.assertIdentityVerified(company, { requirePoa: true }); + await this.assertPoaDelegationSatisfied( + companyId, + await this.effectivePoaAttributes(company), + { requirePoa: true }, + ); + } if (!created) { // New self-service roles start Pending (awaiting backoffice approval) and // carry no reference until approved. @@ -1453,11 +1584,17 @@ export class CompaniesService { userId: string, ): Promise { const { profile, company } = await this.getCompanyInfoByUserId(userId); + const identity = this.getCompanyIdentityState(company); - // 1. Required company-information fields. - const missingInfo = this.REQUIRED_COMPANY_INFO.filter( - (f) => !f.get(company), - ).map((f) => ({ key: f.key, label: f.label })); + // 1. Required company-information fields. The FAN is never one of them — + // Fayda verification doesn't produce a FAN, so it's never collected as + // part of onboarding at all (see the identity block below). + const requiredInfo = this.REQUIRED_COMPANY_INFO.filter( + (f) => f.key !== "fanNumber", + ); + const missingInfo = requiredInfo + .filter((f) => !f.get(company)) + .map((f) => ({ key: f.key, label: f.label })); // 2. Nationality-based company documents + which are already uploaded. const documentSettingCode = this.documentSettingCodeFor(company.nationality); @@ -1504,26 +1641,31 @@ export class CompaniesService { // 4. Power of Attorney. Optional in general, but a freight forwarder acts on // other companies' behalf so its PoA is mandatory. Either way, a PoA that - // has been entered must be evidenced by the delegation letter. + // has been entered must be evidenced by the DARS delegation paper — a legal + // requirement, so unlike the documents above it does not depend on the + // upload set carrying a field for it (see poa-delegation.constants.ts). const poaRequired = (company.companyProfiles ?? []).some( (p) => p.type === ProfileType.freightForwarder, ); const poaProvided = POA_ATTRIBUTES.some((k) => (company.attributes?.[k] as string | undefined)?.trim(), ); - const missingPoaFields = poaRequired - ? REQUIRED_POA_FIELDS.filter( - (f) => !(company.attributes?.[f.key] as string | undefined)?.trim(), - ) - : []; - // Only gate on the letter once the document set actually carries the field. - const delegationField = (setting?.fields ?? []).find( - (f) => f.fileKey === POA_DELEGATION_FILE_KEY, - ); - const missingDelegation = - Boolean(delegationField) && - (poaRequired || poaProvided) && - !uploadedCodes.has(POA_DELEGATION_FILE_KEY); + // An Ethiopian company does not type its PoA details at all — they arrive + // from the Fayda verification — so reporting them as missing fields would + // ask for something the form no longer offers. The identity block below + // reports "verify your PoA" instead. + const missingPoaFields = + poaRequired && !identity.faydaRequired + ? REQUIRED_POA_FIELDS.filter( + (f) => !(company.attributes?.[f.key] as string | undefined)?.trim(), + ) + : []; + const delegation = await this.getPoaDelegationState(company.id); + const delegationDue = poaRequired || poaProvided; + const missingDelegation = delegationDue && !delegation.onFile; + // A paper the reviewer sent back is not evidence — the customer has to + // replace it before the application counts as complete. + const flaggedDelegation = delegationDue && delegation.flagged; const outstanding = [ ...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`), @@ -1534,29 +1676,62 @@ export class CompaniesService { ), ...missingPoaFields.map((f) => `Add your ${f.label.toLowerCase()}`), ...(missingDelegation - ? ["Upload the delegation letter for your Power of Attorney"] + ? [`Upload the ${POA_DELEGATION_LABEL} for your Power of Attorney`] + : []), + ...(flaggedDelegation + ? [`Re-upload your ${POA_DELEGATION_LABEL} — EDR asked for a correction`] + : []), + ...(identity.faydaRequired && !identity.owner.verified + ? ["Verify the company owner's identity with Fayda"] + : []), + ...(identity.faydaRequired && + (poaRequired || poaProvided) && + !identity.poa.verified + ? ["Verify your Power of Attorney's identity with Fayda"] + : []), + ...(identity.passportRequired && !identity.owner.passportNumber + ? ["Add the company owner's passport number"] : []), ]; // Progress spans every required item the user has to satisfy: company-info // fields, required documents, one license per operational profile, and the - // PoA details/letter whenever those are mandatory. + // PoA details/paper whenever those are mandatory. const requiredDocCount = documents.filter((d) => d.isRequired).length; const poaItemCount = - (poaRequired ? REQUIRED_POA_FIELDS.length : 0) + - (delegationField && (poaRequired || poaProvided) ? 1 : 0); + (poaRequired && !identity.faydaRequired + ? REQUIRED_POA_FIELDS.length + : 0) + (delegationDue ? 1 : 0); + // One item per identity credential the company has to prove: the owner + // always (Fayda for Ethiopian, passport for foreign), the PoA once there + // is one and Fayda is what's mandatory here. + const identityItemCount = identity.faydaRequired + ? delegationDue + ? 2 + : 1 + : identity.passportRequired + ? 1 + : 0; + const missingIdentityCount = identity.faydaRequired + ? (identity.owner.verified ? 0 : 1) + + (delegationDue && !identity.poa.verified ? 1 : 0) + : identity.passportRequired && !identity.owner.passportNumber + ? 1 + : 0; const total = - this.REQUIRED_COMPANY_INFO.length + + requiredInfo.length + requiredDocCount + licenseProfiles.length + - poaItemCount; + poaItemCount + + identityItemCount; const completed = total - (missingInfo.length + missingDocs.length + missingLicenses.length + missingPoaFields.length + - (missingDelegation ? 1 : 0)); + (missingDelegation || flaggedDelegation ? 1 : 0) + + missingIdentityCount); return new OnboardingRequirementsResponseDto({ documentSettingCode, @@ -1567,10 +1742,15 @@ export class CompaniesService { poa: { required: poaRequired, provided: poaProvided, - delegationLetterUploaded: uploadedCodes.has(POA_DELEGATION_FILE_KEY), + delegationLetterUploaded: delegation.onFile, + delegationLetterFlagged: delegation.flagged, missingFields: missingPoaFields, - complete: missingPoaFields.length === 0 && !missingDelegation, + complete: + missingPoaFields.length === 0 && + !missingDelegation && + !flaggedDelegation, }, + identity, progress: { completed, total }, isComplete: outstanding.length === 0, onboardingCompleted: profile.onboardingCompleted, @@ -2044,15 +2224,350 @@ export class CompaniesService { } // --------------------------------------------------------------------------- - // Power of Attorney delegation letter + // Power of Attorney delegation paper (DARS) // // A company-level document that follows the same staged-review model as the // business license: on an approved (Active) company an upload lands under the - // pending code and the live letter is flagged for removal, so the reviewer + // pending code and the live paper is flagged for removal, so the reviewer // sees both and approval swaps them atomically. During onboarding it goes live. // --------------------------------------------------------------------------- - /** The company's PoA letter(s), with each file's review status resolved. */ + /** + * What the company has on file towards its DARS delegation paper. A paper + * staged for review counts as "on file" — it is the customer's whole + * obligation discharged; whether it is good enough is the reviewer's call, + * recorded as `flagged`. + */ + private async getPoaDelegationState( + companyId: string, + ignoreFileIds: string[] = [], + ): Promise<{ onFile: boolean; flagged: boolean }> { + const records = ( + await this.filesService.findByResource(companyId, COMPANY_RESOURCE) + ).filter( + (r) => + (r.code === POA_DELEGATION_FILE_KEY || + r.code === POA_DELEGATION_PENDING_CODE) && + !ignoreFileIds.includes(r.id), + ); + return { + onFile: records.length > 0, + flagged: records.some((r) => r.reviewStatus === "change_requested"), + }; + } + + /** + * The rule behind EDRFREIGHT-358: a company that names a Power of Attorney + * must evidence it with a DARS delegation paper, and a freight forwarder — + * which signs on other companies' behalf — must have both, verified. + * + * This is enforced at every write that can break the pairing (PoA details + * saved, paper removed, forwarder role applied for or approved) rather than + * only at onboarding submission, which is what let a company that finished + * onboarding as an importer pick up the forwarder role with neither. + * + * `attributes` is the state being written, which is not always the state on + * the row yet — a staged change request carries it, and a removal has to be + * judged against the files that would survive it (`ignoreFileIds`). + */ + private async assertPoaDelegationSatisfied( + companyId: string, + attributes: Record | null | undefined, + opts: { requirePoa: boolean; ignoreFileIds?: string[] }, + ): Promise { + const read = (key: string) => + (attributes?.[key] as string | undefined)?.trim(); + const poaProvided = POA_ATTRIBUTES.some((k) => read(k)); + if (!opts.requirePoa && !poaProvided) return; + + if (opts.requirePoa) { + const missing = REQUIRED_POA_FIELDS.filter((f) => !read(f.key)); + if (missing.length > 0) { + throw new BadRequestException( + `A freight forwarder acts on other companies' behalf, so a Power of Attorney is required. ` + + `Add the ${missing.map((f) => f.label.toLowerCase()).join(", ")} first.`, + ); + } + } + + const { onFile, flagged } = await this.getPoaDelegationState( + companyId, + opts.ignoreFileIds, + ); + if (!onFile) { + throw new BadRequestException( + `Upload the ${POA_DELEGATION_LABEL} for the Power of Attorney` + + (opts.requirePoa ? " — it is required for freight forwarders." : "."), + ); + } + if (flagged) { + throw new BadRequestException( + `The ${POA_DELEGATION_LABEL} on file needs to be corrected. ` + + `Re-upload it before continuing.`, + ); + } + } + + /** Does this company operate as a freight forwarder? */ + private async isFreightForwarder(companyId: string): Promise { + const profiles = await this.companyProfilesRepo.findByCompanyId(companyId); + return profiles.some((p) => p.type === ProfileType.freightForwarder); + } + + // --------------------------------------------------------------------------- + // Fayda identity verification (owner / PoA) + // + // A completed VeriFayda verification proves a person's name, phone, email + // and address — Fayda's userinfo carries no national ID number, so none of + // that is collected here. For an Ethiopian company both the owner and its + // PoA (once named) must be verified before the company can trade. Fayda is + // an Ethiopian national ID system, so a foreign company's owner proves + // identity with a typed passport number instead — required on its own + // terms, not waived by an owner who happens to verify with Fayda too. + // --------------------------------------------------------------------------- + + /** + * Verification state for both people, plus whether it is mandatory here. + * `complete` answers the gate question directly so the portal, the onboarding + * requirements and the assertions below all read the same verdict — the + * derivation itself is shared with ProfileResponseDto. + */ + getCompanyIdentityState(company: Company): CompanyIdentityStateDto { + return buildCompanyIdentityState(company); + } + + /** + * Complete a Fayda verification and bind the identity to one of the company's + * people. The portal starts the flow through the shared + * `POST /fayda/verification/start` and only tells us which person it was for + * here, at completion — so the verifayda module stays generic and its session + * table needs no company-specific column. + */ + async completeIdentityVerification( + userId: string, + dto: CompleteIdentityVerificationDto, + ): Promise { + const { company } = await this.getCompanyInfoByUserId(userId); + const prefix = IDENTITY_PREFIX[dto.subject]; + + const result = await this.verifaydaService.completeVerification({ + code: dto.code, + state: dto.state, + }); + if (!result.verified || !result.sub) { + throw new BadRequestException( + "Fayda could not verify this identity. Start the verification again.", + ); + } + + // The owner delegating power of attorney to themselves is not a + // delegation — it would let one identity satisfy both halves of the check. + const other: IdentitySubject = dto.subject === "poa" ? "owner" : "poa"; + const otherSub = company.attributes?.[`${IDENTITY_PREFIX[other]}FaydaSub`]; + if (otherSub && otherSub === result.sub) { + throw new BadRequestException( + `This identity is already registered as the company's ${IDENTITY_LABEL[other]}. The Power of Attorney must be a different person from the owner.`, + ); + } + + const now = new Date().toISOString(); + const identity: VerifiedIdentityAttributes = { + [`${prefix}FaydaSub`]: result.sub, + [`${prefix}FaydaVerifiedAt`]: now, + [`${prefix}Birthdate`]: result.birthdate ?? null, + [`${prefix}Gender`]: result.gender ?? null, + // The verified payload owns the person's details from here on. + ...(result.fullName ? { [`${prefix}Name`]: result.fullName } : {}), + ...(result.email ? { [`${prefix}Email`]: result.email } : {}), + ...(result.phoneNumber ? { [`${prefix}Phone`]: result.phoneNumber } : {}), + ...(dto.subject === "poa" && result.address + ? { poaAddress: result.address } + : {}), + }; + + // An approved company's profile edits are staged for backoffice review, and + // swapping the person who can act for the company is exactly the kind of + // edit that review exists for — so a verification lands the same way an + // ordinary edit does, rather than quietly rewriting a live record. + if (company.status === CompanyStatus.Active) { + await this.stageIdentityChange(company, userId, identity); + return this.getCompanyIdentityState(company); + } + + const updated = await this.companiesRepo.update(company.id, { + attributes: { ...(company.attributes ?? {}), ...identity }, + }); + if (!updated) + throw new NotFoundException(`Company ${company.id} not found`); + updated.companyProfiles = company.companyProfiles; + return this.getCompanyIdentityState(updated); + } + + /** + * Drop the Power of Attorney entirely — the verified identity, the details it + * wrote and the delegation paper together. + * + * Only the PoA can go: a company always has an owner, and a freight forwarder + * always has a representative. Once a PoA is Fayda-verified its + * fields are locked, so blanking the form is no longer a way out — without + * this the customer would be stuck with a representative they cannot remove. + */ + async removePoaIdentity(userId: string): Promise { + const { company } = await this.getCompanyInfoByUserId(userId); + if ( + (company.companyProfiles ?? []).some( + (p) => p.type === ProfileType.freightForwarder, + ) + ) { + throw new BadRequestException( + "A freight forwarder must have a Power of Attorney. Remove the freight forwarder role first.", + ); + } + + const cleared: Record = {}; + for (const key of [ + ...POA_ATTRIBUTES, + "poaFaydaSub", + "poaFaydaVerifiedAt", + "poaBirthdate", + "poaGender", + ]) { + cleared[key] = null; + } + const attributes = { ...(company.attributes ?? {}), ...cleared }; + + // The paper evidences a representative who no longer exists. + const records = await this.filesService.findByResource( + company.id, + COMPANY_RESOURCE, + ); + for (const r of records) { + if ( + r.code === POA_DELEGATION_FILE_KEY || + r.code === POA_DELEGATION_PENDING_CODE + ) { + await this.filesService.remove(r.id); + await this.withdrawDocumentIntent(company.id, r.id); + } + } + + const updated = await this.companiesRepo.update(company.id, { attributes }); + if (!updated) + throw new NotFoundException(`Company ${company.id} not found`); + updated.companyProfiles = company.companyProfiles; + return this.getCompanyIdentityState(updated); + } + + /** Stage a verified identity onto the company's pending change request. */ + private async stageIdentityChange( + company: Company, + userId: string, + identity: VerifiedIdentityAttributes, + ): Promise { + const existing = await this.changeRequestRepo.findPendingByCompanyId( + company.id, + ); + const now = new Date(); + const snapshot = { + ...(existing?.snapshot ?? {}), + faydaIdentity: { + ...(((existing?.snapshot ?? {}) as Record) + .faydaIdentity ?? {}), + ...identity, + }, + }; + if (existing) { + await this.changeRequestRepo.update(existing.id, { + snapshot, + submittedBy: userId, + submittedAt: now, + note: null, + }); + this.companyNotifier.changeRequestSubmitted(company, existing.id, false); + return; + } + const history = await this.changeRequestRepo.findByCompanyId(company.id); + const resubmitted = history.some( + (r) => r.status === ChangeRequestStatus.Rejected, + ); + const request = await this.changeRequestRepo.create({ + companyId: company.id, + snapshot, + status: ChangeRequestStatus.Pending, + submittedBy: userId, + submittedAt: now, + }); + this.companyNotifier.changeRequestSubmitted( + company, + request.id, + resubmitted, + ); + } + + /** + * The gate: an Ethiopian company's owner must be Fayda-verified, and so must + * its Power of Attorney once it has one; a foreign company's owner must carry + * a passport number instead. Called from the same places as + * `assertPoaDelegationSatisfied` — the two rules describe the same moment + * (who may act for this company, and on what evidence) and drifting them + * apart is how one of them ends up unenforced. + */ + private assertIdentityVerified( + company: Company, + opts: { requirePoa: boolean }, + ): void { + const state = buildCompanyIdentityState(company); + + if (state.passportRequired) { + if (!state.owner.passportNumber) { + throw new BadRequestException( + "Add the company owner's passport number before continuing.", + ); + } + return; + } + + if (!state.owner.verified) { + throw new BadRequestException( + "Verify the company owner's identity with Fayda before continuing.", + ); + } + + const poaNamed = POA_ATTRIBUTES.some((k) => + (company.attributes?.[k] as string | undefined)?.trim(), + ); + if (!opts.requirePoa && !poaNamed) return; + + if (!state.poa.verified) { + throw new BadRequestException( + opts.requirePoa + ? "Verify your Power of Attorney with Fayda — a freight forwarder cannot operate without one." + : "Verify the Power of Attorney you named with Fayda, or remove the representative.", + ); + } + } + + /** + * The PoA details the company is heading for: its live attributes with any + * pending change-request snapshot laid over them. An Active company's edits + * are staged rather than written, so the live row on its own would judge the + * customer against details they have already asked to change. + */ + private async effectivePoaAttributes( + company: Company, + ): Promise> { + const pending = await this.changeRequestRepo.findPendingByCompanyId( + company.id, + ); + const snapshot = (pending?.snapshot ?? {}) as Record; + const staged: Record = {}; + for (const key of POA_ATTRIBUTES) { + if (key in snapshot) staged[key] = snapshot[key]; + } + return { ...(company.attributes ?? {}), ...staged }; + } + + /** The company's PoA paper(s), with each file's review status resolved. */ async listPoaDelegationFiles( userId: string, ): Promise { @@ -2149,6 +2664,18 @@ export class CompaniesService { throw new NotFoundException(`Delegation letter ${fileId} not found`); } + // Taking the paper away is the other half of the pairing: allowed only once + // the representative it evidences is gone too (which, for an Active + // company, means the clearing edit is already staged). + await this.assertPoaDelegationSatisfied( + company.id, + await this.effectivePoaAttributes(company), + { + requirePoa: await this.isFreightForwarder(company.id), + ignoreFileIds: [fileId], + }, + ); + if (record.code === POA_DELEGATION_PENDING_CODE) { await this.filesService.remove(fileId); await this.withdrawDocumentIntent(company.id, fileId); diff --git a/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts new file mode 100644 index 000000000..dbcc76471 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts @@ -0,0 +1,148 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsIn, IsString, IsNotEmpty } from "class-validator"; + +import { Company, CompanyNationality } from "../entities/company.entity"; +import { ProfileType } from "../entities/company-profile.entity"; + +/** + * The two people a company is verified through — its owner and its Power of + * Attorney. "Owner" is not the same as the General Manager: a company's GM is + * a plain typed role (with a "same as owner" copy the portal offers), while + * the owner is the person this verification proves. They're very often the + * same human, which is exactly what the copy is for. + */ +export const IDENTITY_SUBJECTS = ["owner", "poa"] as const; +export type IdentitySubject = (typeof IDENTITY_SUBJECTS)[number]; + +export class CompleteIdentityVerificationDto { + @ApiProperty({ + enum: IDENTITY_SUBJECTS, + description: "Which of the company's people this verification is for.", + }) + @IsIn(IDENTITY_SUBJECTS) + subject!: IdentitySubject; + + @ApiProperty({ description: "Authorization code from the Fayda redirect." }) + @IsString() + @IsNotEmpty() + code!: string; + + @ApiProperty({ description: "CSRF state from the Fayda redirect." }) + @IsString() + @IsNotEmpty() + state!: string; +} + +/** One person's verification state, as reported back to the portal. */ +export class IdentityVerificationStateDto { + @ApiProperty() verified!: boolean; + @ApiProperty({ nullable: true }) name!: string | null; + @ApiProperty({ nullable: true }) phone!: string | null; + @ApiProperty({ nullable: true }) email!: string | null; + @ApiProperty({ nullable: true }) address!: string | null; + @ApiProperty({ nullable: true }) verifiedAt!: string | null; +} + +export class OwnerIdentityStateDto extends IdentityVerificationStateDto { + @ApiProperty({ + nullable: true, + description: + "Typed passport number — the foreign-company identity credential. Independent of Fayda: never written by a verification, and still required even if the owner also verifies.", + }) + passportNumber!: string | null; +} + +export class CompanyIdentityStateDto { + @ApiProperty({ + description: + "True when Fayda verification of the owner (and PoA, once named) is mandatory — Ethiopian companies only.", + }) + faydaRequired!: boolean; + + @ApiProperty({ + description: + "True when the owner's passport number is mandatory — foreign companies only. Independent of faydaRequired: a foreign owner may verify with Fayda too, but the passport is still required.", + }) + passportRequired!: boolean; + + @ApiProperty({ type: OwnerIdentityStateDto }) + owner!: OwnerIdentityStateDto; + + @ApiProperty({ type: IdentityVerificationStateDto }) + poa!: IdentityVerificationStateDto; + + @ApiProperty({ + description: + "False while a mandatory requirement (Fayda for Ethiopian, passport for foreign) is still outstanding.", + }) + complete!: boolean; +} + +/** `attributes` key prefix per person. */ +const PREFIX: Record = { + owner: "owner", + poa: "poa", +}; + +/** company.attributes keys that together mean "a PoA was entered". */ +const POA_KEYS = [ + "poaName", + "poaPhone", + "poaEmail", + "poaLocation", + "poaAddress", +] as const; + +function stateFor( + attrs: Record, + subject: IdentitySubject, +): IdentityVerificationStateDto { + const p = PREFIX[subject]; + const read = (key: string) => (attrs[key] as string | undefined) ?? null; + return { + verified: Boolean(read(`${p}FaydaSub`)), + name: read(`${p}Name`), + phone: read(`${p}Phone`), + email: read(`${p}Email`), + address: read(`${p}Address`), + verifiedAt: read(`${p}FaydaVerifiedAt`), + }; +} + +/** + * Derive both people's verification state from the company row. + * + * Pure and shared: `CompaniesService` gates on it and `ProfileResponseDto` + * renders from it, so the settings page and the onboarding wizard can never + * disagree with the rule the API actually enforces. + */ +export function buildCompanyIdentityState( + company: Company, +): CompanyIdentityStateDto { + const attrs = company.attributes ?? {}; + const read = (key: string) => (attrs[key] as string | undefined) ?? null; + + // Fayda is an Ethiopian national ID — a foreign company's owner may not hold + // one, so a typed passport number is the mandatory credential there instead. + // The two are mutually exclusive by nationality but independently tracked, + // since a foreign owner verifying with Fayda doesn't waive the passport. + const foreign = company.nationality === CompanyNationality.Foreign; + const faydaRequired = !foreign; + const passportRequired = foreign; + + const owner: OwnerIdentityStateDto = { + ...stateFor(attrs, "owner"), + passportNumber: read("ownerPassportNumber"), + }; + const poa = stateFor(attrs, "poa"); + const poaDue = + (company.companyProfiles ?? []).some( + (p) => p.type === ProfileType.freightForwarder, + ) || POA_KEYS.some((k) => (attrs[k] as string | undefined)?.trim()); + + const complete = faydaRequired + ? owner.verified && (!poaDue || poa.verified) + : !passportRequired || Boolean(owner.passportNumber); + + return { faydaRequired, passportRequired, owner, poa, complete }; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts index da908a177..a8d2f24a2 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts @@ -8,6 +8,8 @@ * truth the wizard uses to auto-finish. */ +import { CompanyIdentityStateDto } from "./complete-identity-verification.dto"; + export interface OnboardingInfoField { key: string; label: string; @@ -40,11 +42,13 @@ export interface OnboardingPoaState { required: boolean; /** True once any PoA detail has been entered. */ provided: boolean; - /** True when the delegation letter is stored for the company. */ + /** True when the DARS delegation paper is stored for the company. */ delegationLetterUploaded: boolean; + /** True when a reviewer sent the paper back for correction. */ + delegationLetterFlagged: boolean; /** PoA details still missing (only populated when `required`). */ missingFields: OnboardingInfoField[]; - /** False while the PoA step still owes details or a delegation letter. */ + /** False while the PoA step still owes details or an uncorrected paper. */ complete: boolean; } @@ -68,6 +72,13 @@ export class OnboardingRequirementsResponseDto { /** Power of Attorney state, so the wizard needn't re-derive the rule. */ poa: OnboardingPoaState; + /** + * Fayda verification state for the company's people. `required` is false for + * a foreign company, which is never gated on it — the portal renders the + * typed personnel forms in that case and the verify panels otherwise. + */ + identity: CompanyIdentityStateDto; + /** Overall setup progress across fields + documents + licenses. */ progress: { completed: number; total: number }; @@ -87,6 +98,7 @@ export class OnboardingRequirementsResponseDto { this.documents = init.documents; this.licenseProfiles = init.licenseProfiles; this.poa = init.poa; + this.identity = init.identity; this.progress = init.progress; this.isComplete = init.isComplete; this.onboardingCompleted = init.onboardingCompleted; diff --git a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts index 89ab954e7..6072268dc 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts @@ -1,3 +1,7 @@ +import { + buildCompanyIdentityState, + CompanyIdentityStateDto, +} from "./complete-identity-verification.dto"; import { Company } from '../entities/company.entity'; import { ExternalProfile } from '../entities/external-profile.entity'; import { @@ -52,6 +56,16 @@ export class ProfileResponseDto { profileId: string; + /** + * Fayda verification state for the company's owner and PoA — not the general + * manager, which is a separate typed role. The settings tabs and the + * onboarding wizard render from `identity.faydaRequired` / + * `identity.passportRequired`: an Ethiopian company verifies the owner (and + * PoA) instead of typing their details; a foreign one requires a typed + * passport number instead. + */ + identity: CompanyIdentityStateDto; + /** * Open profile-edit review, if any. `reviewStatus === "pending"` locks the * settings page; `"rejected"` surfaces the note and prefills the (declined) @@ -124,5 +138,6 @@ export class ProfileResponseDto { : null; this.reviewNote = openReview?.note ?? null; this.pendingChanges = openReview?.snapshot ?? null; + this.identity = buildCompanyIdentityState(company); } } diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts index ba3e27aeb..9f7d1ed39 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts @@ -44,10 +44,11 @@ export class UpdateProfileDto { @MaxLength(50) vatNumber?: string; - @IsOptional() - @IsString() - @MaxLength(16) - fanNumber?: string; + // `fanNumber` is deliberately absent: the FAN is the Fayda number of the + // company's PoA (or its general manager), so it is derived from a completed + // Fayda verification rather than typed. The global validation pipe runs with + // forbidNonWhitelisted, so a client that still sends it gets a 400 telling it + // so — see CompaniesService.completeIdentityVerification. @IsOptional() @IsString() @@ -110,6 +111,16 @@ export class UpdateProfileDto { @IsString() poaAddress?: string; + /** + * The owner's passport number — the identity credential for a foreign + * company, since Fayda is an Ethiopian national ID. Plain typed field, never + * written or locked by a Fayda verification: still required even if the + * owner also verifies. + */ + @IsOptional() + @IsString() + ownerPassportNumber?: string; + @IsOptional() @IsString() @MaxLength(100) diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts index 947bb5ffb..9651d4f37 100644 --- a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts +++ b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts @@ -15,6 +15,11 @@ import { FILE_UPLOAD_SETTINGS_REPOSITORY, IFileUploadSettingsRepository, } from "./interfaces/file-upload-settings.repository.interface"; +import { + COMPANY_ONBOARDING_CODE_PREFIX, + POA_DELEGATION_FILE_KEY, + poaDelegationField, +} from "./poa-delegation.constants"; @Injectable() export class FileUploadSettingsService { @@ -40,6 +45,22 @@ export class FileUploadSettingsService { async getByCode(code: string): Promise { const setting = await this.repository.findByCode(code); if (!setting) throw new NotFoundException(`Setting "${code}" not found`); + return this.withPoaDelegationField(setting); + } + + /** + * Company onboarding sets always carry the DARS delegation paper, whether or + * not anyone configured a row for it — see poa-delegation.constants.ts. Every + * consumer (the portal's PoA step, the onboarding gate) reads the set through + * here, so this is the single place the field can be guaranteed. + */ + private withPoaDelegationField(setting: FileUploadSetting): FileUploadSetting { + if (!setting.code.startsWith(COMPANY_ONBOARDING_CODE_PREFIX)) return setting; + const fields = setting.fields ?? []; + if (fields.some((f) => f.fileKey === POA_DELEGATION_FILE_KEY)) return setting; + + const lastOrder = fields.reduce((max, f) => Math.max(max, f.displayOrder), 0); + setting.fields = [...fields, poaDelegationField(lastOrder + 1)]; return setting; } diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/poa-delegation.constants.ts b/apps/edr-freight-api/src/modules/file-upload-settings/poa-delegation.constants.ts new file mode 100644 index 000000000..9085cc018 --- /dev/null +++ b/apps/edr-freight-api/src/modules/file-upload-settings/poa-delegation.constants.ts @@ -0,0 +1,52 @@ +import { FileUploadField } from "./entities/file-upload-field.entity"; + +/** + * The DARS delegation paper — the document that evidences a company's Power of + * Attorney (EDRFREIGHT-358). + * + * Every other onboarding document is admin-managed: the rows in + * `file_upload_fields` are edited from the backoffice file-settings editor and + * the seeder deliberately inserts none. This one is different — a company that + * names a PoA must produce a delegation paper authenticated by the Documents + * Authentication and Registration Service, and that is a legal requirement + * rather than a configuration choice. So the field is defined here in code and + * injected into the company onboarding sets on read: no row to forget to seed, + * and deleting one in the editor cannot silently switch the requirement off. + */ + +/** FileRecord `code` (and upload field key) of the live delegation paper. */ +export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter"; + +/** Code for a delegation paper staged in an open change request (not yet live). */ +export const POA_DELEGATION_PENDING_CODE = "poa_delegation_letter_pending"; + +/** Customer-facing name of the document, used by the API and both web apps. */ +export const POA_DELEGATION_LABEL = "DARS Delegation Paper"; + +/** Prefix of the setting codes the field is injected into. */ +export const COMPANY_ONBOARDING_CODE_PREFIX = "company_onboarding_documents_"; + +const POA_DELEGATION_HELP = + "Delegation paper issued by the Documents Authentication and Registration " + + "Service (DARS) delegating the representative named above. Upload the " + + "authenticated copy — a plain letter is not accepted."; + +/** + * The field descriptor. `isRequired` stays false because the paper is only due + * once a PoA has actually been named (or the company operates as a freight + * forwarder) — a rule that spans form fields as well as files, so it is + * enforced in CompaniesService rather than by this flag. + */ +export function poaDelegationField(displayOrder: number): FileUploadField { + return { + fileKey: POA_DELEGATION_FILE_KEY, + fileLabel: POA_DELEGATION_LABEL, + helpText: POA_DELEGATION_HELP, + isRequired: false, + isMultiple: false, + maxFiles: 1, + allowedExtensions: ["pdf", "jpg", "jpeg", "png"], + maxSizeMb: 10, + displayOrder, + } as FileUploadField; +} diff --git a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts index fa74f9043..87e9819cf 100644 --- a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts +++ b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts @@ -2,6 +2,7 @@ import { Injectable, Logger } from "@nestjs/common"; import { DataSource } from "typeorm"; import { FileUploadSetting } from "../modules/file-upload-settings/entities/file-upload-setting.entity"; +import { poaDelegationField } from "../modules/file-upload-settings/poa-delegation.constants"; interface OnboardingField { fileKey: string; @@ -17,27 +18,14 @@ interface OnboardingField { const DOC_EXTENSIONS = ["pdf", "jpg", "jpeg", "png"]; -/** fileKey of the delegation letter attached to the Power of Attorney step. */ -export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter"; - /** - * Seeded as optional: the delegation letter is only mandatory once a PoA has - * been entered, or when the company operates as a freight forwarder. That rule - * spans form fields as well as files, so it lives in the onboarding gate - * (companies.service.getOnboardingRequirements) rather than in `isRequired`. + * Listed in the sets below only so the reference defaults stay a complete + * picture of a company onboarding form. Unlike every other field here, the DARS + * delegation paper is not admin-managed: `FileUploadSettingsService.getByCode` + * injects it from poa-delegation.constants.ts whether or not a row exists. */ -const poaDelegationField = (displayOrder: number): OnboardingField => ({ - fileKey: POA_DELEGATION_FILE_KEY, - fileLabel: "PoA Delegation Letter", - helpText: - "Signed letter in which the General Manager delegates the representative named above.", - isRequired: false, - isMultiple: false, - maxFiles: 1, - allowedExtensions: DOC_EXTENSIONS, - maxSizeMb: 10, - displayOrder, -}); +const poaDelegationDefault = (displayOrder: number): OnboardingField => + poaDelegationField(displayOrder) as unknown as OnboardingField; /** Documents required from an Ethiopian company at onboarding. */ const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [ @@ -75,7 +63,7 @@ const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [ maxSizeMb: 10, displayOrder: 3, }, - poaDelegationField(4), + poaDelegationDefault(4), ]; /** Documents required from a Foreign company at onboarding. */ @@ -124,7 +112,7 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [ maxSizeMb: 10, displayOrder: 4, }, - poaDelegationField(5), + poaDelegationDefault(5), ]; /** Legacy combined set, kept for the older per-company-type codes. */ From 68ef8dc5d72813c67948afa8426b14311746ab92 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 28 Jul 2026 13:44:29 +0000 Subject: [PATCH 07/66] test(freight-api): cover poa delegation and fayda identity gates Co-Authored-By: Claude Sonnet 5 --- .../companies.fayda-identity.spec.ts | 412 ++++++++++++++++++ .../companies.poa-delegation.spec.ts | 242 ++++++++++ 2 files changed, 654 insertions(+) create mode 100644 apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts create mode 100644 apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts diff --git a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts new file mode 100644 index 000000000..7f72d431a --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts @@ -0,0 +1,412 @@ +import { BadRequestException } from "@nestjs/common"; + +import { CompaniesService } from "./companies.service"; +import { CompanyNationality, CompanyStatus } from "./entities/company.entity"; +import { ProfileType } from "./entities/company-profile.entity"; +import { POA_DELEGATION_FILE_KEY } from "../file-upload-settings/poa-delegation.constants"; + +/** + * A person's identity is proved through Fayda: name, email, phone and address + * come from the verified payload, not typed. Fayda's userinfo carries no + * national ID number, so none is collected or derived here. + * + * - Ethiopian company: the owner (and its PoA, once named) is verified through + * Fayda, and their details can't be edited afterwards. + * - Foreign company: Fayda is an Ethiopian national ID, so the owner instead + * supplies a typed passport number — required on its own, whether or not the + * owner also completes a (purely optional) Fayda verification. + * + * The owner is NOT the general manager — GM is a separate, plain typed role + * the portal offers a "same as owner" copy for, but it is never itself + * Fayda-verified or gated on. + */ + +interface Ctx { + attributes: Record; + files: { id: string; code: string; reviewStatus?: string | null }[]; + profileTypes: ProfileType[]; + status: CompanyStatus; + nationality: CompanyNationality; + verification: Record; +} + +const OWNER_VERIFIED = { + ownerFaydaSub: "owner-sub", + ownerFaydaVerifiedAt: "2026-07-01T00:00:00.000Z", + ownerName: "Abebe Bikila", +}; + +const POA_VERIFIED = { + poaFaydaSub: "poa-sub", + poaFaydaVerifiedAt: "2026-07-02T00:00:00.000Z", + poaName: "Tirunesh Dibaba", + poaEmail: "tirunesh@example.com", + poaPhone: "+251911000000", +}; + +const paper = () => ({ + id: "file-1", + code: POA_DELEGATION_FILE_KEY, + reviewStatus: null, +}); + +function makeService(overrides: Partial = {}) { + const ctx: Ctx = { + attributes: {}, + files: [], + profileTypes: [ProfileType.importer], + status: CompanyStatus.Pending, + nationality: CompanyNationality.Ethiopian, + verification: { + purpose: "VERIFY", + verified: true, + sub: "new-sub", + fullName: "Haile Gebrselassie", + email: "haile@example.com", + phoneNumber: "+251922000000", + address: "Addis Ababa", + birthdate: "1973-04-18", + gender: "Male", + }, + ...overrides, + }; + + const company = () => ({ + id: "company-1", + status: ctx.status, + nationality: ctx.nationality, + attributes: ctx.attributes, + companyProfiles: ctx.profileTypes.map((type, i) => ({ + id: `profile-${i}`, + type, + })), + type: "customer", + }); + + const deps = { + companiesRepo: { + findById: jest.fn(async () => company()), + update: jest.fn(async (_id: string, patch: Record) => { + if (patch.attributes) + ctx.attributes = patch.attributes as Record; + return company(); + }), + findByTin: jest.fn(async () => null), + }, + companyProfilesRepo: { + findByCompanyId: jest.fn(async () => + ctx.profileTypes.map((type, i) => ({ id: `profile-${i}`, type })), + ), + findByType: jest.fn(async (_id: string, type: ProfileType) => + ctx.profileTypes.includes(type) ? { id: "existing", type } : null, + ), + create: jest.fn(async (row: Record) => ({ + id: "new", + ...row, + })), + }, + changeRequestRepo: { + findPendingByCompanyId: jest.fn(async () => null), + findByCompanyId: jest.fn(async () => []), + create: jest.fn(async (row: Record) => ({ + id: "cr-1", + ...row, + })), + update: jest.fn(async () => ({ id: "cr-1" })), + }, + profilesRepo: { + findByCompanyId: jest.fn(async () => []), + findByUserId: jest.fn(async () => ({ + id: "external-1", + companyId: "company-1", + company: company(), + onboardingCompleted: false, + })), + }, + filesService: { + findByResource: jest.fn(async () => ctx.files), + findById: jest.fn(async () => null), + remove: jest.fn(async () => undefined), + }, + companyNotifier: { changeRequestSubmitted: jest.fn() }, + verifayda: { + completeVerification: jest.fn(async () => ctx.verification), + }, + }; + + const service = new CompaniesService( + deps.companiesRepo as never, + deps.companyProfilesRepo as never, + deps.changeRequestRepo as never, + deps.profilesRepo as never, + {} as never, + deps.filesService as never, + {} as never, + {} as never, + deps.companyNotifier as never, + {} as never, + deps.verifayda as never, + ); + + jest + .spyOn(service, "getCompanyInfoByUserId") + .mockImplementation( + async () => + ({ profile: { id: "external-1" }, company: company() }) as never, + ); + + return { service, ctx, deps, company }; +} + +describe("Fayda identity verification binds a person to the company", () => { + it("writes the verified identity", async () => { + const { service, ctx } = makeService(); + + const state = await service.completeIdentityVerification("user-1", { + subject: "owner", + code: "c", + state: "s", + }); + + expect(ctx.attributes.ownerFaydaSub).toBe("new-sub"); + expect(ctx.attributes.ownerName).toBe("Haile Gebrselassie"); + expect(state.owner.verified).toBe(true); + }); + + it("fills every PoA detail from the payload, address included", async () => { + const { service, ctx } = makeService(); + + await service.completeIdentityVerification("user-1", { + subject: "poa", + code: "c", + state: "s", + }); + + expect(ctx.attributes.poaName).toBe("Haile Gebrselassie"); + expect(ctx.attributes.poaEmail).toBe("haile@example.com"); + expect(ctx.attributes.poaPhone).toBe("+251922000000"); + expect(ctx.attributes.poaAddress).toBe("Addis Ababa"); + }); + + it("verifies successfully even though Fayda returns no national ID number", async () => { + // Fayda's userinfo carries no FAN/FIN claim at all — this must be the + // normal, successful path, not an error. + const { service } = makeService({ + verification: { + purpose: "VERIFY", + verified: true, + sub: "x", + fullName: "No Fan Here", + }, + }); + + const state = await service.completeIdentityVerification("user-1", { + subject: "owner", + code: "c", + state: "s", + }); + + expect(state.owner.verified).toBe(true); + }); + + it("refuses to make one identity both owner and PoA", async () => { + const { service } = makeService({ + attributes: { ownerFaydaSub: "same-person" }, + verification: { + purpose: "VERIFY", + verified: true, + sub: "same-person", + fullName: "Abebe Bikila", + }, + }); + + await expect( + service.completeIdentityVerification("user-1", { + subject: "poa", + code: "c", + state: "s", + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("stages the change for review on an approved company", async () => { + // Swapping the person who can act for a live company is exactly what the + // backoffice review exists for, so it must not rewrite the row directly. + const { service, ctx, deps } = makeService({ + status: CompanyStatus.Active, + }); + + await service.completeIdentityVerification("user-1", { + subject: "poa", + code: "c", + state: "s", + }); + + expect(deps.changeRequestRepo.create).toHaveBeenCalled(); + expect(ctx.attributes.poaFaydaSub).toBeUndefined(); + }); + + it("refuses to rename a verified person by hand", async () => { + const { service } = makeService({ + attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED }, + files: [paper()], + }); + + await expect( + service.updateProfile("user-1", { poaName: "Someone Else" } as never), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("never locks or gates the general manager — it is not the verified subject", async () => { + // GM is a plain typed role; the portal offers a "same as owner" copy, but + // the backend must not treat it as identity-owned or require it verified. + const { service } = makeService({ + attributes: { ...OWNER_VERIFIED }, + }); + + await expect( + service.updateProfile("user-1", { + generalManagerName: "Someone Else", + generalManagerEmail: "someone@example.com", + generalManagerPhone: "+251911223344", + } as never), + ).resolves.toBeDefined(); + }); +}); + +describe("Ethiopian companies verify with Fayda; foreign companies verify identity by passport", () => { + // The company is applying for the forwarder role, so it must not already + // hold it — createCompanyProfileForUser short-circuits on an existing profile + // and would never reach the gate. + const applyingForFf = { + profileTypes: [ProfileType.importer], + attributes: { ...POA_VERIFIED }, + files: [paper()], + }; + + it("blocks the forwarder role while the owner is unverified", async () => { + const { service } = makeService(applyingForFf); + + await expect( + service.createCompanyProfileForUser( + "user-1", + ProfileType.freightForwarder, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("blocks the forwarder role while the PoA is unverified", async () => { + const { service } = makeService({ + profileTypes: [ProfileType.importer], + attributes: { + ...OWNER_VERIFIED, + poaName: "Tirunesh Dibaba", + poaEmail: "t@example.com", + poaPhone: "+251911000000", + }, + files: [paper()], + }); + + await expect( + service.createCompanyProfileForUser( + "user-1", + ProfileType.freightForwarder, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("grants the forwarder role once owner and PoA are both verified", async () => { + const { service } = makeService({ + profileTypes: [ProfileType.importer], + attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED }, + files: [paper()], + }); + + await expect( + service.createCompanyProfileForUser( + "user-1", + ProfileType.freightForwarder, + ), + ).resolves.toBeDefined(); + }); + + it("never asks a foreign company for Fayda, verified or not", async () => { + const { service } = makeService({ + nationality: CompanyNationality.Foreign, + }); + + const state = await service.completeIdentityVerification("user-1", { + subject: "owner", + code: "c", + state: "s", + }); + + // Still lets the owner verify — a foreign owner verifying is allowed, just + // never required — but the passport is the thing that actually gates it. + expect(state.owner.verified).toBe(true); + expect(state.faydaRequired).toBe(false); + expect(state.passportRequired).toBe(true); + }); + + it("blocks the forwarder role for a foreign company with no owner passport", async () => { + const { service } = makeService({ + profileTypes: [ProfileType.importer], + nationality: CompanyNationality.Foreign, + attributes: { + poaName: "Jean Dupont", + poaEmail: "jean@example.com", + poaPhone: "+33100000000", + }, + }); + + await expect( + service.createCompanyProfileForUser( + "user-1", + ProfileType.freightForwarder, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("grants the forwarder role to a foreign company with an owner passport and no Fayda at all", async () => { + const { service } = makeService({ + profileTypes: [ProfileType.importer], + nationality: CompanyNationality.Foreign, + attributes: { + ownerPassportNumber: "P1234567", + poaName: "Jean Dupont", + poaEmail: "jean@example.com", + poaPhone: "+33100000000", + }, + files: [paper()], + }); + + await expect( + service.createCompanyProfileForUser( + "user-1", + ProfileType.freightForwarder, + ), + ).resolves.toBeDefined(); + }); + + it("still requires the passport for a foreign owner who chose to verify with Fayda too", async () => { + // Verifying is optional for a foreign owner, but it does not waive the + // passport requirement — the two are independent credentials. + const { service } = makeService({ + profileTypes: [ProfileType.importer], + nationality: CompanyNationality.Foreign, + attributes: { + ...OWNER_VERIFIED, + poaName: "Jean Dupont", + poaEmail: "jean@example.com", + poaPhone: "+33100000000", + }, + }); + + await expect( + service.createCompanyProfileForUser( + "user-1", + ProfileType.freightForwarder, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); +}); diff --git a/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts new file mode 100644 index 000000000..9e1c76477 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts @@ -0,0 +1,242 @@ +import { BadRequestException } from "@nestjs/common"; + +import { CompaniesService } from "./companies.service"; +import { CompanyStatus } from "./entities/company.entity"; +import { ProfileType } from "./entities/company-profile.entity"; +import { POA_DELEGATION_FILE_KEY } from "../file-upload-settings/poa-delegation.constants"; + +/** + * EDRFREIGHT-358: a company that names a Power of Attorney must have the DARS + * delegation paper on file. The rule used to live only in the onboarding + * wizard's completion check, so every other write that could break the pairing + * — saving PoA details, deleting the paper, picking up the forwarder role — + * went unguarded. These cover those writes. + */ + +interface Ctx { + attributes: Record; + files: { id: string; code: string; reviewStatus?: string | null }[]; + profileTypes: ProfileType[]; + status: CompanyStatus; + pendingSnapshot: Record | null; +} + +const POA = { poaName: "Abebe", poaEmail: "a@b.com", poaPhone: "+251911000000" }; + +/** + * The forwarder role is gated on Fayda-verified identities as well as on the + * delegation paper. These tests are about the paper, so they run against a + * company whose identities are already verified — the identity rule itself is + * covered in companies.fayda-identity.spec.ts. + */ +const VERIFIED_IDENTITIES = { + ownerFaydaSub: "owner-sub", + poaFaydaSub: "poa-sub", +}; + +function makeService(overrides: Partial = {}) { + const ctx: Ctx = { + attributes: {}, + files: [], + profileTypes: [ProfileType.importer], + status: CompanyStatus.Pending, + pendingSnapshot: null, + ...overrides, + }; + + const company = () => ({ + id: "company-1", + status: ctx.status, + attributes: ctx.attributes, + companyProfiles: ctx.profileTypes.map((type, i) => ({ + id: `profile-${i}`, + type, + })), + type: "customer", + }); + + const deps = { + companiesRepo: { + findById: jest.fn(async () => company()), + update: jest.fn(async (_id: string, patch: Record) => { + ctx.attributes = (patch.attributes ?? + ctx.attributes) as Record; + return company(); + }), + findByTin: jest.fn(async () => null), + }, + companyProfilesRepo: { + findByCompanyId: jest.fn(async () => + ctx.profileTypes.map((type, i) => ({ id: `profile-${i}`, type })), + ), + findByType: jest.fn(async (_id: string, type: ProfileType) => + ctx.profileTypes.includes(type) ? { id: "existing", type } : null, + ), + create: jest.fn(async (row: Record) => ({ + id: "new", + ...row, + })), + }, + changeRequestRepo: { + findPendingByCompanyId: jest.fn(async () => + ctx.pendingSnapshot ? { id: "cr-1", snapshot: ctx.pendingSnapshot } : null, + ), + findByCompanyId: jest.fn(async () => []), + create: jest.fn(async (row: Record) => ({ + id: "cr-1", + ...row, + })), + update: jest.fn(async () => ({ id: "cr-1" })), + }, + profilesRepo: { + findByCompanyId: jest.fn(async () => []), + findByUserId: jest.fn(async () => ({ + id: "external-1", + companyId: "company-1", + company: company(), + onboardingCompleted: false, + })), + }, + filesService: { + findByResource: jest.fn(async () => ctx.files), + findById: jest.fn(async (id: string) => + ctx.files.find((f) => f.id === id) + ? { + ...ctx.files.find((f) => f.id === id), + resource: "companies", + resourceId: "company-1", + name: "dars.pdf", + } + : null, + ), + remove: jest.fn(async () => undefined), + }, + companyNotifier: { changeRequestSubmitted: jest.fn() }, + }; + + const service = new CompaniesService( + deps.companiesRepo as never, + deps.companyProfilesRepo as never, + deps.changeRequestRepo as never, + deps.profilesRepo as never, + {} as never, + deps.filesService as never, + {} as never, + {} as never, + deps.companyNotifier as never, + {} as never, + {} as never, + ); + + // getCompanyInfoByUserId does its own lookups; the stubs above are enough for + // the PoA paths, so short-circuit it rather than mock the whole graph. + jest + .spyOn(service, "getCompanyInfoByUserId") + .mockImplementation( + async () => + ({ profile: { id: "external-1" }, company: company() }) as never, + ); + + return { service, ctx, deps }; +} + +const paper = (reviewStatus: string | null = null) => ({ + id: "file-1", + code: POA_DELEGATION_FILE_KEY, + reviewStatus, +}); + +describe("PoA delegation paper is enforced wherever PoA state changes", () => { + it("rejects PoA details saved with no paper on file", async () => { + const { service } = makeService(); + + await expect( + service.updateProfile("user-1", POA as never), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("accepts PoA details once the paper is on file", async () => { + const { service } = makeService({ files: [paper()] }); + + await expect( + service.updateProfile("user-1", POA as never), + ).resolves.toBeDefined(); + }); + + it("rejects a paper the reviewer sent back for correction", async () => { + const { service } = makeService({ files: [paper("change_requested")] }); + + await expect( + service.updateProfile("user-1", POA as never), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("leaves edits that don't touch the PoA alone", async () => { + // A company carrying legacy details must not be locked out of every other + // field until it produces a paper. + const { service } = makeService({ attributes: { ...POA }, files: [] }); + + await expect( + service.updateProfile("user-1", { companyEmail: "x@y.com" } as never), + ).resolves.toBeDefined(); + }); + + it("refuses to remove the paper while the PoA is still named", async () => { + const { service } = makeService({ + attributes: { ...POA }, + files: [paper()], + }); + + await expect( + service.removePoaDelegationLetter("user-1", "file-1"), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("allows removing the paper once the PoA has been cleared", async () => { + const { service } = makeService({ attributes: {}, files: [paper()] }); + + await expect( + service.removePoaDelegationLetter("user-1", "file-1"), + ).resolves.toBeDefined(); + }); + + it("judges the removal against a staged clear, not the live row", async () => { + // An Active company's edits are staged for review rather than written, so + // the live attributes still carry the PoA the customer just cleared. + const { service } = makeService({ + status: CompanyStatus.Active, + attributes: { ...POA }, + pendingSnapshot: { poaName: "", poaEmail: "", poaPhone: "" }, + files: [paper()], + }); + + await expect( + service.removePoaDelegationLetter("user-1", "file-1"), + ).resolves.toBeDefined(); + }); + + it("refuses the forwarder role to a company with no PoA", async () => { + const { service } = makeService({ attributes: { ...VERIFIED_IDENTITIES } }); + + await expect( + service.createCompanyProfileForUser( + "user-1", + ProfileType.freightForwarder, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("grants the forwarder role once PoA details and paper are both in place", async () => { + const { service } = makeService({ + attributes: { ...POA, ...VERIFIED_IDENTITIES }, + files: [paper()], + }); + + await expect( + service.createCompanyProfileForUser( + "user-1", + ProfileType.freightForwarder, + ), + ).resolves.toBeDefined(); + }); +}); From f1431a83537f8f3449b0b486b46229712dee75e0 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 28 Jul 2026 13:44:51 +0000 Subject: [PATCH 08/66] feat(freight-portal): fayda verify ui, owner/gm split, foreign passport - FaydaVerifyPanel + /callback popup flow for owner and poa - general manager is a plain typed role again, offers "same as verified owner" copy instead of being fayda-verified itself - company step gates on owner verification (ethiopian) or typed passport number (foreign); poa step gates on poa verification - settings tabs (company profile, general manager, poa) updated to match Co-Authored-By: Claude Sonnet 5 --- apps/edr-freight-web/portal/src/App.tsx | 4 + .../src/components/FaydaVerifyPanel.tsx | 212 ++++++++++++++++++ .../onboarding/OnboardingWizardDialog.tsx | 8 + .../portal/src/pages/FaydaCallbackPage.tsx | 56 +++++ .../portal/src/pages/SettingsPage.tsx | 13 +- .../src/pages/accounts/CompanyProfileForm.tsx | 201 ++++++++++++----- .../accounts/companyProfileForm/helpers.ts | 6 +- .../accounts/companyProfileForm/schema.ts | 42 +++- .../src/pages/settings/TabCompanyProfile.tsx | 49 ++-- .../src/pages/settings/TabDocuments.tsx | 2 +- .../src/pages/settings/TabGeneralManager.tsx | 40 +++- .../src/pages/settings/TabPowerOfAttorney.tsx | 198 +++++++++++----- .../portal/src/services/companies.service.ts | 5 + .../portal/src/services/verifayda.service.ts | 94 ++++++++ .../portal/src/types/profile.ts | 11 + 15 files changed, 790 insertions(+), 151 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/FaydaCallbackPage.tsx create mode 100644 apps/edr-freight-web/portal/src/services/verifayda.service.ts diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 72674363c..3a010663c 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -55,6 +55,7 @@ import NewShipmentPage from "./pages/contracts/NewShipmentPage"; import NewShipmentRequestPage from "./pages/contracts/NewShipmentRequestPage"; import CheckPaymentPage from "./pages/payments/CheckPaymentPage"; import PaymentFailurePage from "./pages/payments/PaymentFailurePage"; +import FaydaCallbackPage from "./pages/FaydaCallbackPage"; import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage"; import TrackingPage from "./pages/tracking/TrackingPage"; @@ -262,6 +263,9 @@ const App = () => { element={} /> {/* Payment provider browser redirects (PAYMENT_RETURN_URL / PAYMENT_FAILURE_URL) */} + {/* Fayda (eSignet) redirect_uri — runs in the verification popup and + relays the code/state back to the form that opened it. */} + } /> } /> } /> diff --git a/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx b/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx new file mode 100644 index 000000000..6897a1a33 --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx @@ -0,0 +1,212 @@ +import { useEffect, useRef, useState } from "react"; +import { + Alert, + Badge, + Button, + Card, + Group, + SimpleGrid, + Stack, + Text, +} from "@mantine/core"; +import { BadgeCheck, ShieldCheck, XCircle } from "lucide-react"; + +import { + verifaydaService, + type CompanyIdentityState, + type FaydaCallbackMessage, + type IdentitySubject, + type IdentityVerificationState, +} from "@/services/verifayda.service"; + +interface FaydaVerifyPanelProps { + subject: IdentitySubject; + /** Heading — "General Manager" / "Power of Attorney". */ + title: string; + /** What this person's verification is currently known to be. */ + state?: IdentityVerificationState; + /** + * False for a foreign company: verification is offered but nothing is gated + * 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; +} + +function formatDate(iso: string | null): string { + if (!iso) return ""; + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? "" : d.toLocaleDateString(); +} + +/** + * 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. + */ +export default function FaydaVerifyPanel({ + subject, + title, + state, + required, + onVerified, + disabled, +}: 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; + + useEffect(() => { + const onMessage = async (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + if (event.data?.type !== "fayda-callback") return; + + if (event.data.error) { + setLoading(false); + setError(event.data.errorDescription ?? event.data.error); + return; + } + if (!event.data.code || !event.data.state) return; + + 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); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const startVerification = async () => { + setError(null); + setLoading(true); + 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."); + } + // Loading stays on until the popup posts back. + } catch (err) { + setLoading(false); + setError( + (err as { response?: { data?: { message?: string } } })?.response?.data + ?.message ?? + (err instanceof Error ? err.message : "Could not start verification"), + ); + } + }; + + const verified = state?.verified ?? false; + + return ( + + + + + + {title} identity + + {verified ? ( + } + > + Fayda verified + + ) : ( + required && ( + + Verification required + + ) + )} + + + + + {!verified && ( + + {required + ? "Verify this person with Fayda. Their name, phone and address come from the verification — there is nothing to fill in by hand." + : "Optional for a foreign company. If this person holds a Fayda ID, verifying it fills in their details."} + + )} + + {verified && state && ( + + + + + + + + )} + + {error && ( + }> + {error} + + )} + + ); +} + +function VerifiedField({ + label, + value, +}: { + label: string; + value: string | null; +}) { + if (!value) return null; + return ( + + + {label} + + + {value} + + + ); +} 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 55c63ac52..4ac5f6c55 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -424,6 +424,14 @@ export default function OnboardingWizardDialog({ onLicenseChange: setLicenseFiles, uploadedDocumentKeys, onUploadDocuments: handleUploadDocuments, + // Fayda verification state for the owner and the PoA — the general manager + // 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 new file mode 100644 index 000000000..c25dc836a --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/FaydaCallbackPage.tsx @@ -0,0 +1,56 @@ +import { useEffect, useState } from "react"; +import { Center, Loader, Stack, Text } from "@mantine/core"; + +import type { FaydaCallbackMessage } 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. + */ +export default function FaydaCallbackPage() { + const [standalone, setStandalone] = useState(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 (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); + } + }, []); + + return ( +
+ + {standalone ? ( + <> + Verification window lost its parent page + + Close this tab and start the verification again from the form. + + + ) : ( + <> + + + Completing Fayda verification… + + + )} + +
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx index 755691607..5680499c7 100644 --- a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx @@ -63,13 +63,22 @@ type SettingsTab = /** A section is "incomplete" when its required fields aren't filled in yet. */ function tabIncomplete(tabId: SettingsTab, profile: ProfileResponse): boolean { switch (tabId) { - case "company": + case "company": { + // Identity proof lives here: the owner's Fayda verification for an + // Ethiopian company, or the owner's typed passport number for a foreign + // one. + const identity = profile.identity; + const identityIncomplete = identity + ? (identity.faydaRequired && !identity.owner.verified) || + (identity.passportRequired && !identity.owner.passportNumber) + : false; return ( !profile.companyEmail || !profile.companyPhone || !profile.companyAddress || - !profile.fanNumber + identityIncomplete ); + } case "contact": return !profile.contactPersonName || !profile.contactPersonPhone; case "gm": 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 dd68c7006..b068c0a6f 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -9,11 +9,10 @@ import { Stack, Text, TextInput, - Tooltip, } from "@mantine/core"; import { zodResolver } from "@hookform/resolvers/zod"; import { useQuery } from "@tanstack/react-query"; -import { AlertCircle, ArrowLeft, ArrowRight, Info } from "lucide-react"; +import { AlertCircle, ArrowLeft, ArrowRight } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; import { Controller, useForm } from "react-hook-form"; @@ -43,6 +42,8 @@ import { stepPayload, toFormValues, } from "./companyProfileForm/helpers"; +import FaydaVerifyPanel from "@/components/FaydaVerifyPanel"; +import type { CompanyIdentityState } from "@/services/verifayda.service"; import { LinkCheckboxCard } from "./companyProfileForm/LinkCheckboxCard"; import { ReadOnlyField } from "./companyProfileForm/ReadOnlyField"; @@ -65,6 +66,8 @@ export default function CompanyProfileForm({ submitError, uploadedDocumentKeys, onUploadDocuments, + identity, + onIdentityChange, }: { documentSettingCode: string; documentFiles?: Record; @@ -102,6 +105,10 @@ export default function CompanyProfileForm({ onUploadDocuments?: () => Promise< { ok: true } | { ok: false; error: string } >; + /** 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); @@ -161,10 +168,14 @@ export default function CompanyProfileForm({ ); // A freight forwarder signs on other companies' behalf, so its Power of - // Attorney (details + delegation letter) is mandatory rather than optional. + // Attorney (details + DARS delegation paper) is mandatory rather than optional. const requirePoa = (roleProfiles ?? []).some( (p) => p.type === "freight_forwarder", ); + // Fayda is an Ethiopian national ID: an Ethiopian company verifies its owner + // and PoA instead of typing their details, a foreign one keeps the typed + // forms (plus a mandatory owner passport number). + const verifiedIdentity = identity?.faydaRequired === true; const { register, @@ -175,7 +186,13 @@ export default function CompanyProfileForm({ setValue, formState: { errors }, } = useForm({ - resolver: zodResolver(buildOnboardingSchema(requirePoa)), + resolver: zodResolver( + buildOnboardingSchema( + requirePoa, + verifiedIdentity, + identity?.passportRequired === true, + ), + ), defaultValues: { companyName: "", companyEmail: "", @@ -184,7 +201,7 @@ export default function CompanyProfileForm({ companyAddress: "", tinNumber: "", vatNumber: "", - fanNumber: "", + ownerPassportNumber: "", licenceNumber: "", statusDescription: "", dateRegistered: "", @@ -302,10 +319,19 @@ export default function CompanyProfileForm({ // field of its own, so it falls back to the registering user's account name. const companyEmail = watch("companyEmail"); const companyPhone = watch("companyPhone"); - const gmSourceName = etradeOwner?.name ?? user.name?.en ?? ""; - const gmSourceEmail = companyEmail || user.email || ""; + // A Fayda-verified owner outranks eTrade's registered owner — it's the + // higher-trust source, and the whole point of proving identity is to stop + // trusting typed/looked-up data for this. + const gmSourceName = + identity?.owner.name ?? etradeOwner?.name ?? user.name?.en ?? ""; + const gmSourceEmail = + identity?.owner.email ?? (companyEmail || user.email || ""); const gmSourcePhone = - companyPhone || etradeOwner?.phone || toEthiopianE164(user.phoneNumber) || ""; + identity?.owner.phone ?? + companyPhone ?? + etradeOwner?.phone ?? + toEthiopianE164(user.phoneNumber) ?? + ""; useEffect(() => { if (!gmSameAsOwner) return; @@ -387,10 +413,11 @@ export default function CompanyProfileForm({ } }; - // The delegation letter is seeded into the same nationality document set as - // the rest, but belongs on the PoA step next to the details it evidences — - // so it's split out here and the Documents step renders the remainder. Both - // halves share `documentFiles`, so the existing bulk upload still carries it. + // The DARS delegation paper ships in the same nationality document set as the + // rest (the API guarantees it is there), but belongs on the PoA step next to + // the details it evidences — so it's split out here and the Documents step + // renders the remainder. Both halves share `documentFiles`, so the existing + // bulk upload still carries it. const poaDocumentField = uploadSetting?.fields?.find( (f) => f.fileKey === POA_DELEGATION_FILE_KEY, ); @@ -507,13 +534,13 @@ export default function CompanyProfileForm({ ]; const currentIdx = stepOrder.indexOf(step); - // The delegation letter is what proves the representative was actually + // 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. Skipped entirely - // when the document set predates the field (seeder not yet re-run). + // 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 + // round-trip. const poaProvided = hasPoaDetails(watch()); - const delegationRequired = - Boolean(poaDocumentField) && (requirePoa || poaProvided); + const delegationRequired = requirePoa || poaProvided; const delegationPresent = (uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) || (() => { @@ -572,15 +599,41 @@ export default function CompanyProfileForm({ handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; } + // The owner's identity is proved outside the form state too, so it gates + // here rather than through zod. + if ( + step === "company" && + identity && + ((identity.faydaRequired && !identity.owner.verified) || + (identity.passportRequired && !identity.owner.passportNumber)) + ) { + setSaveError( + identity.faydaRequired + ? "Verify the company owner's identity with Fayda before continuing." + : "Add the company owner's passport number before continuing.", + ); + return; + } + if ( + step === "poa" && + verifiedIdentity && + requirePoa && + !identity?.poa.verified + ) { + setSaveError( + "Freight forwarders act on other companies' behalf, so the Power of Attorney's identity must be verified with Fayda.", + ); + return; + } // The PoA step also gates on a file, which lives outside the form state. if (step === "poa" && delegationRequired && !delegationPresent) { setDocumentFieldErrors({ - [POA_DELEGATION_FILE_KEY]: "Delegation letter is required", + [POA_DELEGATION_FILE_KEY]: "DARS delegation paper is required", }); setSaveError( requirePoa - ? "Freight forwarders must provide Power of Attorney details and a delegation letter." - : "Upload the delegation letter for the Power of Attorney you entered, or clear the PoA details to skip.", + ? "Freight forwarders must provide Power of Attorney details and the DARS delegation paper." + : "Upload the DARS delegation paper for the Power of Attorney you entered, or clear the PoA details to skip.", ); // Fall through to validate the text fields too, so every problem shows at once. await trigger(stepFields.poa); @@ -639,39 +692,34 @@ export default function CompanyProfileForm({ error={errors.companyLocation?.message} {...register("companyLocation")} /> - - - - FAN Number (16 digits) - - - - - } - placeholder="1234567890123456" - maxLength={16} - error={errors.fanNumber?.message} - {...register("fanNumber")} - /> - + + + {identity && ( + <> + onIdentityChange?.()} + /> + {identity.passportRequired && ( + + )} + + )} {hasRegistrationDetails && ( <> @@ -778,14 +826,24 @@ export default function CompanyProfileForm({ General Manager + {/* GM is a plain typed role, not the person the Fayda + verification proves — the owner is (see the Company step). + They're very often the same human, which "same as owner" is + for once the owner has verified. */} {requirePoa - ? "As a freight forwarder you act on other companies' behalf, so Power of Attorney details and a delegation letter are required." - : "Power of Attorney details are optional. Fill them in if you have them, or skip to continue. If you do enter a representative, upload the delegation letter authorising them."} + ? "As a freight forwarder you act on other companies' behalf, so Power of Attorney details and the DARS delegation paper are required." + : "Power of Attorney details are optional. Fill them in if you have them, or skip to continue. If you do enter a representative, upload the delegation paper authenticated by DARS."} - {watch("contactPersonName") && ( + {identity && ( + onIdentityChange?.()} + /> + )} + {!verifiedIdentity && watch("contactPersonName") && ( )} + {!verifiedIdentity && ( + <> + + )} + {/* The city is the one field the Fayda address claim does not + reliably decompose into, so it stays typed either way. */} + {verifiedIdentity && ( + + )} {poaDocumentSetting && ( <> diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts index 8f8a24eca..b6b7fbbf0 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts @@ -29,8 +29,8 @@ export function buildPayload( companyAddress: data.companyAddress, tin: data.tinNumber, vatNumber: data.vatNumber, - fanNumber: data.fanNumber, attributes: { + ownerPassportNumber: data.ownerPassportNumber || undefined, contactPersonName: data.contactPersonName, contactPersonPosition: data.contactPersonPosition || undefined, contactPersonEmail: data.contactPersonEmail || undefined, @@ -62,7 +62,7 @@ export function stepPayload( companyAddress: d.companyAddress, tin: d.tinNumber, vatNumber: d.vatNumber, - fanNumber: d.fanNumber, + ownerPassportNumber: d.ownerPassportNumber || undefined, licenceNumber: d.licenceNumber, statusDescription: d.statusDescription, dateRegistered: d.dateRegistered, @@ -114,7 +114,7 @@ export function toFormValues(p: ProfileResponse): FormData { companyAddress: p.companyAddress ?? "", tinNumber: tin, vatNumber: p.vatNumber ?? "", - fanNumber: p.fanNumber ?? "", + ownerPassportNumber: p.identity?.owner.passportNumber ?? "", licenceNumber: p.licenceNumber ?? "", statusDescription: p.statusDescription ?? "", dateRegistered: p.dateRegistered ?? "", diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts index 275b8a4fa..dab536bba 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts @@ -27,7 +27,10 @@ export const onboardingSchema = z.object({ .string() .min(1, "VAT number is required") .length(10, "VAT number must be exactly 10 digits"), - fanNumber: z.string().length(16, "FAN must be exactly 16 digits"), + // The owner's passport number — the foreign-company identity credential + // (Fayda is an Ethiopian national ID). Required only for a foreign company; + // enforced in buildOnboardingSchema since that depends on `nationality`. + ownerPassportNumber: z.string().optional(), licenceNumber: z.string().optional(), statusDescription: z.string().optional(), dateRegistered: z.string().optional(), @@ -109,14 +112,35 @@ export const hasPoaDetails = (d: Partial) => * delegation-letter upload is enforced alongside this, in CompanyProfileForm, * since files live outside the form state). */ -export function buildOnboardingSchema(requirePoa: boolean) { - if (!requirePoa) return onboardingSchema; +export function buildOnboardingSchema( + requirePoa: boolean, + /** + * True when the PoA's identity fields come from a Fayda verification rather + * than the form (Ethiopian companies). Requiring them here would fail + * validation against inputs the step no longer renders — the verification + * itself is what the step gates on instead. + */ + faydaOwnedPoa = false, + /** True for a foreign company: the owner's passport number is mandatory. */ + passportRequired = false, +) { + const poaRequired = requirePoa && !faydaOwnedPoa; + if (!poaRequired && !passportRequired) return onboardingSchema; return onboardingSchema.superRefine((d, ctx) => { - const required: [keyof FormData, string][] = [ - ["poaName", "PoA name is required for freight forwarders"], - ["poaEmail", "PoA email is required for freight forwarders"], - ["poaPhone", "PoA phone is required for freight forwarders"], - ]; + const required: [keyof FormData, string][] = []; + if (poaRequired) { + required.push( + ["poaName", "PoA name is required for freight forwarders"], + ["poaEmail", "PoA email is required for freight forwarders"], + ["poaPhone", "PoA phone is required for freight forwarders"], + ); + } + if (passportRequired) { + required.push([ + "ownerPassportNumber", + "The owner's passport number is required", + ]); + } for (const [path, message] of required) { if (!d[path]?.trim()) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: [path], message }); @@ -134,7 +158,7 @@ export const stepFields: Record = { "companyAddress", "tinNumber", "vatNumber", - "fanNumber", + "ownerPassportNumber", "licenceNumber", "statusDescription", "dateRegistered", diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx index 597dfa509..57d589bdc 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx @@ -22,6 +22,7 @@ import { useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; import OnboardingRoleSelect from "./OnboardingRoleSelect"; +import FaydaVerifyPanel from "@/components/FaydaVerifyPanel"; export const COMPANY_PROFILE_SCHEMA = z.object({ companyName: z.string().min(1, "Company name is required"), @@ -33,13 +34,13 @@ export const COMPANY_PROFILE_SCHEMA = z.object({ companyLocation: z.string().min(1, "Location is required"), companyAddress: z.string().min(1, "Address is required"), tinNumber: z.string().regex(/^\d{10}$/, "TIN must be exactly 10 digits"), - fanNumber: z.string().length(16, "FAN must be exactly 16 digits"), vatNumber: z .string() .trim() .max(20, "VAT number is too long") .optional() .or(z.literal("")), + ownerPassportNumber: z.string().optional(), }); export type CompanyProfileFormData = z.infer; @@ -68,8 +69,8 @@ export default function TabCompanyProfile({ companyLocation: profile.companyLocation, companyAddress: profile.companyAddress ?? "", tinNumber: profile.tinNumber, - fanNumber: profile.fanNumber ?? "", vatNumber: profile.vatNumber ?? "", + ownerPassportNumber: profile.identity?.owner.passportNumber ?? "", }; } return { @@ -79,8 +80,8 @@ export default function TabCompanyProfile({ companyLocation: "", companyAddress: "", tinNumber: "", - fanNumber: "", vatNumber: "", + ownerPassportNumber: "", }; }, [profile]); @@ -104,8 +105,10 @@ export default function TabCompanyProfile({ companyLocation: data.companyLocation, companyAddress: data.companyAddress, tin: data.tinNumber, - fanNumber: data.fanNumber, vatNumber: data.vatNumber ?? "", + ...(data.ownerPassportNumber !== undefined + ? { ownerPassportNumber: data.ownerPassportNumber } + : {}), }; if (isCreate) { @@ -222,18 +225,6 @@ export default function TabCompanyProfile({ {...register("tinNumber")} /> - - - - - - + + {profile?.identity && ( + <> + + queryClient.invalidateQueries({ + queryKey: api.companies.getProfile.queryKey(), + }) + } + /> + {profile.identity.passportRequired && ( + + )} + + )} + void; } +/** + * The general manager is a plain typed role, not the person the Fayda + * verification proves — the owner is. They're very often the same human, + * which is what "Same as owner" is for: once the owner has verified, this + * copies their name/email/phone in rather than making the customer re-type + * data the company already proved. + */ export default function TabGeneralManager({ profile, mode = "edit", onContinue }: TabGeneralManagerProps) { const queryClient = useQueryClient(); + const owner = profile.identity?.owner; + const [gmSameAsOwner, setGmSameAsOwner] = useState(false); const defaultValues = useMemo((): FormData => { return { @@ -51,12 +61,32 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue } control, handleSubmit, reset, + setValue, formState: { errors, isDirty }, } = useForm({ resolver: zodResolver(schema), values: defaultValues, }); + const toggleGmSameAsOwner = (checked: boolean) => { + setGmSameAsOwner(checked); + if (checked && owner) { + setValue("generalManagerName", owner.name ?? "", { shouldValidate: true }); + setValue("generalManagerEmail", owner.email ?? "", { shouldValidate: true }); + setValue("generalManagerPhone", owner.phone ?? "", { shouldValidate: true }); + } + }; + + // Keep the copy live while the checkbox is on — e.g. the owner re-verifies + // with updated details. + useEffect(() => { + if (!gmSameAsOwner || !owner) return; + setValue("generalManagerName", owner.name ?? ""); + setValue("generalManagerEmail", owner.email ?? ""); + setValue("generalManagerPhone", owner.phone ?? ""); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [gmSameAsOwner, owner?.name, owner?.email, owner?.phone]); + const mutation = useMutation({ mutationFn: (data: FormData) => api.companies.updateProfile.call({ @@ -84,6 +114,14 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue } + {owner?.verified && ( + + )} 0; // A freight forwarder signs on other companies' behalf, so its PoA — details - // and delegation letter both — is mandatory rather than optional. + // and DARS delegation paper both — is mandatory rather than optional. const requirePoa = profile.companyProfiles.some( (p) => p.type === "freight_forwarder", ); + // An Ethiopian company does not type its representative's details — they + // come from the Fayda verification. A foreign company keeps the typed form: + // its representative may hold no Fayda ID. + const identity = profile.identity; + const verifiedIdentity = identity?.faydaRequired === true; + const poaValues = watch([ "poaName", "poaEmail", @@ -147,7 +155,9 @@ export default function TabPowerOfAttorney({ "poaLocation", "poaAddress", ]); - const poaProvided = poaValues.some((v) => v?.trim()); + const poaProvided = verifiedIdentity + ? (identity?.poa.verified ?? false) + : poaValues.some((v) => v?.trim()); const letterRequired = requirePoa || poaProvided; const letterMissing = letterRequired && !hasLetterAfterSave; @@ -155,22 +165,32 @@ export default function TabPowerOfAttorney({ const mutation = useMutation({ mutationFn: async (data: FormData) => { - // A fresh upload already stages the removal of every live letter, so the - // explicit removals only need applying when no replacement was picked. + // Every identity field except the city is written by the verification, so + // an Ethiopian company only ever saves the paper and the location here. + const fields = verifiedIdentity + ? { poaLocation: data.poaLocation || undefined } + : { + poaName: data.poaName || undefined, + poaPhone: data.poaPhone || undefined, + poaEmail: data.poaEmail || undefined, + poaLocation: data.poaLocation || undefined, + poaAddress: data.poaAddress || undefined, + }; + // A fresh upload already stages the removal of every paper on file, so + // the explicit removals only need applying when no replacement was + // picked. Saving the details after it means the API sees the new paper. if (pickedFile) { await companiesService.uploadPoaDelegation(pickedFile); - } else { - for (const fileId of removeIds) { - await companiesService.removePoaDelegation(fileId); - } + return api.companies.updateProfile.call(fields); } - return api.companies.updateProfile.call({ - poaName: data.poaName || undefined, - poaPhone: data.poaPhone || undefined, - poaEmail: data.poaEmail || undefined, - poaLocation: data.poaLocation || undefined, - poaAddress: data.poaAddress || undefined, - }); + // Nothing replacing it, so the details go first: the API judges a removal + // against the PoA the customer is keeping, and clearing both together is + // the only way it will let the paper go. + const saved = await api.companies.updateProfile.call(fields); + for (const fileId of removeIds) { + await companiesService.removePoaDelegation(fileId); + } + return saved; }, onSuccess: () => { setPickedFile(null); @@ -185,6 +205,24 @@ export default function TabPowerOfAttorney({ }, }); + /** + * A verified representative cannot be removed by blanking the form — their + * fields are owned by the verification — so removal is its own action that + * clears the identity and the delegation paper together. + */ + const removeMutation = useMutation({ + mutationFn: () => verifaydaService.removePoa(), + onSuccess: () => { + resetAll(); + queryClient.invalidateQueries({ + queryKey: api.companies.getProfile.queryKey(), + }); + queryClient.invalidateQueries({ + queryKey: api.companies.poaDelegation.queryKey(), + }); + }, + }); + const onSubmit = (data: FormData) => { // The letter lives outside the form state, so it's gated here rather than // in the zod resolver. @@ -234,37 +272,62 @@ export default function TabPowerOfAttorney({ {requirePoa - ? "As a freight forwarder you act on other companies' behalf, so a Power of Attorney and its delegation letter are required." - : "Power of Attorney details are optional. If you name a representative, upload the delegation letter authorising them."} + ? "As a freight forwarder you act on other companies' behalf, so a Power of Attorney and its DARS delegation paper are required." + : "Power of Attorney details are optional. If you name a representative, upload the DARS delegation paper authorising them."} + {identity && ( + { + queryClient.invalidateQueries({ + queryKey: api.companies.getProfile.queryKey(), + }); + queryClient.invalidateQueries({ + queryKey: api.companies.poaDelegation.queryKey(), + }); + }} + /> + )} + - - - - + {/* Name, email, phone and address are written by the Fayda + verification for an Ethiopian company, so only the city — which + the address claim does not reliably decompose into — is typed. */} + {!verifiedIdentity && ( + <> - - - - - + + + + + + + + + + + )} @@ -275,14 +338,16 @@ export default function TabPowerOfAttorney({ {...register("poaLocation")} /> - - - + {!verifiedIdentity && ( + + + + )} @@ -292,7 +357,7 @@ export default function TabPowerOfAttorney({ - Delegation letter + DARS delegation paper - The signed letter in which the General Manager delegates the - representative above. Submitted to EDR for review together with the - details; it takes effect once approved. + The delegation paper issued by the Documents Authentication and + Registration Service (DARS) for the representative above — the + authenticated copy, not a plain letter. Submitted to EDR for review + together with the details; it takes effect once approved. {saveBlocked && letterMissing && ( @@ -326,8 +392,8 @@ export default function TabPowerOfAttorney({ icon={} > {requirePoa - ? "Upload the delegation letter before saving — it is required for freight forwarders." - : "Upload the delegation letter for the representative you named, or clear the PoA details."} + ? "Upload the DARS delegation paper before saving — it is required for freight forwarders." + : "Upload the DARS delegation paper for the representative you named, or clear the PoA details."} )} @@ -345,7 +411,7 @@ export default function TabPowerOfAttorney({ }} > - No delegation letter uploaded. + No DARS delegation paper uploaded. ) : ( @@ -397,7 +463,7 @@ export default function TabPowerOfAttorney({ setPickedFile(null)} > @@ -414,7 +480,7 @@ export default function TabPowerOfAttorney({ - Awaiting EDR review — this letter takes effect once approved. + Awaiting EDR review — this paper takes effect once approved. )} @@ -456,6 +522,20 @@ export default function TabPowerOfAttorney({ )} + {mode === "edit" && + verifiedIdentity && + identity?.poa.verified && + !requirePoa && ( + + )} {mode === "edit" && (