feat(backoffice): add organization admin permission assignment page (EDRFREIGHT-242)

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 <noreply@anthropic.com>
This commit is contained in:
Nathnael
2026-07-28 12:27:44 +00:00
parent c2375a5306
commit b1cb149c44
7 changed files with 294 additions and 7 deletions

View File

@@ -7492,12 +7492,22 @@
"loadError": "አስተዳዳሪዎችን መጫን አልተሳካም።",
"pickerError": "ድርጅቶችን መጫን አልተሳካም።",
"addAdmin": "አስተዳዳሪ ጨምር",
"managePermissions": "ፍቃዶችን ያስተዳድሩ",
"add": {
"title": "አስተዳዳሪ ጨምር",
"description": "የተጠቃሚ መለያ ይፍጠሩ እና በዚህ ድርጅት ውስጥ የአስተዳዳሪ መዳረሻ ይስጡ።",
"submit": "አስተዳዳሪ ጨምር",
"inviteNote": "ተጠቃሚው ይፈጠራል እና የይለፍ ቃሉን እንዲያዘጋጅ የኤስኤምኤስ ግብዣ ይደርሰዋል።",
"noUnitsOrgAdmin": "ይህ ድርጅት ክፍሎች የሉትም — አስተዳዳሪው እንደ የድርጅት አስተዳዳሪ ይጨመራል።"
},
"permissions": {
"title": "የድርጅት አስተዳዳሪ ፍቃዶች",
"subtitle": "እያንዳንዱ የድርጅት አስተዳዳሪ በመድረኩ ላይ ምን ማድረግ እንደሚችል ይምረጡ።",
"backToAdmins": "ወደ ድርጅት አስተዳዳሪዎች ይመለሱ",
"roleNotFound": "የድርጅት አስተዳዳሪ ሚና ማግኘት አልተቻለም።",
"saved": "የድርጅት አስተዳዳሪ ፍቃዶች ተዘምነዋል።",
"saveFailed": "የድርጅት አስተዳዳሪ ፍቃዶችን ማዘመን አልተቻለም።",
"cannotClearAll": "ተቀምጧል። ፍቃዶቹ አልተቀየሩም — የድርጅት አስተዳዳሪ ሚና ቢያንስ አንድ ፍቃድ ሊኖረው ይገባል።"
}
}
}

View File

@@ -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."
}
}
}

View File

@@ -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<string[]>([]);
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 (
<div className="p-6 space-y-6">
<Card className="shadow-none border-none bg-transparent px-0">
<CardHeader className="px-0 space-y-1">
<Link
to="/user-management/organization_admins"
className="inline-flex w-fit items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
<ArrowLeft className="h-4 w-4" />
{t("orgAdmins.permissions.backToAdmins")}
</Link>
<CardTitle className="text-2xl font-bold text-slate-800 dark:text-slate-100">
{t("orgAdmins.permissions.title")}
</CardTitle>
<p className="text-sm text-muted-foreground">
{t("orgAdmins.permissions.subtitle")}
</p>
</CardHeader>
<CardContent className="px-0 space-y-6">
{isLoadingRoles ? (
<div className="py-8 text-center text-muted-foreground">
{t("common.loading")}
</div>
) : isRolesError || !orgAdminRole ? (
<div className="py-8 text-center text-red-500">
{t("orgAdmins.permissions.roleNotFound")}
</div>
) : (
<>
<div className="w-full sm:w-1/2">
<label className="block text-sm font-medium text-gray-700">
{t("contentManagement.selectApplication")}
</label>
<Select
value={selectedApplicationId}
onValueChange={setSelectedApplicationId}
disabled={isLoadingApplications}>
<SelectTrigger className="mt-1 block w-full border-gray-300 rounded-md shadow-sm">
<SelectValue
placeholder={
isLoadingApplications
? t("common.loading")
: t("contentManagement.selectApplication")
}
/>
</SelectTrigger>
<SelectContent className="max-h-60 overflow-y-auto">
{applications?.map((app) => (
<SelectItem key={app.id} value={app.id}>
{localizedName(app.name)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
{t("contentManagement.permission")}
{selectedPermissionCount > 0 && (
<span className="ml-2 font-normal text-muted-foreground">
(
{t("contentManagement.permissionsSelected", {
count: selectedPermissionCount,
})}
)
</span>
)}
</label>
<PermissionSearch
selectedPermissions={permissions}
onPermissionChange={handlePermissionChange}
applicationId={selectedApplicationId}
disabled={isLoadingPermissions}
/>
</div>
<div className="flex justify-end">
<Button
type="button"
disabled={isSaving || isLoadingPermissions}
onClick={() => save()}>
{isSaving ? t("common.saving") : t("delegation.save")}
</Button>
</div>
</>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -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() {
<div className="p-6 space-y-6">
<Card className="shadow-none border-none bg-transparent px-0">
<CardHeader className="px-0 space-y-1">
<CardTitle className="text-2xl font-bold text-slate-800 dark:text-slate-100">
{t("orgAdmins.title")}
</CardTitle>
<p className="text-sm text-muted-foreground">
{t("orgAdmins.subtitle")}
</p>
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<CardTitle className="text-2xl font-bold text-slate-800 dark:text-slate-100">
{t("orgAdmins.title")}
</CardTitle>
<p className="text-sm text-muted-foreground">
{t("orgAdmins.subtitle")}
</p>
</div>
<Button variant="outline" asChild>
<Link to="/user-management/organization_admins/permissions">
<ShieldCheck className="h-4 w-4" />
{t("orgAdmins.managePermissions")}
</Link>
</Button>
</div>
</CardHeader>
<CardContent className="px-0 space-y-4">
{/* Org selector + summary */}

View File

@@ -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<AxiosResponse<PermissionListResponse>> =>
axiosInstance.get(`/role-permissions/given-first/${roleId}`, {
headers: withHeaders(),
});
// POST /role-permissions/assign-seconds-for-first
export const assignPermissionsToRole = async (
payload: AssignRolePermissionsPayload,
): Promise<AxiosResponse<void>> =>
axiosInstance.post("/role-permissions/assign-seconds-for-first", payload, {
headers: withHeaders(),
});

View File

@@ -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<AxiosResponse<RoleListResponse>> =>
axiosInstance.get("/roles", {
headers: withHeaders(),
params: { take: 100 },
});

View File

@@ -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={<OrganizationAdminsPage />}
/>
<Route
path="user-management/organization_admins/permissions"
element={<OrgAdminPermissionsPage />}
/>
<Route
path="user-management/add_admin"
element={<AdminRegistrationPage />}