Merge pull request #1013 from Tria-plc/freight/fix/auth-separation

Freight/fix/auth separation
This commit is contained in:
Nathnael Wondisha
2026-07-29 17:37:41 +03:00
committed by GitHub
11 changed files with 389 additions and 45 deletions

View File

@@ -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": "ተቀምጧል። ፍቃዶቹ አልተቀየሩም — የድርጅት አስተዳዳሪ ሚና ቢያንስ አንድ ፍቃድ ሊኖረው ይገባል።"
}
}
}

View File

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

View File

@@ -1169,6 +1169,8 @@
"notFoundError": "Nous navons 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 sest produite lors de lajout 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éé",

View File

@@ -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));

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

@@ -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>

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

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

@@ -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

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