Task #89: Permissions picker for roles in admin dashboard
Original task: let admins assign permissions to roles from the dashboard.
Changes:
- lib/api-spec/openapi.yaml: added Permission and ReplaceRolePermissionsBody
schemas; added GET /permissions, expanded GET /roles/{id}/permissions to
return full Permission objects, and added admin-guarded
PUT /roles/{id}/permissions. Ran codegen to refresh generated hooks/zod.
- artifacts/api-server/src/routes/roles.ts:
* Added a single isSystemRole helper and PROTECTED_ROLE_NAMES set.
* Added serializePermission + GET /permissions.
* Expanded GET /roles/:id/permissions (404 on missing role, full
Permission objects).
* New PUT /roles/:id/permissions: blocks system/protected roles
(400 with code "system_role_permissions"), rejects non-positive /
non-integer permissionIds with 400, validates all ids exist (404),
deletes+inserts atomically in a transaction, and notifies role
holders via emitAppsChangedToRoleHolders.
* Hardened legacy POST /roles/:id/permissions and
DELETE /roles/:id/permissions/:permissionId so they also reject
system-role mutations with the same code, closing the bypass that
the new endpoint was meant to prevent.
* Re-used isSystemRole in PATCH/DELETE role flows.
- artifacts/tx-os/src/pages/admin.tsx: added a permissions checkbox list
inside the role editor dialog, disabled (read-only) for system /
protected roles with a hint. Save flow runs updateRole then
replaceRolePermissions only when the set actually changed and the role
is non-system. Front-end ROLES_PROTECTED_NAMES set mirrors the
back-end fallback so admin/user/order_receiver are recognised even
when the legacy DB column is 0 (also gates the role-card system badge
and delete button).
- artifacts/tx-os/src/locales/en.json + ar.json: new translation keys
for the permissions picker, system-role read-only hint, empty list,
and error toasts.
Verification: tx-os and api-server typecheck clean. End-to-end test
passes: admin login → edit a created role → toggle/save permissions →
re-open and confirm round-trip → admin role dialog shows disabled
checkboxes and read-only hint → PUT on admin role returns 400
"system_role_permissions". curl regression checks: legacy POST/DELETE
on admin role return the same 400/code; PUT rejects float / negative /
string ids with 400.
Replit-Task-Id: 74180397-afdf-4489-976a-a6fe099684bd
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { Router, type IRouter } from "express";
|
import { Router, type IRouter } from "express";
|
||||||
import { eq, sql } from "drizzle-orm";
|
import { eq, inArray, sql } from "drizzle-orm";
|
||||||
import { db } from "@workspace/db";
|
import { db } from "@workspace/db";
|
||||||
import {
|
import {
|
||||||
rolesTable,
|
rolesTable,
|
||||||
@@ -9,11 +9,30 @@ import {
|
|||||||
permissionsTable,
|
permissionsTable,
|
||||||
} from "@workspace/db";
|
} from "@workspace/db";
|
||||||
import { requireAdmin } from "../middlewares/auth";
|
import { requireAdmin } from "../middlewares/auth";
|
||||||
import { CreateRoleBody, UpdateRoleBody } from "@workspace/api-zod";
|
import {
|
||||||
|
CreateRoleBody,
|
||||||
|
UpdateRoleBody,
|
||||||
|
ReplaceRolePermissionsBody,
|
||||||
|
} from "@workspace/api-zod";
|
||||||
import { emitAppsChangedToRoleHolders } from "../lib/realtime.js";
|
import { emitAppsChangedToRoleHolders } from "../lib/realtime.js";
|
||||||
|
|
||||||
const router: IRouter = Router();
|
const router: IRouter = Router();
|
||||||
|
|
||||||
|
const PROTECTED_ROLE_NAMES = new Set(["admin", "user", "order_receiver"]);
|
||||||
|
|
||||||
|
function isSystemRole(role: { isSystem: number | boolean; name: string }): boolean {
|
||||||
|
return Boolean(role.isSystem) || PROTECTED_ROLE_NAMES.has(role.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializePermission(p: typeof permissionsTable.$inferSelect) {
|
||||||
|
return {
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
descriptionAr: p.descriptionAr,
|
||||||
|
descriptionEn: p.descriptionEn,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function serializeRole(r: typeof rolesTable.$inferSelect) {
|
function serializeRole(r: typeof rolesTable.$inferSelect) {
|
||||||
return {
|
return {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
@@ -30,6 +49,11 @@ router.get("/roles", requireAdmin, async (_req, res): Promise<void> => {
|
|||||||
res.json(rows.map(serializeRole));
|
res.json(rows.map(serializeRole));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get("/permissions", requireAdmin, async (_req, res): Promise<void> => {
|
||||||
|
const rows = await db.select().from(permissionsTable).orderBy(permissionsTable.name);
|
||||||
|
res.json(rows.map(serializePermission));
|
||||||
|
});
|
||||||
|
|
||||||
router.post("/roles", requireAdmin, async (req, res): Promise<void> => {
|
router.post("/roles", requireAdmin, async (req, res): Promise<void> => {
|
||||||
const parsed = CreateRoleBody.safeParse(req.body);
|
const parsed = CreateRoleBody.safeParse(req.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@@ -80,8 +104,7 @@ router.patch("/roles/:id", requireAdmin, async (req, res): Promise<void> => {
|
|||||||
if (parsed.data.name !== undefined) {
|
if (parsed.data.name !== undefined) {
|
||||||
const name = parsed.data.name.trim();
|
const name = parsed.data.name.trim();
|
||||||
if (name !== existing.name) {
|
if (name !== existing.name) {
|
||||||
const PROTECTED_NAMES = new Set(["admin", "user", "order_receiver"]);
|
if (isSystemRole(existing)) {
|
||||||
if (existing.isSystem || PROTECTED_NAMES.has(existing.name)) {
|
|
||||||
res.status(400).json({ error: "Cannot rename a system role", code: "system_role_rename" });
|
res.status(400).json({ error: "Cannot rename a system role", code: "system_role_rename" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -122,8 +145,7 @@ router.delete("/roles/:id", requireAdmin, async (req, res): Promise<void> => {
|
|||||||
res.status(404).json({ error: "Role not found" });
|
res.status(404).json({ error: "Role not found" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const PROTECTED_NAMES = new Set(["admin", "user", "order_receiver"]);
|
if (isSystemRole(existing)) {
|
||||||
if (existing.isSystem || PROTECTED_NAMES.has(existing.name)) {
|
|
||||||
res.status(400).json({ error: "Cannot delete a system role" });
|
res.status(400).json({ error: "Cannot delete a system role" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -158,11 +180,82 @@ router.get("/roles/:id/permissions", requireAdmin, async (req, res): Promise<voi
|
|||||||
res.status(400).json({ error: "Invalid id" });
|
res.status(400).json({ error: "Invalid id" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const [role] = await db.select({ id: rolesTable.id }).from(rolesTable).where(eq(rolesTable.id, id));
|
||||||
|
if (!role) {
|
||||||
|
res.status(404).json({ error: "Role not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({ id: permissionsTable.id, name: permissionsTable.name })
|
.select({
|
||||||
|
id: permissionsTable.id,
|
||||||
|
name: permissionsTable.name,
|
||||||
|
descriptionAr: permissionsTable.descriptionAr,
|
||||||
|
descriptionEn: permissionsTable.descriptionEn,
|
||||||
|
})
|
||||||
.from(rolePermissionsTable)
|
.from(rolePermissionsTable)
|
||||||
.innerJoin(permissionsTable, eq(rolePermissionsTable.permissionId, permissionsTable.id))
|
.innerJoin(permissionsTable, eq(rolePermissionsTable.permissionId, permissionsTable.id))
|
||||||
.where(eq(rolePermissionsTable.roleId, id));
|
.where(eq(rolePermissionsTable.roleId, id))
|
||||||
|
.orderBy(permissionsTable.name);
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put("/roles/:id/permissions", requireAdmin, async (req, res): Promise<void> => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
if (!Number.isInteger(id)) {
|
||||||
|
res.status(400).json({ error: "Invalid id" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = ReplaceRolePermissionsBody.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: parsed.error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [role] = await db.select().from(rolesTable).where(eq(rolesTable.id, id));
|
||||||
|
if (!role) {
|
||||||
|
res.status(404).json({ error: "Role not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isSystemRole(role)) {
|
||||||
|
res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "Cannot modify a system role's permissions", code: "system_role_permissions" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!parsed.data.permissionIds.every((n) => Number.isInteger(n) && n > 0)) {
|
||||||
|
res.status(400).json({ error: "permissionIds must be positive integers" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const requestedIds = Array.from(new Set(parsed.data.permissionIds));
|
||||||
|
if (requestedIds.length > 0) {
|
||||||
|
const found = await db
|
||||||
|
.select({ id: permissionsTable.id })
|
||||||
|
.from(permissionsTable)
|
||||||
|
.where(inArray(permissionsTable.id, requestedIds));
|
||||||
|
if (found.length !== requestedIds.length) {
|
||||||
|
res.status(404).json({ error: "Permission not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
await tx.delete(rolePermissionsTable).where(eq(rolePermissionsTable.roleId, id));
|
||||||
|
if (requestedIds.length > 0) {
|
||||||
|
await tx
|
||||||
|
.insert(rolePermissionsTable)
|
||||||
|
.values(requestedIds.map((permissionId) => ({ roleId: id, permissionId })));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await emitAppsChangedToRoleHolders(id);
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: permissionsTable.id,
|
||||||
|
name: permissionsTable.name,
|
||||||
|
descriptionAr: permissionsTable.descriptionAr,
|
||||||
|
descriptionEn: permissionsTable.descriptionEn,
|
||||||
|
})
|
||||||
|
.from(rolePermissionsTable)
|
||||||
|
.innerJoin(permissionsTable, eq(rolePermissionsTable.permissionId, permissionsTable.id))
|
||||||
|
.where(eq(rolePermissionsTable.roleId, id))
|
||||||
|
.orderBy(permissionsTable.name);
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -177,11 +270,17 @@ router.post("/roles/:id/permissions", requireAdmin, async (req, res): Promise<vo
|
|||||||
res.status(400).json({ error: "permissionId is required" });
|
res.status(400).json({ error: "permissionId is required" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const [role] = await db.select({ id: rolesTable.id }).from(rolesTable).where(eq(rolesTable.id, id));
|
const [role] = await db.select().from(rolesTable).where(eq(rolesTable.id, id));
|
||||||
if (!role) {
|
if (!role) {
|
||||||
res.status(404).json({ error: "Role not found" });
|
res.status(404).json({ error: "Role not found" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isSystemRole(role)) {
|
||||||
|
res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "Cannot modify a system role's permissions", code: "system_role_permissions" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const [perm] = await db.select({ id: permissionsTable.id }).from(permissionsTable).where(eq(permissionsTable.id, permissionId));
|
const [perm] = await db.select({ id: permissionsTable.id }).from(permissionsTable).where(eq(permissionsTable.id, permissionId));
|
||||||
if (!perm) {
|
if (!perm) {
|
||||||
res.status(404).json({ error: "Permission not found" });
|
res.status(404).json({ error: "Permission not found" });
|
||||||
@@ -205,6 +304,17 @@ router.delete("/roles/:id/permissions/:permissionId", requireAdmin, async (req,
|
|||||||
res.status(400).json({ error: "Invalid id" });
|
res.status(400).json({ error: "Invalid id" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const [role] = await db.select().from(rolesTable).where(eq(rolesTable.id, id));
|
||||||
|
if (!role) {
|
||||||
|
res.status(404).json({ error: "Role not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isSystemRole(role)) {
|
||||||
|
res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "Cannot modify a system role's permissions", code: "system_role_permissions" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const deleted = await db
|
const deleted = await db
|
||||||
.delete(rolePermissionsTable)
|
.delete(rolePermissionsTable)
|
||||||
.where(
|
.where(
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
@@ -438,7 +438,13 @@
|
|||||||
"groupsCount": "{{count}} مجموعة",
|
"groupsCount": "{{count}} مجموعة",
|
||||||
"errorNameTaken": "يوجد دور بنفس الاسم.",
|
"errorNameTaken": "يوجد دور بنفس الاسم.",
|
||||||
"errorInvalidName": "يجب أن يبدأ اسم الدور بحرف ويحتوي على حروف وأرقام وشرطات سفلية فقط.",
|
"errorInvalidName": "يجب أن يبدأ اسم الدور بحرف ويحتوي على حروف وأرقام وشرطات سفلية فقط.",
|
||||||
"errorSystemRename": "لا يمكن تغيير اسم الأدوار النظامية."
|
"errorSystemRename": "لا يمكن تغيير اسم الأدوار النظامية.",
|
||||||
|
"permissions": "الصلاحيات",
|
||||||
|
"permissionsHint": "اختر الصلاحيات التي يمنحها هذا الدور.",
|
||||||
|
"permissionsSystemHint": "صلاحيات الأدوار النظامية للقراءة فقط.",
|
||||||
|
"permissionsEmpty": "لا توجد صلاحيات معرّفة.",
|
||||||
|
"errorSystemPermissions": "لا يمكن تغيير صلاحيات الأدوار النظامية.",
|
||||||
|
"errorPermissionsSave": "تعذّر حفظ صلاحيات الدور."
|
||||||
},
|
},
|
||||||
"groups": {
|
"groups": {
|
||||||
"searchPlaceholder": "ابحث في المجموعات...",
|
"searchPlaceholder": "ابحث في المجموعات...",
|
||||||
|
|||||||
@@ -435,7 +435,13 @@
|
|||||||
"groupsCount": "{{count}} groups",
|
"groupsCount": "{{count}} groups",
|
||||||
"errorNameTaken": "A role with that name already exists.",
|
"errorNameTaken": "A role with that name already exists.",
|
||||||
"errorInvalidName": "Role name must start with a letter and contain only letters, numbers, or underscores.",
|
"errorInvalidName": "Role name must start with a letter and contain only letters, numbers, or underscores.",
|
||||||
"errorSystemRename": "System role names cannot be changed."
|
"errorSystemRename": "System role names cannot be changed.",
|
||||||
|
"permissions": "Permissions",
|
||||||
|
"permissionsHint": "Select which permissions this role grants.",
|
||||||
|
"permissionsSystemHint": "System role permissions are read-only.",
|
||||||
|
"permissionsEmpty": "No permissions are defined.",
|
||||||
|
"errorSystemPermissions": "System role permissions cannot be changed.",
|
||||||
|
"errorPermissionsSave": "Could not save the role's permissions."
|
||||||
},
|
},
|
||||||
"groups": {
|
"groups": {
|
||||||
"searchPlaceholder": "Search groups...",
|
"searchPlaceholder": "Search groups...",
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ import {
|
|||||||
useCreateRole,
|
useCreateRole,
|
||||||
useUpdateRole,
|
useUpdateRole,
|
||||||
useDeleteRole,
|
useDeleteRole,
|
||||||
|
useListPermissions,
|
||||||
|
getListPermissionsQueryKey,
|
||||||
|
useGetRolePermissions,
|
||||||
|
getGetRolePermissionsQueryKey,
|
||||||
|
useReplaceRolePermissions,
|
||||||
useListAuditLogs,
|
useListAuditLogs,
|
||||||
getListAuditLogsQueryKey,
|
getListAuditLogsQueryKey,
|
||||||
ApiError,
|
ApiError,
|
||||||
@@ -3150,23 +3155,50 @@ function GroupDetailEditor({ groupId, onClose, onSaved }: { groupId: number; onC
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ROLES_PROTECTED_NAMES = new Set(["admin", "user", "order_receiver"]);
|
||||||
|
|
||||||
function RolesPanel() {
|
function RolesPanel() {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { data: roles } = useListRoles({ query: { queryKey: getListRolesQueryKey() } });
|
const { data: roles } = useListRoles({ query: { queryKey: getListRolesQueryKey() } });
|
||||||
|
const { data: allPermissions } = useListPermissions({
|
||||||
|
query: { queryKey: getListPermissionsQueryKey() },
|
||||||
|
});
|
||||||
const createRole = useCreateRole();
|
const createRole = useCreateRole();
|
||||||
const updateRole = useUpdateRole();
|
const updateRole = useUpdateRole();
|
||||||
const deleteRole = useDeleteRole();
|
const deleteRole = useDeleteRole();
|
||||||
|
const replaceRolePermissions = useReplaceRolePermissions();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [createForm, setCreateForm] = useState({ name: "", descriptionAr: "", descriptionEn: "" });
|
const [createForm, setCreateForm] = useState({ name: "", descriptionAr: "", descriptionEn: "" });
|
||||||
const [editingId, setEditingId] = useState<number | null>(null);
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
const [editingIsSystem, setEditingIsSystem] = useState(false);
|
const [editingIsSystem, setEditingIsSystem] = useState(false);
|
||||||
const [editForm, setEditForm] = useState({ name: "", descriptionAr: "", descriptionEn: "" });
|
const [editForm, setEditForm] = useState({ name: "", descriptionAr: "", descriptionEn: "" });
|
||||||
|
const [editPermissionIds, setEditPermissionIds] = useState<Set<number>>(new Set());
|
||||||
|
const [initialPermissionIds, setInitialPermissionIds] = useState<Set<number>>(new Set());
|
||||||
const [deleteTarget, setDeleteTarget] = useState<{ id: number; name: string } | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<{ id: number; name: string } | null>(null);
|
||||||
const [deleteConflict, setDeleteConflict] = useState<{ userCount: number; groupCount: number } | null>(null);
|
const [deleteConflict, setDeleteConflict] = useState<{ userCount: number; groupCount: number } | null>(null);
|
||||||
|
|
||||||
|
const editingPermissionsId = editingId ?? 0;
|
||||||
|
const { data: editingRolePermissions, isLoading: editingPermsLoading } = useGetRolePermissions(
|
||||||
|
editingPermissionsId,
|
||||||
|
{
|
||||||
|
query: {
|
||||||
|
queryKey: getGetRolePermissionsQueryKey(editingPermissionsId),
|
||||||
|
enabled: editingId != null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editingId == null) return;
|
||||||
|
if (!editingRolePermissions) return;
|
||||||
|
const ids = new Set(editingRolePermissions.map((p) => p.id));
|
||||||
|
setEditPermissionIds(ids);
|
||||||
|
setInitialPermissionIds(ids);
|
||||||
|
}, [editingId, editingRolePermissions]);
|
||||||
|
|
||||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: getListRolesQueryKey() });
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: getListRolesQueryKey() });
|
||||||
|
|
||||||
const visibleRoles = (roles ?? []).filter((r) => {
|
const visibleRoles = (roles ?? []).filter((r) => {
|
||||||
@@ -3220,7 +3252,7 @@ function RolesPanel() {
|
|||||||
descriptionEn?: string | null;
|
descriptionEn?: string | null;
|
||||||
}) => {
|
}) => {
|
||||||
setEditingId(role.id);
|
setEditingId(role.id);
|
||||||
setEditingIsSystem(role.isSystem);
|
setEditingIsSystem(role.isSystem || ROLES_PROTECTED_NAMES.has(role.name));
|
||||||
setEditForm({
|
setEditForm({
|
||||||
name: role.name,
|
name: role.name,
|
||||||
descriptionAr: role.descriptionAr ?? "",
|
descriptionAr: role.descriptionAr ?? "",
|
||||||
@@ -3228,13 +3260,66 @@ function RolesPanel() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const closeEditDialog = () => {
|
||||||
|
setEditingId(null);
|
||||||
|
setEditPermissionIds(new Set());
|
||||||
|
setInitialPermissionIds(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
const permissionsChanged = (() => {
|
||||||
|
if (editPermissionIds.size !== initialPermissionIds.size) return true;
|
||||||
|
for (const id of editPermissionIds) {
|
||||||
|
if (!initialPermissionIds.has(id)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
})();
|
||||||
|
|
||||||
const handleEdit = () => {
|
const handleEdit = () => {
|
||||||
if (editingId == null) return;
|
if (editingId == null) return;
|
||||||
|
const editingRoleId = editingId;
|
||||||
const name = editForm.name.trim();
|
const name = editForm.name.trim();
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
|
|
||||||
|
const savePermissionsThenClose = () => {
|
||||||
|
if (editingIsSystem || !permissionsChanged) {
|
||||||
|
invalidate();
|
||||||
|
closeEditDialog();
|
||||||
|
toast({ title: t("common.success") });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
replaceRolePermissions.mutate(
|
||||||
|
{
|
||||||
|
id: editingRoleId,
|
||||||
|
data: { permissionIds: Array.from(editPermissionIds) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidate();
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: getGetRolePermissionsQueryKey(editingRoleId),
|
||||||
|
});
|
||||||
|
closeEditDialog();
|
||||||
|
toast({ title: t("common.success") });
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
if (err instanceof ApiError && err.status === 400) {
|
||||||
|
const code = (err.data as { code?: string } | null)?.code;
|
||||||
|
const key =
|
||||||
|
code === "system_role_permissions"
|
||||||
|
? "admin.roles.errorSystemPermissions"
|
||||||
|
: "admin.roles.errorPermissionsSave";
|
||||||
|
toast({ title: t(key), variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast({ title: t("admin.roles.errorPermissionsSave"), variant: "destructive" });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
updateRole.mutate(
|
updateRole.mutate(
|
||||||
{
|
{
|
||||||
id: editingId,
|
id: editingRoleId,
|
||||||
data: {
|
data: {
|
||||||
...(editingIsSystem ? {} : { name }),
|
...(editingIsSystem ? {} : { name }),
|
||||||
descriptionAr: editForm.descriptionAr.trim() || null,
|
descriptionAr: editForm.descriptionAr.trim() || null,
|
||||||
@@ -3243,9 +3328,7 @@ function RolesPanel() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
invalidate();
|
savePermissionsThenClose();
|
||||||
setEditingId(null);
|
|
||||||
toast({ title: t("common.success") });
|
|
||||||
},
|
},
|
||||||
onError: (err: unknown) => {
|
onError: (err: unknown) => {
|
||||||
if (err instanceof ApiError && err.status === 409) {
|
if (err instanceof ApiError && err.status === 409) {
|
||||||
@@ -3264,6 +3347,16 @@ function RolesPanel() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const togglePermission = (id: number) => {
|
||||||
|
if (editingIsSystem) return;
|
||||||
|
setEditPermissionIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
if (!deleteTarget) return;
|
if (!deleteTarget) return;
|
||||||
deleteRole.mutate(
|
deleteRole.mutate(
|
||||||
@@ -3319,7 +3412,7 @@ function RolesPanel() {
|
|||||||
<div className="font-semibold text-foreground flex items-center gap-2">
|
<div className="font-semibold text-foreground flex items-center gap-2">
|
||||||
<KeyRound size={16} className="text-primary" />
|
<KeyRound size={16} className="text-primary" />
|
||||||
<span className="truncate">{r.name}</span>
|
<span className="truncate">{r.name}</span>
|
||||||
{r.isSystem && (
|
{(r.isSystem || ROLES_PROTECTED_NAMES.has(r.name)) && (
|
||||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-100 text-amber-700 uppercase tracking-wider">
|
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-100 text-amber-700 uppercase tracking-wider">
|
||||||
{t("admin.roles.system")}
|
{t("admin.roles.system")}
|
||||||
</span>
|
</span>
|
||||||
@@ -3345,7 +3438,7 @@ function RolesPanel() {
|
|||||||
>
|
>
|
||||||
<Pencil size={14} />
|
<Pencil size={14} />
|
||||||
</button>
|
</button>
|
||||||
{!r.isSystem && (
|
{!(r.isSystem || ROLES_PROTECTED_NAMES.has(r.name)) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDeleteConflict(null);
|
setDeleteConflict(null);
|
||||||
@@ -3423,7 +3516,10 @@ function RolesPanel() {
|
|||||||
|
|
||||||
{editingId != null && (
|
{editingId != null && (
|
||||||
<div className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
<div className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||||
<div className="glass-panel rounded-3xl p-6 w-full max-w-sm space-y-3" data-testid="edit-role-dialog">
|
<div
|
||||||
|
className="glass-panel rounded-3xl p-6 w-full max-w-md space-y-3 max-h-[90vh] overflow-y-auto"
|
||||||
|
data-testid="edit-role-dialog"
|
||||||
|
>
|
||||||
<h2 className="font-semibold text-foreground">{t("admin.roles.editRole")}</h2>
|
<h2 className="font-semibold text-foreground">{t("admin.roles.editRole")}</h2>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>{t("admin.roles.name")}</Label>
|
<Label>{t("admin.roles.name")}</Label>
|
||||||
@@ -3459,17 +3555,82 @@ function RolesPanel() {
|
|||||||
data-testid="role-edit-desc-en"
|
data-testid="role-edit-desc-en"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-1 pt-2">
|
||||||
|
<Label>{t("admin.roles.permissions")}</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{editingIsSystem
|
||||||
|
? t("admin.roles.permissionsSystemHint")
|
||||||
|
: t("admin.roles.permissionsHint")}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
className="border border-slate-200 rounded-xl bg-white/60 max-h-56 overflow-y-auto divide-y divide-slate-100"
|
||||||
|
data-testid="role-permissions-list"
|
||||||
|
>
|
||||||
|
{editingPermsLoading || !allPermissions ? (
|
||||||
|
<div className="flex justify-center py-6 text-muted-foreground">
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : allPermissions.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground p-3 text-center">
|
||||||
|
{t("admin.roles.permissionsEmpty")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
allPermissions.map((p) => {
|
||||||
|
const checked = editPermissionIds.has(p.id);
|
||||||
|
const desc =
|
||||||
|
i18n.language === "ar"
|
||||||
|
? p.descriptionAr ?? p.descriptionEn
|
||||||
|
: p.descriptionEn ?? p.descriptionAr;
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={p.id}
|
||||||
|
className={cn(
|
||||||
|
"flex items-start gap-2 p-2.5 text-sm",
|
||||||
|
editingIsSystem
|
||||||
|
? "cursor-not-allowed opacity-80"
|
||||||
|
: "cursor-pointer hover:bg-slate-50",
|
||||||
|
)}
|
||||||
|
data-testid={`role-perm-row-${p.id}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-0.5"
|
||||||
|
checked={checked}
|
||||||
|
disabled={editingIsSystem}
|
||||||
|
onChange={() => togglePermission(p.id)}
|
||||||
|
data-testid={`role-perm-checkbox-${p.id}`}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="font-mono text-xs text-foreground">{p.name}</div>
|
||||||
|
{desc && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">{desc}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex gap-2 pt-2">
|
<div className="flex gap-2 pt-2">
|
||||||
<Button variant="outline" className="flex-1" onClick={() => setEditingId(null)}>
|
<Button variant="outline" className="flex-1" onClick={closeEditDialog}>
|
||||||
{t("common.cancel")}
|
{t("common.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
disabled={!editForm.name.trim() || updateRole.isPending}
|
disabled={
|
||||||
|
!editForm.name.trim() ||
|
||||||
|
updateRole.isPending ||
|
||||||
|
replaceRolePermissions.isPending
|
||||||
|
}
|
||||||
onClick={handleEdit}
|
onClick={handleEdit}
|
||||||
data-testid="edit-role-submit"
|
data-testid="edit-role-submit"
|
||||||
>
|
>
|
||||||
{updateRole.isPending ? <Loader2 size={14} className="animate-spin" /> : t("common.save")}
|
{updateRole.isPending || replaceRolePermissions.isPending ? (
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
t("common.save")
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -204,6 +204,20 @@ export interface RoleDeletionConflict {
|
|||||||
groupCount: number;
|
groupCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Permission {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
/** @nullable */
|
||||||
|
descriptionAr?: string | null;
|
||||||
|
/** @nullable */
|
||||||
|
descriptionEn?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplaceRolePermissionsBody {
|
||||||
|
/** Full set of permission IDs the role should grant. Existing assignments not present here are removed. */
|
||||||
|
permissionIds: number[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface Group {
|
export interface Group {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -58,7 +58,9 @@ import type {
|
|||||||
LoginBody,
|
LoginBody,
|
||||||
MessageWithSender,
|
MessageWithSender,
|
||||||
Notification,
|
Notification,
|
||||||
|
Permission,
|
||||||
RegisterBody,
|
RegisterBody,
|
||||||
|
ReplaceRolePermissionsBody,
|
||||||
RequestUploadUrlBody,
|
RequestUploadUrlBody,
|
||||||
RequestUploadUrlResponse,
|
RequestUploadUrlResponse,
|
||||||
ResetPasswordBody,
|
ResetPasswordBody,
|
||||||
@@ -4729,6 +4731,81 @@ export const useRemoveUserRole = <
|
|||||||
return useMutation(getRemoveUserRoleMutationOptions(options));
|
return useMutation(getRemoveUserRoleMutationOptions(options));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary List all available permissions (admin)
|
||||||
|
*/
|
||||||
|
export const getListPermissionsUrl = () => {
|
||||||
|
return `/api/permissions`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listPermissions = async (
|
||||||
|
options?: RequestInit,
|
||||||
|
): Promise<Permission[]> => {
|
||||||
|
return customFetch<Permission[]>(getListPermissionsUrl(), {
|
||||||
|
...options,
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getListPermissionsQueryKey = () => {
|
||||||
|
return [`/api/permissions`] as const;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getListPermissionsQueryOptions = <
|
||||||
|
TData = Awaited<ReturnType<typeof listPermissions>>,
|
||||||
|
TError = ErrorType<ErrorResponse>,
|
||||||
|
>(options?: {
|
||||||
|
query?: UseQueryOptions<
|
||||||
|
Awaited<ReturnType<typeof listPermissions>>,
|
||||||
|
TError,
|
||||||
|
TData
|
||||||
|
>;
|
||||||
|
request?: SecondParameter<typeof customFetch>;
|
||||||
|
}) => {
|
||||||
|
const { query: queryOptions, request: requestOptions } = options ?? {};
|
||||||
|
|
||||||
|
const queryKey = queryOptions?.queryKey ?? getListPermissionsQueryKey();
|
||||||
|
|
||||||
|
const queryFn: QueryFunction<Awaited<ReturnType<typeof listPermissions>>> = ({
|
||||||
|
signal,
|
||||||
|
}) => listPermissions({ signal, ...requestOptions });
|
||||||
|
|
||||||
|
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||||
|
Awaited<ReturnType<typeof listPermissions>>,
|
||||||
|
TError,
|
||||||
|
TData
|
||||||
|
> & { queryKey: QueryKey };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListPermissionsQueryResult = NonNullable<
|
||||||
|
Awaited<ReturnType<typeof listPermissions>>
|
||||||
|
>;
|
||||||
|
export type ListPermissionsQueryError = ErrorType<ErrorResponse>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary List all available permissions (admin)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function useListPermissions<
|
||||||
|
TData = Awaited<ReturnType<typeof listPermissions>>,
|
||||||
|
TError = ErrorType<ErrorResponse>,
|
||||||
|
>(options?: {
|
||||||
|
query?: UseQueryOptions<
|
||||||
|
Awaited<ReturnType<typeof listPermissions>>,
|
||||||
|
TError,
|
||||||
|
TData
|
||||||
|
>;
|
||||||
|
request?: SecondParameter<typeof customFetch>;
|
||||||
|
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||||
|
const queryOptions = getListPermissionsQueryOptions(options);
|
||||||
|
|
||||||
|
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||||
|
queryKey: QueryKey;
|
||||||
|
};
|
||||||
|
|
||||||
|
return { ...query, queryKey: queryOptions.queryKey };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary List roles (admin)
|
* @summary List roles (admin)
|
||||||
*/
|
*/
|
||||||
@@ -5053,6 +5130,181 @@ export const useDeleteRole = <
|
|||||||
return useMutation(getDeleteRoleMutationOptions(options));
|
return useMutation(getDeleteRoleMutationOptions(options));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary List permissions assigned to a role (admin)
|
||||||
|
*/
|
||||||
|
export const getGetRolePermissionsUrl = (id: number) => {
|
||||||
|
return `/api/roles/${id}/permissions`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getRolePermissions = async (
|
||||||
|
id: number,
|
||||||
|
options?: RequestInit,
|
||||||
|
): Promise<Permission[]> => {
|
||||||
|
return customFetch<Permission[]>(getGetRolePermissionsUrl(id), {
|
||||||
|
...options,
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getGetRolePermissionsQueryKey = (id: number) => {
|
||||||
|
return [`/api/roles/${id}/permissions`] as const;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getGetRolePermissionsQueryOptions = <
|
||||||
|
TData = Awaited<ReturnType<typeof getRolePermissions>>,
|
||||||
|
TError = ErrorType<ErrorResponse>,
|
||||||
|
>(
|
||||||
|
id: number,
|
||||||
|
options?: {
|
||||||
|
query?: UseQueryOptions<
|
||||||
|
Awaited<ReturnType<typeof getRolePermissions>>,
|
||||||
|
TError,
|
||||||
|
TData
|
||||||
|
>;
|
||||||
|
request?: SecondParameter<typeof customFetch>;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const { query: queryOptions, request: requestOptions } = options ?? {};
|
||||||
|
|
||||||
|
const queryKey = queryOptions?.queryKey ?? getGetRolePermissionsQueryKey(id);
|
||||||
|
|
||||||
|
const queryFn: QueryFunction<
|
||||||
|
Awaited<ReturnType<typeof getRolePermissions>>
|
||||||
|
> = ({ signal }) => getRolePermissions(id, { signal, ...requestOptions });
|
||||||
|
|
||||||
|
return {
|
||||||
|
queryKey,
|
||||||
|
queryFn,
|
||||||
|
enabled: !!id,
|
||||||
|
...queryOptions,
|
||||||
|
} as UseQueryOptions<
|
||||||
|
Awaited<ReturnType<typeof getRolePermissions>>,
|
||||||
|
TError,
|
||||||
|
TData
|
||||||
|
> & { queryKey: QueryKey };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetRolePermissionsQueryResult = NonNullable<
|
||||||
|
Awaited<ReturnType<typeof getRolePermissions>>
|
||||||
|
>;
|
||||||
|
export type GetRolePermissionsQueryError = ErrorType<ErrorResponse>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary List permissions assigned to a role (admin)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function useGetRolePermissions<
|
||||||
|
TData = Awaited<ReturnType<typeof getRolePermissions>>,
|
||||||
|
TError = ErrorType<ErrorResponse>,
|
||||||
|
>(
|
||||||
|
id: number,
|
||||||
|
options?: {
|
||||||
|
query?: UseQueryOptions<
|
||||||
|
Awaited<ReturnType<typeof getRolePermissions>>,
|
||||||
|
TError,
|
||||||
|
TData
|
||||||
|
>;
|
||||||
|
request?: SecondParameter<typeof customFetch>;
|
||||||
|
},
|
||||||
|
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||||
|
const queryOptions = getGetRolePermissionsQueryOptions(id, options);
|
||||||
|
|
||||||
|
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||||
|
queryKey: QueryKey;
|
||||||
|
};
|
||||||
|
|
||||||
|
return { ...query, queryKey: queryOptions.queryKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Atomically replace the permissions assigned to a role (admin)
|
||||||
|
*/
|
||||||
|
export const getReplaceRolePermissionsUrl = (id: number) => {
|
||||||
|
return `/api/roles/${id}/permissions`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const replaceRolePermissions = async (
|
||||||
|
id: number,
|
||||||
|
replaceRolePermissionsBody: ReplaceRolePermissionsBody,
|
||||||
|
options?: RequestInit,
|
||||||
|
): Promise<Permission[]> => {
|
||||||
|
return customFetch<Permission[]>(getReplaceRolePermissionsUrl(id), {
|
||||||
|
...options,
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json", ...options?.headers },
|
||||||
|
body: JSON.stringify(replaceRolePermissionsBody),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getReplaceRolePermissionsMutationOptions = <
|
||||||
|
TError = ErrorType<ErrorResponse>,
|
||||||
|
TContext = unknown,
|
||||||
|
>(options?: {
|
||||||
|
mutation?: UseMutationOptions<
|
||||||
|
Awaited<ReturnType<typeof replaceRolePermissions>>,
|
||||||
|
TError,
|
||||||
|
{ id: number; data: BodyType<ReplaceRolePermissionsBody> },
|
||||||
|
TContext
|
||||||
|
>;
|
||||||
|
request?: SecondParameter<typeof customFetch>;
|
||||||
|
}): UseMutationOptions<
|
||||||
|
Awaited<ReturnType<typeof replaceRolePermissions>>,
|
||||||
|
TError,
|
||||||
|
{ id: number; data: BodyType<ReplaceRolePermissionsBody> },
|
||||||
|
TContext
|
||||||
|
> => {
|
||||||
|
const mutationKey = ["replaceRolePermissions"];
|
||||||
|
const { mutation: mutationOptions, request: requestOptions } = options
|
||||||
|
? options.mutation &&
|
||||||
|
"mutationKey" in options.mutation &&
|
||||||
|
options.mutation.mutationKey
|
||||||
|
? options
|
||||||
|
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||||
|
: { mutation: { mutationKey }, request: undefined };
|
||||||
|
|
||||||
|
const mutationFn: MutationFunction<
|
||||||
|
Awaited<ReturnType<typeof replaceRolePermissions>>,
|
||||||
|
{ id: number; data: BodyType<ReplaceRolePermissionsBody> }
|
||||||
|
> = (props) => {
|
||||||
|
const { id, data } = props ?? {};
|
||||||
|
|
||||||
|
return replaceRolePermissions(id, data, requestOptions);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { mutationFn, ...mutationOptions };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReplaceRolePermissionsMutationResult = NonNullable<
|
||||||
|
Awaited<ReturnType<typeof replaceRolePermissions>>
|
||||||
|
>;
|
||||||
|
export type ReplaceRolePermissionsMutationBody =
|
||||||
|
BodyType<ReplaceRolePermissionsBody>;
|
||||||
|
export type ReplaceRolePermissionsMutationError = ErrorType<ErrorResponse>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Atomically replace the permissions assigned to a role (admin)
|
||||||
|
*/
|
||||||
|
export const useReplaceRolePermissions = <
|
||||||
|
TError = ErrorType<ErrorResponse>,
|
||||||
|
TContext = unknown,
|
||||||
|
>(options?: {
|
||||||
|
mutation?: UseMutationOptions<
|
||||||
|
Awaited<ReturnType<typeof replaceRolePermissions>>,
|
||||||
|
TError,
|
||||||
|
{ id: number; data: BodyType<ReplaceRolePermissionsBody> },
|
||||||
|
TContext
|
||||||
|
>;
|
||||||
|
request?: SecondParameter<typeof customFetch>;
|
||||||
|
}): UseMutationResult<
|
||||||
|
Awaited<ReturnType<typeof replaceRolePermissions>>,
|
||||||
|
TError,
|
||||||
|
{ id: number; data: BodyType<ReplaceRolePermissionsBody> },
|
||||||
|
TContext
|
||||||
|
> => {
|
||||||
|
return useMutation(getReplaceRolePermissionsMutationOptions(options));
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary List groups (admin)
|
* @summary List groups (admin)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1284,6 +1284,28 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/ErrorResponse"
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
|
||||||
|
# Permissions (admin)
|
||||||
|
/permissions:
|
||||||
|
get:
|
||||||
|
operationId: listPermissions
|
||||||
|
tags: [roles]
|
||||||
|
summary: List all available permissions (admin)
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Permissions
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/Permission"
|
||||||
|
"403":
|
||||||
|
description: Forbidden
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
|
||||||
# Roles (admin)
|
# Roles (admin)
|
||||||
/roles:
|
/roles:
|
||||||
get:
|
get:
|
||||||
@@ -1403,6 +1425,70 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/RoleDeletionConflict"
|
$ref: "#/components/schemas/RoleDeletionConflict"
|
||||||
|
|
||||||
|
/roles/{id}/permissions:
|
||||||
|
get:
|
||||||
|
operationId: getRolePermissions
|
||||||
|
tags: [roles]
|
||||||
|
summary: List permissions assigned to a role (admin)
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Permissions assigned to the role
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/Permission"
|
||||||
|
"404":
|
||||||
|
description: Role not found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
put:
|
||||||
|
operationId: replaceRolePermissions
|
||||||
|
tags: [roles]
|
||||||
|
summary: Atomically replace the permissions assigned to a role (admin)
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ReplaceRolePermissionsBody"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Updated permissions list for the role
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/Permission"
|
||||||
|
"400":
|
||||||
|
description: Cannot modify a system role's permissions, or invalid request
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
"404":
|
||||||
|
description: Role or one of the permissions was not found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
|
||||||
# Groups (admin)
|
# Groups (admin)
|
||||||
/groups:
|
/groups:
|
||||||
get:
|
get:
|
||||||
@@ -2120,6 +2206,32 @@ components:
|
|||||||
- userCount
|
- userCount
|
||||||
- groupCount
|
- groupCount
|
||||||
|
|
||||||
|
Permission:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
descriptionAr:
|
||||||
|
type: ["string", "null"]
|
||||||
|
descriptionEn:
|
||||||
|
type: ["string", "null"]
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- name
|
||||||
|
|
||||||
|
ReplaceRolePermissionsBody:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
permissionIds:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: integer
|
||||||
|
description: Full set of permission IDs the role should grant. Existing assignments not present here are removed.
|
||||||
|
required:
|
||||||
|
- permissionIds
|
||||||
|
|
||||||
GroupSummary:
|
GroupSummary:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
@@ -1623,6 +1623,17 @@ export const RemoveUserRoleResponse = zod.object({
|
|||||||
createdAt: zod.coerce.date(),
|
createdAt: zod.coerce.date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary List all available permissions (admin)
|
||||||
|
*/
|
||||||
|
export const ListPermissionsResponseItem = zod.object({
|
||||||
|
id: zod.number(),
|
||||||
|
name: zod.string(),
|
||||||
|
descriptionAr: zod.string().nullish(),
|
||||||
|
descriptionEn: zod.string().nullish(),
|
||||||
|
});
|
||||||
|
export const ListPermissionsResponse = zod.array(ListPermissionsResponseItem);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary List roles (admin)
|
* @summary List roles (admin)
|
||||||
*/
|
*/
|
||||||
@@ -1675,6 +1686,48 @@ export const DeleteRoleParams = zod.object({
|
|||||||
id: zod.coerce.number(),
|
id: zod.coerce.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary List permissions assigned to a role (admin)
|
||||||
|
*/
|
||||||
|
export const GetRolePermissionsParams = zod.object({
|
||||||
|
id: zod.coerce.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const GetRolePermissionsResponseItem = zod.object({
|
||||||
|
id: zod.number(),
|
||||||
|
name: zod.string(),
|
||||||
|
descriptionAr: zod.string().nullish(),
|
||||||
|
descriptionEn: zod.string().nullish(),
|
||||||
|
});
|
||||||
|
export const GetRolePermissionsResponse = zod.array(
|
||||||
|
GetRolePermissionsResponseItem,
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Atomically replace the permissions assigned to a role (admin)
|
||||||
|
*/
|
||||||
|
export const ReplaceRolePermissionsParams = zod.object({
|
||||||
|
id: zod.coerce.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ReplaceRolePermissionsBody = zod.object({
|
||||||
|
permissionIds: zod
|
||||||
|
.array(zod.number())
|
||||||
|
.describe(
|
||||||
|
"Full set of permission IDs the role should grant. Existing assignments not present here are removed.",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ReplaceRolePermissionsResponseItem = zod.object({
|
||||||
|
id: zod.number(),
|
||||||
|
name: zod.string(),
|
||||||
|
descriptionAr: zod.string().nullish(),
|
||||||
|
descriptionEn: zod.string().nullish(),
|
||||||
|
});
|
||||||
|
export const ReplaceRolePermissionsResponse = zod.array(
|
||||||
|
ReplaceRolePermissionsResponseItem,
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary List groups (admin)
|
* @summary List groups (admin)
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user