mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
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:
@@ -7492,12 +7492,22 @@
|
||||
"loadError": "አስተዳዳሪዎችን መጫን አልተሳካም።",
|
||||
"pickerError": "ድርጅቶችን መጫን አልተሳካም።",
|
||||
"addAdmin": "አስተዳዳሪ ጨምር",
|
||||
"managePermissions": "ፍቃዶችን ያስተዳድሩ",
|
||||
"add": {
|
||||
"title": "አስተዳዳሪ ጨምር",
|
||||
"description": "የተጠቃሚ መለያ ይፍጠሩ እና በዚህ ድርጅት ውስጥ የአስተዳዳሪ መዳረሻ ይስጡ።",
|
||||
"submit": "አስተዳዳሪ ጨምር",
|
||||
"inviteNote": "ተጠቃሚው ይፈጠራል እና የይለፍ ቃሉን እንዲያዘጋጅ የኤስኤምኤስ ግብዣ ይደርሰዋል።",
|
||||
"noUnitsOrgAdmin": "ይህ ድርጅት ክፍሎች የሉትም — አስተዳዳሪው እንደ የድርጅት አስተዳዳሪ ይጨመራል።"
|
||||
},
|
||||
"permissions": {
|
||||
"title": "የድርጅት አስተዳዳሪ ፍቃዶች",
|
||||
"subtitle": "እያንዳንዱ የድርጅት አስተዳዳሪ በመድረኩ ላይ ምን ማድረግ እንደሚችል ይምረጡ።",
|
||||
"backToAdmins": "ወደ ድርጅት አስተዳዳሪዎች ይመለሱ",
|
||||
"roleNotFound": "የድርጅት አስተዳዳሪ ሚና ማግኘት አልተቻለም።",
|
||||
"saved": "የድርጅት አስተዳዳሪ ፍቃዶች ተዘምነዋል።",
|
||||
"saveFailed": "የድርጅት አስተዳዳሪ ፍቃዶችን ማዘመን አልተቻለም።",
|
||||
"cannotClearAll": "ተቀምጧል። ፍቃዶቹ አልተቀየሩም — የድርጅት አስተዳዳሪ ሚና ቢያንስ አንድ ፍቃድ ሊኖረው ይገባል።"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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 />}
|
||||
|
||||
Reference in New Issue
Block a user