From 2ccbfe324fd23cba9eec6809f9012d63f8e71d3c Mon Sep 17 00:00:00 2001
From: Michael Abebe
Date: Thu, 28 May 2026 10:44:41 +0300
Subject: [PATCH 1/3] feat(freight:backoffice): Added permissions and roles
pages under user-management dashboard
---
apps/edr-freight-web/backoffice/src/App.tsx | 14 +
.../user-management/PermissionsPage.tsx | 202 ++++++++++++
.../dashboard/user-management/RolesPage.tsx | 291 +++++++++++++++++-
3 files changed, 502 insertions(+), 5 deletions(-)
create mode 100644 apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/PermissionsPage.tsx
diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx
index 6d28d8ac9..10a69ae5e 100644
--- a/apps/edr-freight-web/backoffice/src/App.tsx
+++ b/apps/edr-freight-web/backoffice/src/App.tsx
@@ -5,6 +5,8 @@ import { LayoutDashboard, Network } from "lucide-react";
import { useAuth } from "./auth/useAuth";
import LoginPage from "./pages/auth/LoginPage";
import OverviewPage from "./pages/dashboard/OverviewPage";
+import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage";
+import RolesPage from "./pages/dashboard/user-management/RolesPage";
import UserManagementPage from "./pages/dashboard/user-management/UserManagementPage";
import LoadingScreen from "./components/LoadingScreen";
@@ -18,6 +20,16 @@ const sidebarItems: SidebarItem[] = [
label: "User management",
href: "/dashboard/user-management",
icon: ,
+ children: [
+ {
+ label: "Permissions",
+ href: "/dashboard/user-management/permissions",
+ },
+ {
+ label: "Roles",
+ href: "/dashboard/user-management/roles",
+ },
+ ],
},
];
@@ -67,6 +79,8 @@ const App = () => {
}>
} />
} />
+ } />
+ } />
} />
} />
diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/PermissionsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/PermissionsPage.tsx
new file mode 100644
index 000000000..d0d9a7867
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/PermissionsPage.tsx
@@ -0,0 +1,202 @@
+import { useEffect, useState } from "react";
+import { isAxiosError } from "axios";
+
+import { api } from "@/auth/http";
+
+interface LocaleText {
+ en?: string;
+ am?: string;
+}
+
+interface PermissionRecord {
+ id: string;
+ key: string;
+ name?: LocaleText;
+}
+
+interface ListResponse {
+ count?: number;
+ items?: T[];
+ data?: T[];
+}
+
+const PAGE_SIZE = 2000;
+
+const getLocaleLabel = (value?: LocaleText | null, fallback = "Unnamed") =>
+ value?.en ?? value?.am ?? fallback;
+
+const getItems = (payload: ListResponse | T[] | undefined | null) => {
+ if (!payload) {
+ return [] as T[];
+ }
+
+ if (Array.isArray(payload)) {
+ return payload;
+ }
+
+ return payload.items ?? payload.data ?? [];
+};
+
+const sortPermissions = (items: PermissionRecord[]) =>
+ [...items].sort((left, right) =>
+ getLocaleLabel(left.name, left.key).localeCompare(
+ getLocaleLabel(right.name, right.key),
+ ),
+ );
+
+const PermissionsPage = () => {
+ const [permissions, setPermissions] = useState([]);
+ const [count, setCount] = useState(0);
+ const [loading, setLoading] = useState(true);
+ const [loadingMore, setLoadingMore] = useState(false);
+ const [errorMessage, setErrorMessage] = useState(null);
+
+ useEffect(() => {
+ let isMounted = true;
+
+ const loadPermissions = async () => {
+ setLoading(true);
+ setErrorMessage(null);
+
+ try {
+ const response = await api.get>("/permissions", {
+ params: {
+ skip: 0,
+ take: PAGE_SIZE,
+ },
+ });
+
+ if (!isMounted) {
+ return;
+ }
+
+ const items = sortPermissions(getItems(response.data));
+
+ setPermissions(items);
+ setCount(response.data.count ?? items.length);
+ } catch (error) {
+ if (!isMounted) {
+ return;
+ }
+
+ setErrorMessage(
+ isAxiosError(error)
+ ? error.response?.data?.message ?? "Unable to load permissions."
+ : "Unable to load permissions.",
+ );
+ } finally {
+ if (isMounted) {
+ setLoading(false);
+ }
+ }
+ };
+
+ void loadPermissions();
+
+ return () => {
+ isMounted = false;
+ };
+ }, []);
+
+ const hasMore = count > permissions.length;
+
+ const handleLoadMore = async () => {
+ setLoadingMore(true);
+ setErrorMessage(null);
+
+ try {
+ const response = await api.get>("/permissions", {
+ params: {
+ skip: permissions.length,
+ take: PAGE_SIZE,
+ },
+ });
+
+ const nextItems = sortPermissions(getItems(response.data));
+
+ setPermissions((current) => [...current, ...nextItems]);
+ setCount(response.data.count ?? permissions.length + nextItems.length);
+ } catch (error) {
+ setErrorMessage(
+ isAxiosError(error)
+ ? error.response?.data?.message ?? "Unable to load more permissions."
+ : "Unable to load more permissions.",
+ );
+ } finally {
+ setLoadingMore(false);
+ }
+ };
+
+ return (
+
+
+
+
+ User Management
+
+
+
+
Permissions
+
+ Browse the full IAM permission catalog for the freight backoffice environment.
+
+
+
+ {count || permissions.length} permissions
+
+
+
+
+ {loading ? (
+
+ Loading permissions...
+
+ ) : errorMessage ? (
+
+ {errorMessage}
+
+ ) : permissions.length ? (
+
+ {permissions.map((permission) => (
+
+
+
+ {getLocaleLabel(permission.name, permission.key)}
+
+
+ {permission.key}
+
+
+
+ ))}
+
+ ) : (
+
+ No permissions are available in the system.
+
+ )}
+
+ {hasMore ? (
+
+
+ Showing {permissions.length} of {count} permissions.
+
+
+
+ ) : null}
+
+
+ );
+};
+
+export default PermissionsPage;
diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/RolesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/RolesPage.tsx
index fd02787cf..862df0119 100644
--- a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/RolesPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/RolesPage.tsx
@@ -1,11 +1,292 @@
-import FeaturePlaceholder from "@/components/FeaturePlaceholder";
+import { useEffect, useState } from "react";
+import { isAxiosError } from "axios";
+import { Shield } from "lucide-react";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@edr/ui-common";
+
+import { api } from "@/auth/http";
+
+interface LocaleText {
+ en?: string;
+ am?: string;
+}
+
+interface RoleRecord {
+ id: string;
+ key: string;
+ name?: LocaleText;
+}
+
+interface PermissionRecord {
+ id: string;
+ key: string;
+ name?: LocaleText;
+}
+
+interface ListResponse {
+ items?: T[];
+ data?: T[];
+}
+
+const getLocaleLabel = (value?: LocaleText | null, fallback = "Unnamed") =>
+ value?.en ?? value?.am ?? fallback;
+
+const getItems = (payload: ListResponse | T[] | undefined | null) => {
+ if (!payload) {
+ return [] as T[];
+ }
+
+ if (Array.isArray(payload)) {
+ return payload;
+ }
+
+ return payload.items ?? payload.data ?? [];
+};
const RolesPage = () => {
+ const [roles, setRoles] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [errorMessage, setErrorMessage] = useState(null);
+ const [selectedRole, setSelectedRole] = useState(null);
+ const [rolePermissions, setRolePermissions] = useState([]);
+ const [rolePermissionsLoading, setRolePermissionsLoading] = useState(false);
+ const [rolePermissionsError, setRolePermissionsError] = useState(null);
+
+ useEffect(() => {
+ let isMounted = true;
+
+ const loadRoles = async () => {
+ setLoading(true);
+ setErrorMessage(null);
+
+ try {
+ const response = await api.get>("/roles");
+
+ if (!isMounted) {
+ return;
+ }
+
+ setRoles(
+ getItems(response.data).sort((left, right) =>
+ getLocaleLabel(left.name, left.key).localeCompare(
+ getLocaleLabel(right.name, right.key),
+ ),
+ ),
+ );
+ } catch (error) {
+ if (!isMounted) {
+ return;
+ }
+
+ setErrorMessage(
+ isAxiosError(error)
+ ? error.response?.data?.message ?? "Unable to load roles."
+ : "Unable to load roles.",
+ );
+ } finally {
+ if (isMounted) {
+ setLoading(false);
+ }
+ }
+ };
+
+ void loadRoles();
+
+ return () => {
+ isMounted = false;
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!selectedRole) {
+ setRolePermissions([]);
+ setRolePermissionsError(null);
+ setRolePermissionsLoading(false);
+ return;
+ }
+
+ let isMounted = true;
+
+ const loadRolePermissions = async () => {
+ setRolePermissionsLoading(true);
+ setRolePermissionsError(null);
+
+ try {
+ const response = await api.get>(
+ `/role-permissions/given-first/${selectedRole.id}`,
+ );
+
+ if (!isMounted) {
+ return;
+ }
+
+ setRolePermissions(
+ getItems(response.data).sort((left, right) =>
+ getLocaleLabel(left.name, left.key).localeCompare(
+ getLocaleLabel(right.name, right.key),
+ ),
+ ),
+ );
+ } catch (error) {
+ if (!isMounted) {
+ return;
+ }
+
+ setRolePermissionsError(
+ isAxiosError(error)
+ ? error.response?.data?.message ?? "Unable to load role details."
+ : "Unable to load role details.",
+ );
+ } finally {
+ if (isMounted) {
+ setRolePermissionsLoading(false);
+ }
+ }
+ };
+
+ void loadRolePermissions();
+
+ return () => {
+ isMounted = false;
+ };
+ }, [selectedRole]);
+
return (
-
+
+
+
+
+ User Management
+
+
+
+
Roles
+
+ Browse freight backoffice roles and their internal keys in a simple grid view.
+
+
+
+ {roles.length} roles
+
+
+
+
+ {loading ? (
+
+ Loading roles...
+
+ ) : errorMessage ? (
+
+ {errorMessage}
+
+ ) : roles.length ? (
+
+ {roles.map((role) => (
+
+ ))}
+
+ ) : (
+
+ No roles available.
+
+ )}
+
+
+
+
);
};
From 6841c3f4d834ef9d4af6c6d36d6511e51299a756 Mon Sep 17 00:00:00 2001
From: Michael Abebe
Date: Thu, 28 May 2026 10:57:20 +0300
Subject: [PATCH 2/3] feat(freight:backoffice): group roles by apps
---
.../user-management/PermissionsPage.tsx | 91 ++++++++++++++++---
1 file changed, 77 insertions(+), 14 deletions(-)
diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/PermissionsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/PermissionsPage.tsx
index d0d9a7867..10c5aba25 100644
--- a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/PermissionsPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/PermissionsPage.tsx
@@ -1,5 +1,12 @@
import { useEffect, useState } from "react";
import { isAxiosError } from "axios";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@edr/ui-common";
import { api } from "@/auth/http";
@@ -12,6 +19,13 @@ interface PermissionRecord {
id: string;
key: string;
name?: LocaleText;
+ applicationId?: string | null;
+}
+
+interface ApplicationRecord {
+ id: string;
+ key: string;
+ name?: LocaleText;
}
interface ListResponse {
@@ -21,6 +35,8 @@ interface ListResponse {
}
const PAGE_SIZE = 2000;
+const ALL_APPLICATIONS_VALUE = "all";
+const SYSTEM_APPLICATION_VALUE = "system";
const getLocaleLabel = (value?: LocaleText | null, fallback = "Unnamed") =>
value?.en ?? value?.am ?? fallback;
@@ -46,6 +62,8 @@ const sortPermissions = (items: PermissionRecord[]) =>
const PermissionsPage = () => {
const [permissions, setPermissions] = useState([]);
+ const [applications, setApplications] = useState([]);
+ const [selectedApplication, setSelectedApplication] = useState(ALL_APPLICATIONS_VALUE);
const [count, setCount] = useState(0);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
@@ -54,26 +72,35 @@ const PermissionsPage = () => {
useEffect(() => {
let isMounted = true;
- const loadPermissions = async () => {
+ const loadPageData = async () => {
setLoading(true);
setErrorMessage(null);
try {
- const response = await api.get>("/permissions", {
- params: {
- skip: 0,
- take: PAGE_SIZE,
- },
- });
+ const [permissionsResponse, applicationsResponse] = await Promise.all([
+ api.get>("/permissions", {
+ params: {
+ skip: 0,
+ take: PAGE_SIZE,
+ },
+ }),
+ api.get>("/applications"),
+ ]);
if (!isMounted) {
return;
}
- const items = sortPermissions(getItems(response.data));
+ const items = sortPermissions(getItems(permissionsResponse.data));
+ const applicationItems = [...getItems(applicationsResponse.data)].sort((left, right) =>
+ getLocaleLabel(left.name, left.key).localeCompare(
+ getLocaleLabel(right.name, right.key),
+ ),
+ );
setPermissions(items);
- setCount(response.data.count ?? items.length);
+ setApplications(applicationItems);
+ setCount(permissionsResponse.data.count ?? items.length);
} catch (error) {
if (!isMounted) {
return;
@@ -91,7 +118,7 @@ const PermissionsPage = () => {
}
};
- void loadPermissions();
+ void loadPageData();
return () => {
isMounted = false;
@@ -99,6 +126,17 @@ const PermissionsPage = () => {
}, []);
const hasMore = count > permissions.length;
+ const filteredPermissions = permissions.filter((permission) => {
+ if (selectedApplication === ALL_APPLICATIONS_VALUE) {
+ return true;
+ }
+
+ if (selectedApplication === SYSTEM_APPLICATION_VALUE) {
+ return !permission.applicationId;
+ }
+
+ return permission.applicationId === selectedApplication;
+ });
const handleLoadMore = async () => {
setLoadingMore(true);
@@ -142,11 +180,36 @@ const PermissionsPage = () => {
- {count || permissions.length} permissions
+ {filteredPermissions.length} permissions
+
+
+
+ Application
+
+
+
+
+ Filter the IAM permission catalog by application, or view the shared system permissions that do not belong to any application.
+
+
+
{loading ? (
Loading permissions...
@@ -155,9 +218,9 @@ const PermissionsPage = () => {
{errorMessage}
- ) : permissions.length ? (
+ ) : filteredPermissions.length ? (
- {permissions.map((permission) => (
+ {filteredPermissions.map((permission) => (
{
) : (
- No permissions are available in the system.
+ No permissions match the selected application.
)}
From c0fc66729b67262a2e948077ccb80a1807532513 Mon Sep 17 00:00:00 2001
From: Michael Abebe
Date: Thu, 28 May 2026 11:44:58 +0300
Subject: [PATCH 3/3] feat(freight:backoffice): Added employees page
---
apps/edr-freight-web/backoffice/src/App.tsx | 6 +
.../user-management/EmployeesPage.tsx | 321 ++++++++++++++++++
2 files changed, 327 insertions(+)
create mode 100644 apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/EmployeesPage.tsx
diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx
index 10a69ae5e..7947060ef 100644
--- a/apps/edr-freight-web/backoffice/src/App.tsx
+++ b/apps/edr-freight-web/backoffice/src/App.tsx
@@ -5,6 +5,7 @@ import { LayoutDashboard, Network } from "lucide-react";
import { useAuth } from "./auth/useAuth";
import LoginPage from "./pages/auth/LoginPage";
import OverviewPage from "./pages/dashboard/OverviewPage";
+import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage";
import RolesPage from "./pages/dashboard/user-management/RolesPage";
import UserManagementPage from "./pages/dashboard/user-management/UserManagementPage";
@@ -21,6 +22,10 @@ const sidebarItems: SidebarItem[] = [
href: "/dashboard/user-management",
icon: ,
children: [
+ {
+ label: "Employees",
+ href: "/dashboard/user-management/employees",
+ },
{
label: "Permissions",
href: "/dashboard/user-management/permissions",
@@ -79,6 +84,7 @@ const App = () => {
}>
} />
} />
+ } />
} />
} />
} />
diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/EmployeesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/EmployeesPage.tsx
new file mode 100644
index 000000000..2e54608b0
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/EmployeesPage.tsx
@@ -0,0 +1,321 @@
+import { useEffect, useState } from "react";
+import { isAxiosError } from "axios";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@edr/ui-common";
+
+import { api } from "@/auth/http";
+import { useAuth } from "@/auth/useAuth";
+
+interface LocaleText {
+ en?: string;
+ am?: string;
+}
+
+interface EmployeeUserRecord {
+ id: string;
+ name?: LocaleText;
+ email?: string;
+ phoneNumber?: string;
+ username?: string;
+}
+
+interface EmployeePositionSummary {
+ id: string;
+ position?: {
+ id: string;
+ name?: LocaleText;
+ key?: string;
+ };
+}
+
+interface EmployeeRecord {
+ id: string;
+ name?: LocaleText;
+ status?: string;
+ user?: EmployeeUserRecord;
+ employeePositions?: EmployeePositionSummary[];
+}
+
+interface ListResponse {
+ count?: number;
+ items?: T[];
+ data?: T[];
+}
+
+const getLocaleLabel = (value?: LocaleText | null, fallback = "Unnamed") =>
+ value?.en ?? value?.am ?? fallback;
+
+const getItems = (payload: ListResponse | T[] | undefined | null) => {
+ if (!payload) {
+ return [] as T[];
+ }
+
+ if (Array.isArray(payload)) {
+ return payload;
+ }
+
+ return payload.items ?? payload.data ?? [];
+};
+
+const getEmployeeDisplayName = (employee: EmployeeRecord) =>
+ getLocaleLabel(
+ employee.name ?? employee.user?.name,
+ employee.user?.email ?? employee.user?.username ?? employee.id,
+ );
+
+const EmployeesPage = () => {
+ const { user } = useAuth();
+ const [employees, setEmployees] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [errorMessage, setErrorMessage] = useState(null);
+ const [selectedEmployee, setSelectedEmployee] = useState(null);
+
+ useEffect(() => {
+ let isMounted = true;
+ const organizationIds = Array.from(
+ new Set(
+ (user?.employee ?? [])
+ .map((employee) => employee.organizationId)
+ .filter((organizationId): organizationId is string => Boolean(organizationId)),
+ ),
+ );
+
+ const loadEmployees = async () => {
+ setLoading(true);
+ setErrorMessage(null);
+
+ if (!organizationIds.length) {
+ setEmployees([]);
+ setErrorMessage("No employee organization scope is available for this account.");
+ setLoading(false);
+ return;
+ }
+
+ try {
+ const responses = await Promise.all(
+ organizationIds.map((organizationId) =>
+ api.get>(
+ `/employees/${organizationId}/by-organization`,
+ {
+ params: {
+ skip: 0,
+ take: 1000,
+ },
+ },
+ ),
+ ),
+ );
+
+ if (!isMounted) {
+ return;
+ }
+
+ const uniqueEmployees = Array.from(
+ new Map(
+ responses
+ .flatMap((response) => getItems(response.data))
+ .map((employee) => [employee.id, employee]),
+ ).values(),
+ );
+
+ setEmployees(
+ uniqueEmployees.sort((left, right) =>
+ getEmployeeDisplayName(left).localeCompare(getEmployeeDisplayName(right)),
+ ),
+ );
+ } catch (error) {
+ if (!isMounted) {
+ return;
+ }
+
+ setErrorMessage(
+ isAxiosError(error)
+ ? error.response?.data?.message ?? "Unable to load employees."
+ : "Unable to load employees.",
+ );
+ } finally {
+ if (isMounted) {
+ setLoading(false);
+ }
+ }
+ };
+
+ void loadEmployees();
+
+ return () => {
+ isMounted = false;
+ };
+ }, [user]);
+
+ return (
+
+
+
+
+ User Management
+
+
+
+
Employees
+
+ Browse employees within your accessible scope and open a record to review contact and position details.
+
+
+
+ {employees.length} employees
+
+
+
+
+ {loading ? (
+
+ Loading employees...
+
+ ) : errorMessage ? (
+
+ {errorMessage}
+
+ ) : employees.length ? (
+
+
+
+
+ | Name |
+ Username |
+ Email |
+ Phone |
+ Status |
+ Positions |
+
+
+
+ {employees.map((employee) => {
+ const positions = employee.employeePositions?.map((item) => getLocaleLabel(item.position?.name, item.position?.key ?? "Unnamed")) ?? [];
+
+ return (
+ setSelectedEmployee(employee)}
+ className="cursor-pointer border-t border-border bg-background transition hover:bg-accent/20"
+ >
+ | {getEmployeeDisplayName(employee)} |
+ {employee.user?.username ?? "-"} |
+ {employee.user?.email ?? "-"} |
+ {employee.user?.phoneNumber ?? "-"} |
+ {employee.status ?? "-"} |
+ {positions.join(", ") || "-"} |
+
+ );
+ })}
+
+
+
+ ) : (
+
+ No employees are available in your scope.
+
+ )}
+
+
+
+
+ );
+};
+
+export default EmployeesPage;