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.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
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 {
|
||||
rolesTable,
|
||||
@@ -9,11 +9,30 @@ import {
|
||||
permissionsTable,
|
||||
} from "@workspace/db";
|
||||
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";
|
||||
|
||||
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) {
|
||||
return {
|
||||
id: r.id,
|
||||
@@ -30,6 +49,11 @@ router.get("/roles", requireAdmin, async (_req, res): Promise<void> => {
|
||||
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> => {
|
||||
const parsed = CreateRoleBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
@@ -80,8 +104,7 @@ router.patch("/roles/:id", requireAdmin, async (req, res): Promise<void> => {
|
||||
if (parsed.data.name !== undefined) {
|
||||
const name = parsed.data.name.trim();
|
||||
if (name !== existing.name) {
|
||||
const PROTECTED_NAMES = new Set(["admin", "user", "order_receiver"]);
|
||||
if (existing.isSystem || PROTECTED_NAMES.has(existing.name)) {
|
||||
if (isSystemRole(existing)) {
|
||||
res.status(400).json({ error: "Cannot rename a system role", code: "system_role_rename" });
|
||||
return;
|
||||
}
|
||||
@@ -122,8 +145,7 @@ router.delete("/roles/:id", requireAdmin, async (req, res): Promise<void> => {
|
||||
res.status(404).json({ error: "Role not found" });
|
||||
return;
|
||||
}
|
||||
const PROTECTED_NAMES = new Set(["admin", "user", "order_receiver"]);
|
||||
if (existing.isSystem || PROTECTED_NAMES.has(existing.name)) {
|
||||
if (isSystemRole(existing)) {
|
||||
res.status(400).json({ error: "Cannot delete a system role" });
|
||||
return;
|
||||
}
|
||||
@@ -158,11 +180,82 @@ router.get("/roles/:id/permissions", requireAdmin, async (req, res): Promise<voi
|
||||
res.status(400).json({ error: "Invalid id" });
|
||||
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
|
||||
.select({ id: permissionsTable.id, name: permissionsTable.name })
|
||||
.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));
|
||||
.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);
|
||||
});
|
||||
|
||||
@@ -177,11 +270,17 @@ router.post("/roles/:id/permissions", requireAdmin, async (req, res): Promise<vo
|
||||
res.status(400).json({ error: "permissionId is required" });
|
||||
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) {
|
||||
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 [perm] = await db.select({ id: permissionsTable.id }).from(permissionsTable).where(eq(permissionsTable.id, permissionId));
|
||||
if (!perm) {
|
||||
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" });
|
||||
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
|
||||
.delete(rolePermissionsTable)
|
||||
.where(
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
@@ -438,7 +438,13 @@
|
||||
"groupsCount": "{{count}} مجموعة",
|
||||
"errorNameTaken": "يوجد دور بنفس الاسم.",
|
||||
"errorInvalidName": "يجب أن يبدأ اسم الدور بحرف ويحتوي على حروف وأرقام وشرطات سفلية فقط.",
|
||||
"errorSystemRename": "لا يمكن تغيير اسم الأدوار النظامية."
|
||||
"errorSystemRename": "لا يمكن تغيير اسم الأدوار النظامية.",
|
||||
"permissions": "الصلاحيات",
|
||||
"permissionsHint": "اختر الصلاحيات التي يمنحها هذا الدور.",
|
||||
"permissionsSystemHint": "صلاحيات الأدوار النظامية للقراءة فقط.",
|
||||
"permissionsEmpty": "لا توجد صلاحيات معرّفة.",
|
||||
"errorSystemPermissions": "لا يمكن تغيير صلاحيات الأدوار النظامية.",
|
||||
"errorPermissionsSave": "تعذّر حفظ صلاحيات الدور."
|
||||
},
|
||||
"groups": {
|
||||
"searchPlaceholder": "ابحث في المجموعات...",
|
||||
|
||||
@@ -435,7 +435,13 @@
|
||||
"groupsCount": "{{count}} groups",
|
||||
"errorNameTaken": "A role with that name already exists.",
|
||||
"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": {
|
||||
"searchPlaceholder": "Search groups...",
|
||||
|
||||
@@ -41,6 +41,11 @@ import {
|
||||
useCreateRole,
|
||||
useUpdateRole,
|
||||
useDeleteRole,
|
||||
useListPermissions,
|
||||
getListPermissionsQueryKey,
|
||||
useGetRolePermissions,
|
||||
getGetRolePermissionsQueryKey,
|
||||
useReplaceRolePermissions,
|
||||
useListAuditLogs,
|
||||
getListAuditLogsQueryKey,
|
||||
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() {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: roles } = useListRoles({ query: { queryKey: getListRolesQueryKey() } });
|
||||
const { data: allPermissions } = useListPermissions({
|
||||
query: { queryKey: getListPermissionsQueryKey() },
|
||||
});
|
||||
const createRole = useCreateRole();
|
||||
const updateRole = useUpdateRole();
|
||||
const deleteRole = useDeleteRole();
|
||||
const replaceRolePermissions = useReplaceRolePermissions();
|
||||
const [search, setSearch] = useState("");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createForm, setCreateForm] = useState({ name: "", descriptionAr: "", descriptionEn: "" });
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [editingIsSystem, setEditingIsSystem] = useState(false);
|
||||
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 [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 visibleRoles = (roles ?? []).filter((r) => {
|
||||
@@ -3220,7 +3252,7 @@ function RolesPanel() {
|
||||
descriptionEn?: string | null;
|
||||
}) => {
|
||||
setEditingId(role.id);
|
||||
setEditingIsSystem(role.isSystem);
|
||||
setEditingIsSystem(role.isSystem || ROLES_PROTECTED_NAMES.has(role.name));
|
||||
setEditForm({
|
||||
name: role.name,
|
||||
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 = () => {
|
||||
if (editingId == null) return;
|
||||
const editingRoleId = editingId;
|
||||
const name = editForm.name.trim();
|
||||
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(
|
||||
{
|
||||
id: editingId,
|
||||
id: editingRoleId,
|
||||
data: {
|
||||
...(editingIsSystem ? {} : { name }),
|
||||
descriptionAr: editForm.descriptionAr.trim() || null,
|
||||
@@ -3243,9 +3328,7 @@ function RolesPanel() {
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
setEditingId(null);
|
||||
toast({ title: t("common.success") });
|
||||
savePermissionsThenClose();
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
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 = () => {
|
||||
if (!deleteTarget) return;
|
||||
deleteRole.mutate(
|
||||
@@ -3319,7 +3412,7 @@ function RolesPanel() {
|
||||
<div className="font-semibold text-foreground flex items-center gap-2">
|
||||
<KeyRound size={16} className="text-primary" />
|
||||
<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">
|
||||
{t("admin.roles.system")}
|
||||
</span>
|
||||
@@ -3345,7 +3438,7 @@ function RolesPanel() {
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
{!r.isSystem && (
|
||||
{!(r.isSystem || ROLES_PROTECTED_NAMES.has(r.name)) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setDeleteConflict(null);
|
||||
@@ -3423,7 +3516,10 @@ function RolesPanel() {
|
||||
|
||||
{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="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>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("admin.roles.name")}</Label>
|
||||
@@ -3459,17 +3555,82 @@ function RolesPanel() {
|
||||
data-testid="role-edit-desc-en"
|
||||
/>
|
||||
</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">
|
||||
<Button variant="outline" className="flex-1" onClick={() => setEditingId(null)}>
|
||||
<Button variant="outline" className="flex-1" onClick={closeEditDialog}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
disabled={!editForm.name.trim() || updateRole.isPending}
|
||||
disabled={
|
||||
!editForm.name.trim() ||
|
||||
updateRole.isPending ||
|
||||
replaceRolePermissions.isPending
|
||||
}
|
||||
onClick={handleEdit}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -204,6 +204,20 @@ export interface RoleDeletionConflict {
|
||||
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 {
|
||||
id: number;
|
||||
name: string;
|
||||
|
||||
@@ -58,7 +58,9 @@ import type {
|
||||
LoginBody,
|
||||
MessageWithSender,
|
||||
Notification,
|
||||
Permission,
|
||||
RegisterBody,
|
||||
ReplaceRolePermissionsBody,
|
||||
RequestUploadUrlBody,
|
||||
RequestUploadUrlResponse,
|
||||
ResetPasswordBody,
|
||||
@@ -4729,6 +4731,81 @@ export const useRemoveUserRole = <
|
||||
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)
|
||||
*/
|
||||
@@ -5053,6 +5130,181 @@ export const useDeleteRole = <
|
||||
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)
|
||||
*/
|
||||
|
||||
@@ -1284,6 +1284,28 @@ paths:
|
||||
schema:
|
||||
$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:
|
||||
get:
|
||||
@@ -1403,6 +1425,70 @@ paths:
|
||||
schema:
|
||||
$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:
|
||||
get:
|
||||
@@ -2120,6 +2206,32 @@ components:
|
||||
- userCount
|
||||
- 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:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -1623,6 +1623,17 @@ export const RemoveUserRoleResponse = zod.object({
|
||||
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)
|
||||
*/
|
||||
@@ -1675,6 +1686,48 @@ export const DeleteRoleParams = zod.object({
|
||||
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)
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user