add assign permission to role pages
This commit is contained in:
@@ -54,7 +54,6 @@ const navSections = [
|
|||||||
{ href: "/dashboard/clients", label: "Clients", icon: "ti-users-group", permission: "read-client" },
|
{ 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/branches", label: "Branches", icon: "ti-building", permission: "read-branch" },
|
||||||
{ href: "/dashboard/roles", label: "Roles", icon: "ti-shield", permission: "read-role" },
|
{ 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" },
|
{ href: "/dashboard/audit", label: "Audit", icon: "ti-clipboard-list", permission: "read-audit" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -181,12 +180,12 @@ export function Sidebar() {
|
|||||||
padding: "20px 12px",
|
padding: "20px 12px",
|
||||||
zIndex: 50,
|
zIndex: 50,
|
||||||
overflowY: "auto",
|
overflowY: "auto",
|
||||||
/* transform بيخفي/يظهر الـ drawer على sm */
|
|
||||||
transform: open ? "translateX(0)" : "translateX(100%)",
|
transform: open ? "translateX(0)" : "translateX(100%)",
|
||||||
transition: "transform 280ms cubic-bezier(0.4,0,0.2,1)",
|
transition: "transform 280ms cubic-bezier(0.4,0,0.2,1)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* زرار الإغلاق */}
|
{/*close button */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
return (
|
|
||||||
<div>page</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -25,6 +25,7 @@ export default function RolesPage() {
|
|||||||
page, search,
|
page, search,
|
||||||
setPage, handleSearch, clearError,
|
setPage, handleSearch, clearError,
|
||||||
createRole, updateRole, deleteRole,
|
createRole, updateRole, deleteRole,
|
||||||
|
assignPermission, removePermission,
|
||||||
notification,
|
notification,
|
||||||
} = useRoles();
|
} = useRoles();
|
||||||
|
|
||||||
@@ -54,7 +55,10 @@ export default function RolesPage() {
|
|||||||
{viewRoleId && (
|
{viewRoleId && (
|
||||||
<RoleDetailModal
|
<RoleDetailModal
|
||||||
roleId={viewRoleId}
|
roleId={viewRoleId}
|
||||||
|
permissions={permissions}
|
||||||
onClose={() => setViewRoleId(null)}
|
onClose={() => setViewRoleId(null)}
|
||||||
|
onAssign={assignPermission}
|
||||||
|
onRemove={removePermission}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
19
package-lock.json
generated
19
package-lock.json
generated
@@ -8,7 +8,7 @@
|
|||||||
"name": "logistic_client",
|
"name": "logistic_client",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.4.0",
|
"@hookform/resolvers": "^3.9.1",
|
||||||
"@tabler/icons-webfont": "^3.44.0",
|
"@tabler/icons-webfont": "^3.44.0",
|
||||||
"next": "16.2.7",
|
"next": "16.2.7",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
@@ -460,15 +460,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@hookform/resolvers": {
|
"node_modules/@hookform/resolvers": {
|
||||||
"version": "5.4.0",
|
"version": "3.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.9.1.tgz",
|
||||||
"integrity": "sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==",
|
"integrity": "sha512-ud2HqmGBM0P0IABqoskKWI6PEf6ZDDBZkFqe2Vnl+mTHCEHzr3ISjjZyCwTjC/qpL25JC9aIDkloQejvMeq0ug==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
|
||||||
"@standard-schema/utils": "^0.3.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react-hook-form": "^7.55.0"
|
"react-hook-form": "^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@humanfs/core": {
|
"node_modules/@humanfs/core": {
|
||||||
@@ -1344,12 +1341,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@standard-schema/utils": {
|
|
||||||
"version": "0.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
|
||||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@swc/helpers": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.15",
|
"version": "0.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.4.0",
|
"@hookform/resolvers": "^3.9.1",
|
||||||
"@tabler/icons-webfont": "^3.44.0",
|
"@tabler/icons-webfont": "^3.44.0",
|
||||||
"next": "16.2.7",
|
"next": "16.2.7",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import { useEffect, useState } from "react";
|
|||||||
import { Spinner } from "../UI";
|
import { Spinner } from "../UI";
|
||||||
import { getStoredToken } from "@/src/lib/auth";
|
import { getStoredToken } from "@/src/lib/auth";
|
||||||
import { roleService } from "@/src/services/role.service";
|
import { roleService } from "@/src/services/role.service";
|
||||||
import { Role } from "@/src/types/role";
|
import { Permission, Role } from "@/src/types/role";
|
||||||
|
|
||||||
|
|
||||||
interface RoleDetailModalProps {
|
interface RoleDetailModalProps {
|
||||||
roleId: string;
|
roleId: string;
|
||||||
onClose: () => void;
|
permissions: Permission[]; // full catalog, for the "add permission" select
|
||||||
|
onClose: () => void;
|
||||||
|
onAssign: (roleId: string, permissionId: string) => Promise<boolean>;
|
||||||
|
onRemove: (roleId: string, permissionId: string) => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DetailRow({ label, value }: { label: string; value?: string | null }) {
|
function DetailRow({ label, value }: { label: string; value?: string | null }) {
|
||||||
@@ -44,17 +46,33 @@ function StatusBadge({ active }: { active: boolean }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RoleDetailModal({ roleId, onClose }: RoleDetailModalProps) {
|
export function RoleDetailModal({ roleId, permissions, onClose, onAssign, onRemove }: RoleDetailModalProps) {
|
||||||
const [role, setRole] = useState<Role | null>(null);
|
const [role, setRole] = useState<Role | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// ── Inline permission mutation state ─────────────────────────────────────────
|
||||||
|
const [pendingPermId, setPendingPermId] = useState("");
|
||||||
|
const [mutating, setMutating] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||||
window.addEventListener("keydown", handler);
|
window.addEventListener("keydown", handler);
|
||||||
return () => window.removeEventListener("keydown", handler);
|
return () => window.removeEventListener("keydown", handler);
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
|
const loadRole = async () => {
|
||||||
|
try {
|
||||||
|
const token = getStoredToken();
|
||||||
|
const res = await roleService.getById(roleId, token);
|
||||||
|
setRole((res as unknown as { data: Role }).data);
|
||||||
|
} catch {
|
||||||
|
setError("تعذّر تحميل بيانات الدور. يرجى المحاولة لاحقاً.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -71,9 +89,34 @@ export function RoleDetailModal({ roleId, onClose }: RoleDetailModalProps) {
|
|||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [roleId]);
|
}, [roleId]);
|
||||||
|
|
||||||
|
// Endpoint 1: POST /role/{id}/permissions
|
||||||
|
const handleAssign = async () => {
|
||||||
|
if (!pendingPermId || mutating) return;
|
||||||
|
setMutating(true);
|
||||||
|
const ok = await onAssign(roleId, pendingPermId);
|
||||||
|
if (ok) {
|
||||||
|
setPendingPermId("");
|
||||||
|
await loadRole();
|
||||||
|
}
|
||||||
|
setMutating(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Endpoint 3: DELETE /role/{id}/permissions/{permissionId}
|
||||||
|
const handleRemove = async (permissionId: string) => {
|
||||||
|
if (mutating) return;
|
||||||
|
setMutating(true);
|
||||||
|
const ok = await onRemove(roleId, permissionId);
|
||||||
|
if (ok) await loadRole();
|
||||||
|
setMutating(false);
|
||||||
|
};
|
||||||
|
|
||||||
const fmt = (iso?: string | null) =>
|
const fmt = (iso?: string | null) =>
|
||||||
iso ? new Date(iso).toLocaleDateString("ar-SA", { year: "numeric", month: "long", day: "numeric" }) : null;
|
iso ? new Date(iso).toLocaleDateString("ar-SA", { year: "numeric", month: "long", day: "numeric" }) : null;
|
||||||
|
|
||||||
|
const assignablePermissions = permissions.filter(
|
||||||
|
(p) => !role?.permissions?.some((rp) => rp.permission.id === p.id),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="dialog" aria-modal="true" aria-labelledby="role-detail-title"
|
role="dialog" aria-modal="true" aria-labelledby="role-detail-title"
|
||||||
@@ -155,31 +198,77 @@ export function RoleDetailModal({ roleId, onClose }: RoleDetailModalProps) {
|
|||||||
{role.updatedAt && <DetailRow label="آخر تحديث" value={fmt(role.updatedAt)} />}
|
{role.updatedAt && <DetailRow label="آخر تحديث" value={fmt(role.updatedAt)} />}
|
||||||
|
|
||||||
{/* Permissions list */}
|
{/* Permissions list */}
|
||||||
{role.permissions && role.permissions.length > 0 && (
|
<div style={{ paddingTop: "0.75rem" }}>
|
||||||
<div style={{ paddingTop: "0.75rem" }}>
|
<p style={{ fontSize: 11, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.15em", color: "var(--color-text-muted)", margin: "0 0 10px" }}>
|
||||||
<p style={{ fontSize: 11, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.15em", color: "var(--color-text-muted)", margin: "0 0 10px" }}>
|
الصلاحيات ({role.permissions?.length ?? 0})
|
||||||
الصلاحيات ({role.permissions.length})
|
</p>
|
||||||
</p>
|
|
||||||
|
{/* Assign new permission — Endpoint 1 */}
|
||||||
|
{assignablePermissions.length > 0 && (
|
||||||
|
<div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
|
||||||
|
<select
|
||||||
|
value={pendingPermId}
|
||||||
|
onChange={(e) => setPendingPermId(e.target.value)}
|
||||||
|
disabled={mutating}
|
||||||
|
style={{
|
||||||
|
flex: 1, height: 36, borderRadius: "var(--radius-md)",
|
||||||
|
border: "1px solid var(--color-border)", fontSize: 12,
|
||||||
|
padding: "0 0.5rem", fontFamily: "var(--font-sans)",
|
||||||
|
color: "var(--color-text-primary)", background: "var(--color-surface)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">اختر صلاحية لإضافتها…</option>
|
||||||
|
{assignablePermissions.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>{p.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button" onClick={handleAssign} disabled={!pendingPermId || mutating}
|
||||||
|
style={{
|
||||||
|
height: 36, padding: "0 0.875rem", borderRadius: "var(--radius-md)",
|
||||||
|
border: "none", background: "var(--color-brand-600)", color: "#FFF",
|
||||||
|
fontSize: 12, fontWeight: 700,
|
||||||
|
cursor: pendingPermId && !mutating ? "pointer" : "not-allowed",
|
||||||
|
opacity: !pendingPermId || mutating ? 0.6 : 1,
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
display: "flex", alignItems: "center", gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mutating && <Spinner size="sm" className="text-white" />}
|
||||||
|
إضافة
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{role.permissions && role.permissions.length > 0 ? (
|
||||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.4rem" }}>
|
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.4rem" }}>
|
||||||
{role.permissions.map(({ permission }) => (
|
{role.permissions.map(({ permission }) => (
|
||||||
<span key={permission.id} style={{
|
<span key={permission.id} style={{
|
||||||
display: "inline-flex", alignItems: "center",
|
display: "inline-flex", alignItems: "center", gap: 6,
|
||||||
padding: "0.25rem 0.6rem", borderRadius: "var(--radius-md)",
|
padding: "0.25rem 0.4rem 0.25rem 0.6rem", borderRadius: "var(--radius-md)",
|
||||||
background: "#EFF6FF", border: "1px solid #BFDBFE",
|
background: "#EFF6FF", border: "1px solid #BFDBFE",
|
||||||
fontSize: 11, fontWeight: 600, color: "#1D4ED8",
|
fontSize: 11, fontWeight: 600, color: "#1D4ED8",
|
||||||
}}>
|
}}>
|
||||||
{permission.name}
|
{permission.name}
|
||||||
|
{/* Endpoint 3: remove this permission */}
|
||||||
|
<button
|
||||||
|
type="button" onClick={() => handleRemove(permission.id)} disabled={mutating}
|
||||||
|
aria-label={`إزالة ${permission.name}`}
|
||||||
|
style={{
|
||||||
|
background: "none", border: "none",
|
||||||
|
cursor: mutating ? "not-allowed" : "pointer",
|
||||||
|
color: "#1D4ED8", fontSize: 13, lineHeight: 1, padding: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
)}
|
|
||||||
|
|
||||||
{(!role.permissions || role.permissions.length === 0) && (
|
|
||||||
<div style={{ paddingTop: "0.75rem" }}>
|
|
||||||
<p style={{ fontSize: 13, color: "var(--color-text-hint)", fontStyle: "italic" }}>لا توجد صلاحيات مسندة لهذا الدور</p>
|
<p style={{ fontSize: 13, color: "var(--color-text-hint)", fontStyle: "italic" }}>لا توجد صلاحيات مسندة لهذا الدور</p>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ async function validate(data: RoleFormData, isNew: boolean): Promise<RoleFormErr
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Permission checkbox group ─────────────────────────────────────────────────
|
// ── Permission checkbox group ─────────────────────────────────────────────────
|
||||||
/*function PermissionGroup({
|
function PermissionGroup({
|
||||||
module, perms, selected, onToggle,
|
module, perms, selected, onToggle,
|
||||||
}: {
|
}: {
|
||||||
module: string; perms: Permission[]; selected: string[]; onToggle: (id: string) => void;
|
module: string; perms: Permission[]; selected: string[]; onToggle: (id: string) => void;
|
||||||
@@ -77,7 +77,7 @@ async function validate(data: RoleFormData, isNew: boolean): Promise<RoleFormErr
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}*/
|
}
|
||||||
|
|
||||||
// ── Props ─────────────────────────────────────────────────────────────────────
|
// ── Props ─────────────────────────────────────────────────────────────────────
|
||||||
interface RoleFormModalProps {
|
interface RoleFormModalProps {
|
||||||
@@ -225,7 +225,7 @@ export function RoleFormModal({ editRole, permissions, onClose, onSubmit }: Role
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
{/* Permissions */}
|
{/* Permissions */}
|
||||||
{/*{permissions.length > 0 && (
|
{permissions.length > 0 && (
|
||||||
<div dir="rtl">
|
<div dir="rtl">
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "0.75rem" }}>
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "0.75rem" }}>
|
||||||
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--color-text-secondary)" }}>
|
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--color-text-secondary)" }}>
|
||||||
@@ -258,7 +258,7 @@ export function RoleFormModal({ editRole, permissions, onClose, onSubmit }: Role
|
|||||||
{form.permissionIds.length} صلاحية محددة من أصل {permissions.length}
|
{form.permissionIds.length} صلاحية محددة من أصل {permissions.length}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}*/}
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end", paddingTop: "0.5rem", flexShrink: 0 }}>
|
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end", paddingTop: "0.5rem", flexShrink: 0 }}>
|
||||||
|
|||||||
@@ -3,13 +3,10 @@
|
|||||||
import { useCallback, useEffect, useReducer, useState } from "react";
|
import { useCallback, useEffect, useReducer, useState } from "react";
|
||||||
import { getStoredToken } from "@/src/lib/auth";
|
import { getStoredToken } from "@/src/lib/auth";
|
||||||
import { roleService } from "@/src/services/role.service";
|
import { roleService } from "@/src/services/role.service";
|
||||||
|
import { parseApiError } from "@/src/lib/apiError";
|
||||||
import { Notification } from "../types/notif";
|
import { Notification } from "../types/notif";
|
||||||
import { Permission, Role, RoleFormData } from "../types/role";
|
import { Permission, Role, RoleFormData } from "../types/role";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ── Table state / reducer ─────────────────────────────────────────────────────
|
// ── Table state / reducer ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface TableState {
|
interface TableState {
|
||||||
@@ -119,7 +116,6 @@ export function useRoles() {
|
|||||||
roleService
|
roleService
|
||||||
.getPermissions(token)
|
.getPermissions(token)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
//console.log("permissions response:", JSON.stringify(res, null, 2));
|
|
||||||
const raw = (
|
const raw = (
|
||||||
res as unknown as { data: { premissions: { data: Permission[] } } }
|
res as unknown as { data: { premissions: { data: Permission[] } } }
|
||||||
).data;
|
).data;
|
||||||
@@ -142,7 +138,13 @@ export function useRoles() {
|
|||||||
const res = await roleService.create(data, token);
|
const res = await roleService.create(data, token);
|
||||||
const role = (res as unknown as { data: Role }).data;
|
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);
|
await loadRoles(page, search);
|
||||||
notify({
|
notify({
|
||||||
type: "success",
|
type: "success",
|
||||||
@@ -152,7 +154,7 @@ export function useRoles() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
notify({
|
notify({
|
||||||
type: "error",
|
type: "error",
|
||||||
message: err instanceof Error ? err.message : "تعذّر إنشاء الدور.",
|
message: parseApiError(err, "تعذّر إنشاء الدور."),
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -164,32 +166,28 @@ export function useRoles() {
|
|||||||
async (
|
async (
|
||||||
id: string,
|
id: string,
|
||||||
data: RoleFormData,
|
data: RoleFormData,
|
||||||
currentPermIds: string[],
|
_currentPermIds: string[],
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
// Update basic fields
|
// Update basic fields
|
||||||
await roleService.update(id, data, token);
|
await roleService.update(id, data, token);
|
||||||
|
|
||||||
// Diff permissions: add new, remove removed
|
// Endpoint 2: replace the full permission set in one call rather
|
||||||
const toAdd = data.permissionIds.filter(
|
// than diffing add/remove client-side — the bulk endpoint already
|
||||||
(p) => !currentPermIds.includes(p),
|
// performs the replacement atomically on the backend.
|
||||||
);
|
await roleService.bulkAssignPermissions(id, data.permissionIds, token);
|
||||||
const toRemove = currentPermIds.filter(
|
|
||||||
(p) => !data.permissionIds.includes(p),
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
await loadRoles(page, search);
|
await loadRoles(page, search);
|
||||||
notify({
|
notify({
|
||||||
type: "success",
|
type: "success",
|
||||||
message: "تم تحديث الدور بنجاح.",
|
message: "تم تحديث الدور بنجاح.",
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notify({
|
notify({
|
||||||
type: "error",
|
type: "error",
|
||||||
message: err instanceof Error ? err.message : "تعذّر تحديث الدور.",
|
message: parseApiError(err, "تعذّر تحديث الدور."),
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -208,7 +206,7 @@ export function useRoles() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
notify({
|
notify({
|
||||||
type: "error",
|
type: "error",
|
||||||
message: err instanceof Error ? err.message : "تعذّر حذف الدور.",
|
message: parseApiError(err, "تعذّر حذف الدور."),
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -216,6 +214,43 @@ export function useRoles() {
|
|||||||
[notify],
|
[notify],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── Endpoint 1: assign a single permission (used from the detail modal) ─────
|
||||||
|
const assignPermission = useCallback(
|
||||||
|
async (roleId: string, permissionId: string): Promise<boolean> => {
|
||||||
|
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<boolean> => {
|
||||||
|
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) => {
|
const handleSearch = useCallback((q: string) => {
|
||||||
setSearch(q);
|
setSearch(q);
|
||||||
@@ -233,7 +268,8 @@ export function useRoles() {
|
|||||||
createRole,
|
createRole,
|
||||||
updateRole,
|
updateRole,
|
||||||
deleteRole,
|
deleteRole,
|
||||||
|
assignPermission,
|
||||||
|
removePermission,
|
||||||
notification,
|
notification,
|
||||||
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
27
src/lib/apiError.ts
Normal file
27
src/lib/apiError.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -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";
|
import { get, post, put, del, patch } from "./api";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/** Build query string for paginated role listing */
|
/** Build query string for paginated role listing */
|
||||||
function buildRolesQuery(page: number, search: string): string {
|
function buildRolesQuery(page: number, search: string): string {
|
||||||
return `?page=${page}&limit=10${search ? `&search=${encodeURIComponent(search)}` : ""}`;
|
return `?page=${page}&limit=10${search ? `&search=${encodeURIComponent(search)}` : ""}`;
|
||||||
@@ -24,13 +25,11 @@ export const roleService = {
|
|||||||
|
|
||||||
/** Create a new role */
|
/** Create a new role */
|
||||||
create: (data: RoleFormData, token: string | null) =>
|
create: (data: RoleFormData, token: string | null) =>
|
||||||
post<RoleResponse>("role", { name: data.name, description: data.description }, token),
|
post<RoleResponse>("role", { name: data.name, description: data.description }, token),
|
||||||
|
|
||||||
|
|
||||||
/** Update role name/description */
|
/** Update role name/description */
|
||||||
update: (id: string, data: Partial<RoleFormData>, token: string | null) =>
|
update: (id: string, data: Partial<RoleFormData>, token: string | null) =>
|
||||||
put<RoleResponse>(`role/${id}`, { name: data.name, description: data.description }, token),
|
put<RoleResponse>(`role/${id}`, { name: data.name, description: data.description }, token),
|
||||||
|
|
||||||
|
|
||||||
/** Soft-delete a role */
|
/** Soft-delete a role */
|
||||||
delete: (id: string, token: string | null) =>
|
delete: (id: string, token: string | null) =>
|
||||||
@@ -40,5 +39,15 @@ export const roleService = {
|
|||||||
getPermissions: (token: string | null) =>
|
getPermissions: (token: string | null) =>
|
||||||
get<PermissionsResponse>("premission?limit=200", token),
|
get<PermissionsResponse>("premission?limit=200", token),
|
||||||
|
|
||||||
|
/** POST /role/{id}/permissions — assign a single permission to a role */
|
||||||
};
|
assignPermission: (roleId: string, permissionId: string, token: string | null) =>
|
||||||
|
post<AssignPermissionResponse>(`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<BulkAssignPermissionsResponse>(`role-permissions/${roleId}/permissions`, { permissionIds }, token),
|
||||||
|
|
||||||
|
/** DELETE /role/{id}/permissions/{permissionId} — remove a single permission */
|
||||||
|
removePermission: (roleId: string, permissionId: string, token: string | null) =>
|
||||||
|
del<void>(`role-permissions/${roleId}/permissions/${permissionId}`, token),
|
||||||
|
};
|
||||||
@@ -33,11 +33,48 @@ export interface PermissionsResponse {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
//permissions
|
//permissions
|
||||||
export interface Permission {
|
export interface Permission {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
module: 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[];
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import * as yup from "yup";
|
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({
|
export const createRoleSchema = yup.object({
|
||||||
name: yup
|
name: yup
|
||||||
.string()
|
.string()
|
||||||
@@ -25,4 +28,35 @@ export const updateRoleSchema = yup.object({
|
|||||||
permissionIds: yup.array(yup.string().required()).default([]),
|
permissionIds: yup.array(yup.string().required()).default([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type RoleFormErrors = Partial<Record<"name" | "description", string>>;
|
export type RoleFormErrors = Partial<Record<"name" | "description", string>>;
|
||||||
|
|
||||||
|
// ── 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<Record<"permissionId" | "permissionIds", string>>;
|
||||||
Reference in New Issue
Block a user