diff --git a/app/components/layout/Sidebar.tsx b/app/components/layout/Sidebar.tsx index 5e1c3e8..8a379ae 100644 --- a/app/components/layout/Sidebar.tsx +++ b/app/components/layout/Sidebar.tsx @@ -54,7 +54,6 @@ const navSections = [ { href: "/dashboard/clients", label: "Clients", icon: "ti-users-group", permission: "read-client" }, { href: "/dashboard/branches", label: "Branches", icon: "ti-building", permission: "read-branch" }, { href: "/dashboard/roles", label: "Roles", icon: "ti-shield", permission: "read-role" }, - { href: "/dashboard/role_permission", label: "Roles & Permission", icon: "ti-shield", permission: "manage-role-permissions" }, { href: "/dashboard/audit", label: "Audit", icon: "ti-clipboard-list", permission: "read-audit" }, ], }, @@ -181,12 +180,12 @@ export function Sidebar() { padding: "20px 12px", zIndex: 50, overflowY: "auto", - /* transform بيخفي/يظهر الـ drawer على sm */ + transform: open ? "translateX(0)" : "translateX(100%)", transition: "transform 280ms cubic-bezier(0.4,0,0.2,1)", }} > - {/* زرار الإغلاق */} + {/*close button */} + + )} + + {role.permissions && role.permissions.length > 0 ? (
{role.permissions.map(({ permission }) => ( {permission.name} + {/* Endpoint 3: remove this permission */} + ))}
- - )} - - {(!role.permissions || role.permissions.length === 0) && ( -
+ ) : (

لا توجد صلاحيات مسندة لهذا الدور

-
- )} + )} + )} diff --git a/src/Components/role/RoleFormModal.tsx b/src/Components/role/RoleFormModal.tsx index 9d555fe..d1bac9a 100644 --- a/src/Components/role/RoleFormModal.tsx +++ b/src/Components/role/RoleFormModal.tsx @@ -43,7 +43,7 @@ async function validate(data: RoleFormData, isNew: boolean): Promise void; @@ -77,7 +77,7 @@ async function validate(data: RoleFormData, isNew: boolean): Promise ); -}*/ +} // ── Props ───────────────────────────────────────────────────────────────────── interface RoleFormModalProps { @@ -225,7 +225,7 @@ export function RoleFormModal({ editRole, permissions, onClose, onSubmit }: Role {/* Permissions */} - {/*{permissions.length > 0 && ( + {permissions.length > 0 && (
@@ -258,7 +258,7 @@ export function RoleFormModal({ editRole, permissions, onClose, onSubmit }: Role {form.permissionIds.length} صلاحية محددة من أصل {permissions.length}

- )}*/} + )} {/* Actions */}
diff --git a/src/hooks/useRole.ts b/src/hooks/useRole.ts index 358d0d7..d36b1de 100644 --- a/src/hooks/useRole.ts +++ b/src/hooks/useRole.ts @@ -3,13 +3,10 @@ import { useCallback, useEffect, useReducer, useState } from "react"; import { getStoredToken } from "@/src/lib/auth"; import { roleService } from "@/src/services/role.service"; +import { parseApiError } from "@/src/lib/apiError"; import { Notification } from "../types/notif"; import { Permission, Role, RoleFormData } from "../types/role"; - - - - // ── Table state / reducer ───────────────────────────────────────────────────── interface TableState { @@ -119,7 +116,6 @@ export function useRoles() { roleService .getPermissions(token) .then((res) => { - //console.log("permissions response:", JSON.stringify(res, null, 2)); const raw = ( res as unknown as { data: { premissions: { data: Permission[] } } } ).data; @@ -142,7 +138,13 @@ export function useRoles() { const res = await roleService.create(data, token); const role = (res as unknown as { data: Role }).data; - // Re-fetch to get fresh data with permissions attached + // Endpoint 2 (PATCH /role/{id}/permissions/bulk): attach selected + // permissions right after creation, since POST /role only accepts + // name/description. + if (data.permissionIds.length) { + await roleService.bulkAssignPermissions(role.id, data.permissionIds, token); + } + await loadRoles(page, search); notify({ type: "success", @@ -152,7 +154,7 @@ export function useRoles() { } catch (err) { notify({ type: "error", - message: err instanceof Error ? err.message : "تعذّر إنشاء الدور.", + message: parseApiError(err, "تعذّر إنشاء الدور."), }); return false; } @@ -164,32 +166,28 @@ export function useRoles() { async ( id: string, data: RoleFormData, - currentPermIds: string[], + _currentPermIds: string[], ): Promise => { try { const token = getStoredToken(); // Update basic fields await roleService.update(id, data, token); - // Diff permissions: add new, remove removed - const toAdd = data.permissionIds.filter( - (p) => !currentPermIds.includes(p), - ); - const toRemove = currentPermIds.filter( - (p) => !data.permissionIds.includes(p), - ); - + // Endpoint 2: replace the full permission set in one call rather + // than diffing add/remove client-side — the bulk endpoint already + // performs the replacement atomically on the backend. + await roleService.bulkAssignPermissions(id, data.permissionIds, token); await loadRoles(page, search); notify({ type: "success", - message: "تم تحديث الدور بنجاح.", + message: "تم تحديث الدور بنجاح.", }); return true; } catch (err) { notify({ type: "error", - message: err instanceof Error ? err.message : "تعذّر تحديث الدور.", + message: parseApiError(err, "تعذّر تحديث الدور."), }); return false; } @@ -208,7 +206,7 @@ export function useRoles() { } catch (err) { notify({ type: "error", - message: err instanceof Error ? err.message : "تعذّر حذف الدور.", + message: parseApiError(err, "تعذّر حذف الدور."), }); return false; } @@ -216,6 +214,43 @@ export function useRoles() { [notify], ); + // ── Endpoint 1: assign a single permission (used from the detail modal) ───── + const assignPermission = useCallback( + async (roleId: string, permissionId: string): Promise => { + try { + const token = getStoredToken(); + await roleService.assignPermission(roleId, permissionId, token); + notify({ type: "success", message: "تم إضافة الصلاحية للدور بنجاح." }); + return true; + } catch (err) { + notify({ + type: "error", + message: parseApiError(err, "تعذّر إضافة الصلاحية."), + }); + return false; + } + }, + [notify], + ); + + // ── Endpoint 3: remove a single permission (used from the detail modal) ───── + const removePermission = useCallback( + async (roleId: string, permissionId: string): Promise => { + try { + const token = getStoredToken(); + await roleService.removePermission(roleId, permissionId, token); + notify({ type: "success", message: "تم إزالة الصلاحية من الدور بنجاح." }); + return true; + } catch (err) { + notify({ + type: "error", + message: parseApiError(err, "تعذّر إزالة الصلاحية."), + }); + return false; + } + }, + [notify], + ); const handleSearch = useCallback((q: string) => { setSearch(q); @@ -233,7 +268,8 @@ export function useRoles() { createRole, updateRole, deleteRole, + assignPermission, + removePermission, notification, - }; -} +} \ No newline at end of file diff --git a/src/lib/apiError.ts b/src/lib/apiError.ts new file mode 100644 index 0000000..06f0d15 --- /dev/null +++ b/src/lib/apiError.ts @@ -0,0 +1,27 @@ +import { ApiErrorResponse } from "@/src/types/role"; + +/** + * Extracts a human-readable Arabic message from a backend error payload + * shaped like ApiErrorResponse (validation_failed or other error codes). + * Falls back to a generic message when the shape doesn't match. + */ +export function parseApiError(err: unknown, fallback: string): string { + const body = (err as { body?: unknown })?.body ?? err; + + if ( + body && + typeof body === "object" && + "error" in body && + (body as ApiErrorResponse).error?.details?.length + ) { + const { details } = (body as ApiErrorResponse).error; + // Surface the first field-level validation message + return details.map((d) => `${d.field}: ${d.code}`).join(" — "); + } + + if (body && typeof body === "object" && "message" in body) { + return String((body as { message: string }).message); + } + + return err instanceof Error ? err.message : fallback; +} \ No newline at end of file diff --git a/src/services/role.service.ts b/src/services/role.service.ts index 76e1a43..7446eb6 100644 --- a/src/services/role.service.ts +++ b/src/services/role.service.ts @@ -1,13 +1,14 @@ -import { ApiPaginatedResponse, PermissionsResponse, Role, RoleFormData, RoleResponse } from "../types/role"; +import { + ApiPaginatedResponse, + AssignPermissionResponse, + BulkAssignPermissionsResponse, + PermissionsResponse, + Role, + RoleFormData, + RoleResponse, +} from "../types/role"; import { get, post, put, del, patch } from "./api"; - - - - - - - /** Build query string for paginated role listing */ function buildRolesQuery(page: number, search: string): string { return `?page=${page}&limit=10${search ? `&search=${encodeURIComponent(search)}` : ""}`; @@ -24,13 +25,11 @@ export const roleService = { /** Create a new role */ create: (data: RoleFormData, token: string | null) => - post("role", { name: data.name, description: data.description }, token), - + post("role", { name: data.name, description: data.description }, token), /** Update role name/description */ update: (id: string, data: Partial, token: string | null) => - put(`role/${id}`, { name: data.name, description: data.description }, token), - + put(`role/${id}`, { name: data.name, description: data.description }, token), /** Soft-delete a role */ delete: (id: string, token: string | null) => @@ -40,5 +39,15 @@ export const roleService = { getPermissions: (token: string | null) => get("premission?limit=200", token), - -}; + /** POST /role/{id}/permissions — assign a single permission to a role */ + assignPermission: (roleId: string, permissionId: string, token: string | null) => + post(`role-permissions/${roleId}/permissions`, { permissionId }, token), + + /** PATCH /role/{id}/permissions/bulk — replace all permissions for a role */ + bulkAssignPermissions: (roleId: string, permissionIds: string[], token: string | null) => + patch(`role-permissions/${roleId}/permissions`, { permissionIds }, token), + + /** DELETE /role/{id}/permissions/{permissionId} — remove a single permission */ + removePermission: (roleId: string, permissionId: string, token: string | null) => + del(`role-permissions/${roleId}/permissions/${permissionId}`, token), +}; \ No newline at end of file diff --git a/src/types/role.ts b/src/types/role.ts index eacbaa7..1d980e1 100644 --- a/src/types/role.ts +++ b/src/types/role.ts @@ -33,11 +33,48 @@ export interface PermissionsResponse { }; } - //permissions export interface Permission { id: string; name: string; slug: string; module: string; +} + +// ── Permission mutation responses ─────────────────────────────────────────── + +export interface RolePermissionLink { + id: string; + roleId: string; + permissionId: string; +} + +export interface AssignPermissionResponse { + success: true; + message: string; + responseAt: string; + data: RolePermissionLink; +} + +export interface BulkAssignPermissionsResponse { + success: true; + message: string; + responseAt: string; + data: Role; +} + +export interface ApiErrorDetail { + field: string; + code: string; +} + +export interface ApiErrorResponse { + success: false; + message: string; + responseAt: string; + error: { + code: string; + path: string; + details: ApiErrorDetail[]; + }; } \ No newline at end of file diff --git a/src/validations/role.validator.ts b/src/validations/role.validator.ts index 3c40241..8b4f12a 100644 --- a/src/validations/role.validator.ts +++ b/src/validations/role.validator.ts @@ -1,5 +1,8 @@ import * as yup from "yup"; +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + export const createRoleSchema = yup.object({ name: yup .string() @@ -25,4 +28,35 @@ export const updateRoleSchema = yup.object({ permissionIds: yup.array(yup.string().required()).default([]), }); -export type RoleFormErrors = Partial>; \ No newline at end of file +export type RoleFormErrors = Partial>; + +// ── Mirrors backend rolePremisson.validators.js ──────────────────────────── + +const UUID_REGEX = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** POST /role/{id}/permissions */ +export const assignPermissionSchema = yup.object({ + permissionId: yup + .string() + .required("Permission ID is required") + .matches(UUID_REGEX, "Invalid Permission ID format"), +}); + +/** PATCH /role/{id}/permissions/bulk */ +export const bulkAssignPermissionSchema = yup.object({ + permissionIds: yup + .array( + yup + .string() + .matches(UUID_REGEX, "Each permission ID must be a valid UUID") + .required() + ) + .test("unique", "Permission IDs must be unique", function (arr) { + if (!arr) return true; + return new Set(arr).size === arr.length; + }) + .required(), +}); + +export type PermissionFormErrors = Partial>; \ No newline at end of file