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. +
+ )} +
+ + !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} +
+
+
); };