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 { 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(
|
||||
|
||||
Reference in New Issue
Block a user