mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1013 from Tria-plc/freight/fix/auth-separation
Freight/fix/auth separation
This commit is contained in:
@@ -1649,6 +1649,8 @@
|
||||
"notFoundError": "የፈለጉትን መረጃ አልተገኘም።",
|
||||
"fileTooLarge": "ፋይሉ በጣም ትልቅ ነው። እባክዎ ፋይሉን አሳንሰው ዳግም ይሞክሩ።",
|
||||
"serverError": "ከአገልጋይ በኩል ችግር አለ። እባክዎ ዳግመኛ ይሞክሩ።",
|
||||
"userRoleNotFound": "ይህ የአስተዳዳሪ ሚና ምደባ አልተገኘም — ቀደም ብሎ ተወግዶ ሊሆን ይችላል።",
|
||||
"unitEmployeeLimitReached": "ይህ ክፍል የ{{limit}} ሰራተኞች ገደብ ላይ ደርሷል።",
|
||||
"attachmentDeleted": "አባሪው በተሳካ ሁኔታ ተሰርዟል።",
|
||||
"replyAdded": "ምላሹ በተሳካ ሁኔታ ታክሏል!",
|
||||
"replyError": "ምላሹን በመጨመር ላይ ስህተት አጋጥሟል።",
|
||||
@@ -2653,6 +2655,7 @@
|
||||
"copyPermissionsHint": "የነበረ የቦታ አይነት ይምረጡ፤ ፍቃዶቹ አስቀድመው ይሞላሉ፣ ከታች ማስተካከል ይችላሉ።",
|
||||
"copyPermissionsFailed": "ፍቃዶችን መቅዳት አልተቻለም",
|
||||
"selectOrganizationToCopy": "መቅዳት የሚችሏቸውን የቦታ ዓይነቶች ለማየት መጀመሪያ ድርጅት ይምረጡ",
|
||||
"selectUnitToCopy": "መቅዳት የሚችሏቸውን የቦታ ዓይነቶች ለማየት መጀመሪያ ክፍል ይምረጡ",
|
||||
"cannotClearAllPermissions": "ተቀምጧል። ፍቃዶቹ አልተቀየሩም — ይህ የቦታ ዓይነት ቢያንስ አንድ ፍቃድ ሊኖረው ይገባል።",
|
||||
"permissionsSelected": "{{count}} ተመርጠዋል",
|
||||
"positionTypeCreated": "የቦታ ዓይነት ተፈጥሯል",
|
||||
@@ -7491,12 +7494,22 @@
|
||||
"loadError": "አስተዳዳሪዎችን መጫን አልተሳካም።",
|
||||
"pickerError": "ድርጅቶችን መጫን አልተሳካም።",
|
||||
"addAdmin": "አስተዳዳሪ ጨምር",
|
||||
"managePermissions": "ፍቃዶችን ያስተዳድሩ",
|
||||
"add": {
|
||||
"title": "አስተዳዳሪ ጨምር",
|
||||
"description": "የተጠቃሚ መለያ ይፍጠሩ እና በዚህ ድርጅት ውስጥ የአስተዳዳሪ መዳረሻ ይስጡ።",
|
||||
"submit": "አስተዳዳሪ ጨምር",
|
||||
"inviteNote": "ተጠቃሚው ይፈጠራል እና የይለፍ ቃሉን እንዲያዘጋጅ የኤስኤምኤስ ግብዣ ይደርሰዋል።",
|
||||
"noUnitsOrgAdmin": "ይህ ድርጅት ክፍሎች የሉትም — አስተዳዳሪው እንደ የድርጅት አስተዳዳሪ ይጨመራል።"
|
||||
},
|
||||
"permissions": {
|
||||
"title": "የድርጅት አስተዳዳሪ ፍቃዶች",
|
||||
"subtitle": "እያንዳንዱ የድርጅት አስተዳዳሪ በመድረኩ ላይ ምን ማድረግ እንደሚችል ይምረጡ።",
|
||||
"backToAdmins": "ወደ ድርጅት አስተዳዳሪዎች ይመለሱ",
|
||||
"roleNotFound": "የድርጅት አስተዳዳሪ ሚና ማግኘት አልተቻለም።",
|
||||
"saved": "የድርጅት አስተዳዳሪ ፍቃዶች ተዘምነዋል።",
|
||||
"saveFailed": "የድርጅት አስተዳዳሪ ፍቃዶችን ማዘመን አልተቻለም።",
|
||||
"cannotClearAll": "ተቀምጧል። ፍቃዶቹ አልተቀየሩም — የድርጅት አስተዳዳሪ ሚና ቢያንስ አንድ ፍቃድ ሊኖረው ይገባል።"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1669,6 +1669,8 @@
|
||||
"notFoundError": "We couldn't find what you were looking for.",
|
||||
"fileTooLarge": "The file is too large. Please reduce the file size and try again.",
|
||||
"serverError": "Something went wrong on our side. Please try again in a moment.",
|
||||
"userRoleNotFound": "This admin role assignment could not be found — it may have already been removed.",
|
||||
"unitEmployeeLimitReached": "This unit has reached its limit of {{limit}} employees.",
|
||||
"attachmentDeleted": "Attachment deleted successfully.",
|
||||
"replyAdded": "Reply added successfully!",
|
||||
"replyError": "An error occurred while adding the reply.",
|
||||
@@ -2762,6 +2764,7 @@
|
||||
"copyPermissionsHint": "Pick an existing position type to pre-fill its permissions, then edit below.",
|
||||
"copyPermissionsFailed": "Failed to copy permissions",
|
||||
"selectOrganizationToCopy": "Select an organization to see the position types you can copy from",
|
||||
"selectUnitToCopy": "Select a unit to see the position types you can copy from",
|
||||
"cannotClearAllPermissions": "Saved. Permissions were left unchanged — this position type must keep at least one permission.",
|
||||
"permissionsSelected": "{{count}} selected",
|
||||
"positionTypeCreated": "Position type created",
|
||||
@@ -7492,12 +7495,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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1169,6 +1169,8 @@
|
||||
"notFoundError": "Nous n’avons pas trouvé ce que vous cherchiez.",
|
||||
"fileTooLarge": "Le fichier est trop volumineux. Veuillez réduire sa taille et réessayer.",
|
||||
"serverError": "Un problème est survenu de notre côté. Veuillez réessayer dans un instant.",
|
||||
"userRoleNotFound": "Cette attribution de rôle d'administrateur est introuvable — elle a peut-être déjà été supprimée.",
|
||||
"unitEmployeeLimitReached": "Cette unité a atteint sa limite de {{limit}} employés.",
|
||||
"attachmentDeleted": "Pièce jointe supprimée avec succès.",
|
||||
"replyAdded": "Réponse ajoutée avec succès !",
|
||||
"replyError": "Une erreur s’est produite lors de l’ajout de la réponse.",
|
||||
@@ -1888,6 +1890,7 @@
|
||||
"copyPermissionsHint": "Choisissez un type de poste existant pour préremplir ses autorisations, puis modifiez ci-dessous.",
|
||||
"copyPermissionsFailed": "Échec de la copie des autorisations",
|
||||
"selectOrganizationToCopy": "Sélectionnez une organisation pour voir les types de poste que vous pouvez copier",
|
||||
"selectUnitToCopy": "Sélectionnez une unité pour voir les types de poste que vous pouvez copier",
|
||||
"cannotClearAllPermissions": "Enregistré. Les autorisations n'ont pas été modifiées — ce type de poste doit conserver au moins une autorisation.",
|
||||
"permissionsSelected": "{{count}} sélectionné(s)",
|
||||
"positionTypeCreated": "Type de poste créé",
|
||||
|
||||
@@ -54,6 +54,28 @@ const extractMessage = (value: unknown): string | null => {
|
||||
}
|
||||
};
|
||||
|
||||
// IAM (@tria-plc/iamapi-common) throws BadRequestException with a raw,
|
||||
// untranslated code string as the message — no i18n on that side — so it
|
||||
// would otherwise reach the UI verbatim (e.g. "user_role_not_found"). Map
|
||||
// known codes to a friendly, translated message before falling back to the
|
||||
// raw text. `unit_employee_limit_reached` carries its configured limit after
|
||||
// a colon (e.g. "unit_employee_limit_reached:5").
|
||||
const UNIT_EMPLOYEE_LIMIT_PREFIX = "unit_employee_limit_reached:";
|
||||
|
||||
const mapIamErrorCode = (
|
||||
raw: string | null,
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
): string | null => {
|
||||
if (!raw) return null;
|
||||
if (raw.startsWith(UNIT_EMPLOYEE_LIMIT_PREFIX)) {
|
||||
return t("msg.unitEmployeeLimitReached", {
|
||||
limit: raw.slice(UNIT_EMPLOYEE_LIMIT_PREFIX.length),
|
||||
});
|
||||
}
|
||||
if (raw === "user_role_not_found") return t("msg.userRoleNotFound");
|
||||
return null;
|
||||
};
|
||||
|
||||
// Maps an HTTP status code to the i18n key used when no backend message is available.
|
||||
const statusKeyFor = (status: number | undefined): string => {
|
||||
if (status === 400 || status === 422) return "msg.validationError";
|
||||
@@ -111,7 +133,7 @@ const parseBlobBody = async (blob: Blob): Promise<unknown> => {
|
||||
};
|
||||
|
||||
export const useErrorHandler = (
|
||||
t: (key: string) => string,
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
) => {
|
||||
const getErrorMessage = useCallback(
|
||||
async (err: unknown): Promise<string> => {
|
||||
@@ -134,15 +156,15 @@ export const useErrorHandler = (
|
||||
const fromException =
|
||||
extractMessage((data as any)?.exception?.response) ??
|
||||
extractMessage((data as any)?.exception);
|
||||
if (fromException) return fromException;
|
||||
if (fromException) return mapIamErrorCode(fromException, t) ?? fromException;
|
||||
|
||||
const fromData = extractMessage(data);
|
||||
if (fromData) return fromData;
|
||||
if (fromData) return mapIamErrorCode(fromData, t) ?? fromData;
|
||||
}
|
||||
|
||||
if (err instanceof Error) {
|
||||
const fromError = extractMessage(err.message);
|
||||
if (fromError) return fromError;
|
||||
if (fromError) return mapIamErrorCode(fromError, t) ?? fromError;
|
||||
}
|
||||
|
||||
return t(statusKeyFor(status));
|
||||
@@ -186,7 +208,7 @@ export const useErrorHandler = (
|
||||
};
|
||||
|
||||
export const useClientErrorHandler = (
|
||||
t: (key: string) => string,
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
) => {
|
||||
const getErrorMessage = useCallback(
|
||||
(err: unknown): string => {
|
||||
@@ -205,13 +227,13 @@ export const useClientErrorHandler = (
|
||||
const fromException =
|
||||
extractMessage(data?.exception?.response) ??
|
||||
extractMessage(data?.exception);
|
||||
if (fromException) return fromException;
|
||||
if (fromException) return mapIamErrorCode(fromException, t) ?? fromException;
|
||||
|
||||
const fromData = extractMessage(data);
|
||||
if (fromData) return fromData;
|
||||
if (fromData) return mapIamErrorCode(fromData, t) ?? fromData;
|
||||
|
||||
const fromError = extractMessage((err as any).message);
|
||||
if (fromError) return fromError;
|
||||
if (fromError) return mapIamErrorCode(fromError, t) ?? fromError;
|
||||
}
|
||||
|
||||
return t(statusKeyFor(status));
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -32,13 +32,15 @@ export interface AdminRoleInfo {
|
||||
/**
|
||||
* all-admins/:id returns users who are org admins of the org OR unit admins of
|
||||
* one of its units; userRoles carries every role of the user, so match the org
|
||||
* explicitly for the org-admin grant.
|
||||
* explicitly for the org-admin grant. The unit-admin grant carries no
|
||||
* organizationId of its own, only a unitId, so orgUnitIds (every unit that
|
||||
* belongs to the selected org) is required to tell a same-org unit-admin
|
||||
* grant apart from a same-user unit-admin grant in a different org.
|
||||
*/
|
||||
// ponytail: unit relation isn't loaded, so a unit_admin grant from another org
|
||||
// can't be told apart — acceptable, the server only returns admins of this org.
|
||||
export function getAdminRoleInfo(
|
||||
admin: OrgAdminUser,
|
||||
selectedOrgId: string,
|
||||
orgUnitIds: Set<string>,
|
||||
): AdminRoleInfo {
|
||||
const roles = admin.userRoles ?? [];
|
||||
const isOrgAdmin = roles.some(
|
||||
@@ -47,7 +49,10 @@ export function getAdminRoleInfo(
|
||||
r.organizationId === selectedOrgId,
|
||||
);
|
||||
const unitRole = roles.find(
|
||||
(r) => r.role?.key === UNIT_ADMIN_ROLE_KEY && r.unitId,
|
||||
(r) =>
|
||||
r.role?.key === UNIT_ADMIN_ROLE_KEY &&
|
||||
!!r.unitId &&
|
||||
orgUnitIds.has(r.unitId),
|
||||
);
|
||||
return {
|
||||
isOrgAdmin,
|
||||
@@ -58,6 +63,7 @@ export function getAdminRoleInfo(
|
||||
|
||||
interface ColumnCallbacks {
|
||||
selectedOrgId: string;
|
||||
orgUnitIds: Set<string>;
|
||||
localizedName: (name?: { am?: string; en?: string }) => string;
|
||||
onEdit: (admin: OrgAdminUser) => void;
|
||||
onResend: (admin: OrgAdminUser) => void;
|
||||
@@ -67,6 +73,7 @@ interface ColumnCallbacks {
|
||||
|
||||
export function getOrgAdminsColumnDefn({
|
||||
selectedOrgId,
|
||||
orgUnitIds,
|
||||
localizedName,
|
||||
onEdit,
|
||||
onResend,
|
||||
@@ -118,7 +125,7 @@ export function getOrgAdminsColumnDefn({
|
||||
id: "role",
|
||||
header: () => t("orgAdmins.columns.role"),
|
||||
cell: ({ row }) => {
|
||||
const info = getAdminRoleInfo(row.original, selectedOrgId);
|
||||
const info = getAdminRoleInfo(row.original, selectedOrgId, orgUnitIds);
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{info.isOrgAdmin && (
|
||||
@@ -179,7 +186,7 @@ export function getOrgAdminsColumnDefn({
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => {
|
||||
const admin = row.original;
|
||||
const roleInfo = getAdminRoleInfo(admin, selectedOrgId);
|
||||
const roleInfo = getAdminRoleInfo(admin, selectedOrgId, orgUnitIds);
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
||||
@@ -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,
|
||||
@@ -23,6 +31,8 @@ import { Badge } from "@/shared/common/ui/badge";
|
||||
import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import { OrganizationDto } from "@/shared/dto/organization/organizationDto";
|
||||
import { useUnit } from "@/user-management/hooks/useUnit";
|
||||
import { UnitDto } from "@/user-management/dto/unit/unitDto";
|
||||
import {
|
||||
OrgAdminUser,
|
||||
useOrgAdmins,
|
||||
@@ -81,6 +91,22 @@ export default function OrgAdminsPage() {
|
||||
skip: pageIndex * pageSize,
|
||||
});
|
||||
|
||||
const { getList: getUnitList } = useUnit();
|
||||
// A unit-admin grant only carries a unitId, no organizationId — this is the
|
||||
// set that tells "unit_admin of this org" apart from "unit_admin of some
|
||||
// other org the same user also administers" (see getAdminRoleInfo).
|
||||
const { data: orgUnitsResponse } = getUnitList(selectedOrg?.id ?? "", {
|
||||
take: 3000,
|
||||
skip: 0,
|
||||
});
|
||||
const orgUnitIds = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
(orgUnitsResponse?.data?.items ?? []).map((unit: UnitDto) => unit.id),
|
||||
),
|
||||
[orgUnitsResponse],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setPageIndex(0);
|
||||
}, [selectedOrg?.id, pageSize]);
|
||||
@@ -170,6 +196,7 @@ export default function OrgAdminsPage() {
|
||||
() =>
|
||||
getOrgAdminsColumnDefn({
|
||||
selectedOrgId: selectedOrg?.id ?? "",
|
||||
orgUnitIds,
|
||||
localizedName: localizedName as (name?: {
|
||||
am?: string;
|
||||
en?: string;
|
||||
@@ -183,19 +210,29 @@ export default function OrgAdminsPage() {
|
||||
onRemove: (admin, roleInfo) => setRemoveTarget({ admin, roleInfo }),
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[selectedOrg?.id],
|
||||
[selectedOrg?.id, orgUnitIds],
|
||||
);
|
||||
|
||||
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">
|
||||
<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 },
|
||||
});
|
||||
@@ -115,6 +115,7 @@ export const CreatePositionForm = ({
|
||||
});
|
||||
|
||||
const selectedOrganizationId = form.watch("organizationId");
|
||||
const selectedUnitId = form.watch("unitId");
|
||||
|
||||
const { organizationsResponse, isLoading: isLoadingOrgs } = useOrganizations(
|
||||
"Org",
|
||||
@@ -163,36 +164,32 @@ export const CreatePositionForm = ({
|
||||
enabled: mode === "edit" && !!positionTypeId,
|
||||
});
|
||||
|
||||
// A position type belongs to a unit, and a unit to an organization — IAM has
|
||||
// no organizationId on the type itself and no organization-scoped route, so
|
||||
// the picked org narrows the list through its units. isSystem types are the
|
||||
// shared "commons" and stay available to every organization.
|
||||
const orgUnitIds = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
(unitsResponse?.data?.items ?? []).map((unit: UnitDto) => unit.id),
|
||||
),
|
||||
[unitsResponse],
|
||||
);
|
||||
|
||||
// A position type belongs to a single unit — scope copy sources to the
|
||||
// selected unit, same as the "Select Unit" filter on the position list page.
|
||||
// isSystem types are the shared "commons" and stay available everywhere.
|
||||
const copyFromOptions = useMemo(() => {
|
||||
if (!selectedOrganizationId) return [];
|
||||
if (!selectedUnitId) return [];
|
||||
return positionTypes.filter(
|
||||
(type: PositionTypeDto) =>
|
||||
type.id !== positionTypeId &&
|
||||
(type.isSystem || (!!type.unitId && orgUnitIds.has(type.unitId))),
|
||||
(type.isSystem || type.unitId === selectedUnitId),
|
||||
);
|
||||
}, [positionTypes, orgUnitIds, selectedOrganizationId, positionTypeId]);
|
||||
}, [positionTypes, selectedUnitId, positionTypeId]);
|
||||
|
||||
// Reset the selected unit when the organization changes so a unit from a
|
||||
// different org can't be submitted by mistake. The copy source is cleared
|
||||
// too — it is scoped to the old organization.
|
||||
// different org can't be submitted by mistake.
|
||||
useEffect(() => {
|
||||
if (mode === "edit") return;
|
||||
form.setValue("unitId", "");
|
||||
setCopyFromPositionId("");
|
||||
}, [selectedOrganizationId, mode, form]);
|
||||
|
||||
// The copy source is scoped to the selected unit — clear it whenever the
|
||||
// unit changes (including as a side effect of the org reset above) so a
|
||||
// stale selection from a different unit can't be submitted.
|
||||
useEffect(() => {
|
||||
setCopyFromPositionId("");
|
||||
}, [selectedUnitId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "edit" || !initialValues || !positionTypeId) return;
|
||||
if (hasLoadedEditData.current) return;
|
||||
@@ -335,11 +332,13 @@ export const CreatePositionForm = ({
|
||||
|
||||
const copyFromPlaceholder = !selectedOrganizationId
|
||||
? t("contentManagement.selectOrganizationToCopy")
|
||||
: isCopying || isLoadingPositionTypes || isLoadingUnits
|
||||
? t("common.loading")
|
||||
: isErrorPositionTypes
|
||||
? t("contentManagement.failedToLoadPositionTypes")
|
||||
: t("contentManagement.selectPositionToCopy");
|
||||
: !selectedUnitId
|
||||
? t("contentManagement.selectUnitToCopy")
|
||||
: isCopying || isLoadingPositionTypes || isLoadingUnits
|
||||
? t("common.loading")
|
||||
: isErrorPositionTypes
|
||||
? t("contentManagement.failedToLoadPositionTypes")
|
||||
: t("contentManagement.selectPositionToCopy");
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
@@ -454,6 +453,7 @@ export const CreatePositionForm = ({
|
||||
onValueChange={handleCopyFrom}
|
||||
disabled={
|
||||
!selectedOrganizationId ||
|
||||
!selectedUnitId ||
|
||||
isLoadingPositionTypes ||
|
||||
isLoadingUnits ||
|
||||
isCopying
|
||||
|
||||
@@ -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