diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 6d28d8ac9..7947060ef 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -5,6 +5,9 @@ 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"; import LoadingScreen from "./components/LoadingScreen"; @@ -18,6 +21,20 @@ const sidebarItems: SidebarItem[] = [ label: "User management", href: "/dashboard/user-management", icon: , + children: [ + { + label: "Employees", + href: "/dashboard/user-management/employees", + }, + { + label: "Permissions", + href: "/dashboard/user-management/permissions", + }, + { + label: "Roles", + href: "/dashboard/user-management/roles", + }, + ], }, ]; @@ -67,6 +84,9 @@ 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 ? ( +
+ + + + + + + + + + + + + {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" + > + + + + + + + + ); + })} + +
NameUsernameEmailPhoneStatusPositions
{getEmployeeDisplayName(employee)}{employee.user?.username ?? "-"}{employee.user?.email ?? "-"}{employee.user?.phoneNumber ?? "-"}{employee.status ?? "-"}{positions.join(", ") || "-"}
+
+ ) : ( +
+ No employees are available in your scope. +
+ )} +
+ + !open && setSelectedEmployee(null)}> + + + + {selectedEmployee ? getEmployeeDisplayName(selectedEmployee) : "Employee details"} + + + {selectedEmployee + ? "Review the employee profile, contact information, and assigned positions." + : undefined} + + + + {selectedEmployee ? ( +
+
+
+

+ Employee name +

+

+ {getEmployeeDisplayName(selectedEmployee)} +

+
+
+

+ Status +

+

+ {selectedEmployee.status ?? "-"} +

+
+
+

+ Username +

+

{selectedEmployee.user?.username ?? "-"}

+
+
+

+ Email +

+

{selectedEmployee.user?.email ?? "-"}

+
+
+

+ Phone +

+

{selectedEmployee.user?.phoneNumber ?? "-"}

+
+
+

+ Employee ID +

+

{selectedEmployee.id}

+
+
+ +
+
+

Assigned positions

+
+ {selectedEmployee.employeePositions?.length ?? 0} positions +
+
+ + {selectedEmployee.employeePositions?.length ? ( +
+ {selectedEmployee.employeePositions.map((item) => ( +
+

+ {getLocaleLabel(item.position?.name, item.position?.key ?? "Unnamed")} +

+

+ {item.position?.key ?? "-"} +

+
+ ))} +
+ ) : ( +
+ No positions are assigned to this employee. +
+ )} +
+
+ ) : null} +
+
+
+ ); +}; + +export default EmployeesPage; 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..10c5aba25 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/PermissionsPage.tsx @@ -0,0 +1,265 @@ +import { useEffect, useState } from "react"; +import { isAxiosError } from "axios"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@edr/ui-common"; + +import { api } from "@/auth/http"; + +interface LocaleText { + en?: string; + am?: string; +} + +interface PermissionRecord { + id: string; + key: string; + name?: LocaleText; + applicationId?: string | null; +} + +interface ApplicationRecord { + id: string; + key: string; + name?: LocaleText; +} + +interface ListResponse { + count?: number; + items?: T[]; + data?: T[]; +} + +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; + +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 [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); + const [errorMessage, setErrorMessage] = useState(null); + + useEffect(() => { + let isMounted = true; + + const loadPageData = async () => { + setLoading(true); + setErrorMessage(null); + + try { + 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(permissionsResponse.data)); + const applicationItems = [...getItems(applicationsResponse.data)].sort((left, right) => + getLocaleLabel(left.name, left.key).localeCompare( + getLocaleLabel(right.name, right.key), + ), + ); + + setPermissions(items); + setApplications(applicationItems); + setCount(permissionsResponse.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 loadPageData(); + + return () => { + isMounted = false; + }; + }, []); + + 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); + 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. +

+
+
+ {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... +
+ ) : errorMessage ? ( +
+ {errorMessage} +
+ ) : filteredPermissions.length ? ( +
+ {filteredPermissions.map((permission) => ( +
+
+

+ {getLocaleLabel(permission.name, permission.key)} +

+

+ {permission.key} +

+
+
+ ))} +
+ ) : ( +
+ No permissions match the selected application. +
+ )} + + {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. +
+ )} +
+ + !open && setSelectedRole(null)}> + + + {selectedRole ? getLocaleLabel(selectedRole.name, selectedRole.key) : "Role details"} + + {selectedRole + ? `Review the permission set assigned to ${getLocaleLabel(selectedRole.name, selectedRole.key)}.` + : undefined} + + + + {selectedRole ? ( +
+
+
+

+ Role name +

+

+ {getLocaleLabel(selectedRole.name, selectedRole.key)} +

+
+
+

+ Role key +

+

{selectedRole.key}

+
+
+ +
+
+

Permissions

+
+ {rolePermissions.length} permissions +
+
+ + {rolePermissionsLoading ? ( +
+ Loading role details... +
+ ) : rolePermissionsError ? ( +
+ {rolePermissionsError} +
+ ) : rolePermissions.length ? ( +
+ {rolePermissions.map((permission) => ( +
+

+ {getLocaleLabel(permission.name, permission.key)} +

+

+ {permission.key} +

+
+ ))} +
+ ) : ( +
+ No permissions are assigned to this role. +
+ )} +
+
+ ) : null} +
+
+
); };