From c2375a53069903b918577620a21f2fd98b4001ee Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 28 Jul 2026 12:27:14 +0000 Subject: [PATCH 1/4] 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 2/4] 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 3/4] 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 4/4] 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 (