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

@@ -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 */}