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:
riyadhafraa
2026-04-27 12:58:16 +00:00
parent 1e9024e5a2
commit aa6866936d
9 changed files with 737 additions and 23 deletions
+119 -9
View File
@@ -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

+7 -1
View File
@@ -438,7 +438,13 @@
"groupsCount": "{{count}} مجموعة", "groupsCount": "{{count}} مجموعة",
"errorNameTaken": "يوجد دور بنفس الاسم.", "errorNameTaken": "يوجد دور بنفس الاسم.",
"errorInvalidName": "يجب أن يبدأ اسم الدور بحرف ويحتوي على حروف وأرقام وشرطات سفلية فقط.", "errorInvalidName": "يجب أن يبدأ اسم الدور بحرف ويحتوي على حروف وأرقام وشرطات سفلية فقط.",
"errorSystemRename": "لا يمكن تغيير اسم الأدوار النظامية." "errorSystemRename": "لا يمكن تغيير اسم الأدوار النظامية.",
"permissions": "الصلاحيات",
"permissionsHint": "اختر الصلاحيات التي يمنحها هذا الدور.",
"permissionsSystemHint": "صلاحيات الأدوار النظامية للقراءة فقط.",
"permissionsEmpty": "لا توجد صلاحيات معرّفة.",
"errorSystemPermissions": "لا يمكن تغيير صلاحيات الأدوار النظامية.",
"errorPermissionsSave": "تعذّر حفظ صلاحيات الدور."
}, },
"groups": { "groups": {
"searchPlaceholder": "ابحث في المجموعات...", "searchPlaceholder": "ابحث في المجموعات...",
+7 -1
View File
@@ -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...",
+173 -12
View File
@@ -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;
+252
View File
@@ -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)
*/ */
+112
View File
@@ -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:
+53
View File
@@ -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)
*/ */