mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 17:10:56 +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:
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user