Let admins create and edit roles from the dashboard (Task #82)

What changed:
- Added an `is_system` column to the `roles` table (default 0). The
  built-in roles (admin, user, order_receiver) are flagged as system roles
  in the seed and via a one-off UPDATE on the existing dev DB.
- Created a new `/api/roles` route module with admin-guarded endpoints:
    - POST /api/roles – create a role (validates name format, rejects duplicates).
    - PATCH /api/roles/:id – update bilingual descriptions; name is immutable.
    - DELETE /api/roles/:id – returns 400 for system roles (also defended
      by name allowlist), 409 with userCount/groupCount when in use, 204 on
      success. Cleans up role_permissions in a transaction.
  GET /api/roles was moved out of groups.ts into the new module.
- OpenAPI updated with createRole / updateRole / deleteRole operations,
  isSystem on the Role schema, and CreateRoleBody / UpdateRoleBody /
  RoleDeletionConflict schemas. Regenerated api-zod and api-client-react.
- Added a "Roles" item under the User Management nav group in the admin
  dashboard, plus a new RolesPanel with search, create dialog, edit dialog
  (description-only), and delete dialog with conflict messaging.
- Bilingual translations added in en.json and ar.json (admin.nav.roles +
  admin.roles.*).

Notes / deviations:
- Also marked `order_receiver` as a system role since it is seeded by the
  system and required by the orders feature, even though the task brief
  only called out admin and user.
- Verified end-to-end via the testing skill: list, create, edit, delete,
  duplicate-name validation, and protection of system roles all work.

Replit-Task-Id: b1187555-be09-4687-a9ae-83b123d908bd
This commit is contained in:
riyadhafraa
2026-04-27 10:26:24 +00:00
parent b0b1d673b7
commit 19a20cc5a4
13 changed files with 981 additions and 17 deletions
-13
View File
@@ -39,19 +39,6 @@ function serializeGroup(g: typeof groupsTable.$inferSelect) {
};
}
router.get("/roles", requireAdmin, async (_req, res): Promise<void> => {
const rows = await db.select().from(rolesTable).orderBy(rolesTable.name);
res.json(
rows.map((r) => ({
id: r.id,
name: r.name,
descriptionAr: r.descriptionAr,
descriptionEn: r.descriptionEn,
createdAt: r.createdAt,
})),
);
});
router.get("/groups", requireAdmin, async (req, res): Promise<void> => {
const q = typeof req.query.q === "string" ? req.query.q.trim() : "";
const where = q ? ilike(groupsTable.name, `%${q}%`) : undefined;
+2
View File
@@ -12,6 +12,7 @@ import storageRouter from "./storage";
import settingsRouter from "./settings";
import notesRouter from "./notes";
import groupsRouter from "./groups";
import rolesRouter from "./roles";
const router: IRouter = Router();
@@ -28,5 +29,6 @@ router.use(storageRouter);
router.use(settingsRouter);
router.use(notesRouter);
router.use(groupsRouter);
router.use(rolesRouter);
export default router;
+128
View File
@@ -0,0 +1,128 @@
import { Router, type IRouter } from "express";
import { eq, sql } from "drizzle-orm";
import { db } from "@workspace/db";
import {
rolesTable,
userRolesTable,
groupRolesTable,
rolePermissionsTable,
} from "@workspace/db";
import { requireAdmin } from "../middlewares/auth";
import { CreateRoleBody, UpdateRoleBody } from "@workspace/api-zod";
const router: IRouter = Router();
function serializeRole(r: typeof rolesTable.$inferSelect) {
return {
id: r.id,
name: r.name,
descriptionAr: r.descriptionAr,
descriptionEn: r.descriptionEn,
isSystem: Boolean(r.isSystem),
createdAt: r.createdAt,
};
}
router.get("/roles", requireAdmin, async (_req, res): Promise<void> => {
const rows = await db.select().from(rolesTable).orderBy(rolesTable.name);
res.json(rows.map(serializeRole));
});
router.post("/roles", requireAdmin, async (req, res): Promise<void> => {
const parsed = CreateRoleBody.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.message });
return;
}
const name = parsed.data.name.trim();
if (!/^[a-z][a-z0-9_]*$/i.test(name)) {
res.status(400).json({ error: "Role name must start with a letter and contain only letters, numbers, or underscores" });
return;
}
const existing = await db
.select({ id: rolesTable.id })
.from(rolesTable)
.where(eq(rolesTable.name, name));
if (existing.length > 0) {
res.status(409).json({ error: "Role name already taken" });
return;
}
const [role] = await db
.insert(rolesTable)
.values({
name,
descriptionAr: parsed.data.descriptionAr ?? null,
descriptionEn: parsed.data.descriptionEn ?? null,
})
.returning();
res.status(201).json(serializeRole(role));
});
router.patch("/roles/:id", 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 = UpdateRoleBody.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.message });
return;
}
const [existing] = await db.select().from(rolesTable).where(eq(rolesTable.id, id));
if (!existing) {
res.status(404).json({ error: "Role not found" });
return;
}
const updates: Partial<typeof rolesTable.$inferInsert> = {};
if (parsed.data.descriptionAr !== undefined) updates.descriptionAr = parsed.data.descriptionAr;
if (parsed.data.descriptionEn !== undefined) updates.descriptionEn = parsed.data.descriptionEn;
if (Object.keys(updates).length > 0) {
await db.update(rolesTable).set(updates).where(eq(rolesTable.id, id));
}
const [role] = await db.select().from(rolesTable).where(eq(rolesTable.id, id));
res.json(serializeRole(role));
});
router.delete("/roles/:id", 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 [existing] = await db.select().from(rolesTable).where(eq(rolesTable.id, id));
if (!existing) {
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)) {
res.status(400).json({ error: "Cannot delete a system role" });
return;
}
const [userCountRow] = await db
.select({ count: sql<number>`count(*)::int` })
.from(userRolesTable)
.where(eq(userRolesTable.roleId, id));
const [groupCountRow] = await db
.select({ count: sql<number>`count(*)::int` })
.from(groupRolesTable)
.where(eq(groupRolesTable.roleId, id));
const userCount = userCountRow?.count ?? 0;
const groupCount = groupCountRow?.count ?? 0;
if (userCount > 0 || groupCount > 0) {
res.status(409).json({
error: "Role is in use",
userCount,
groupCount,
});
return;
}
await db.transaction(async (tx) => {
await tx.delete(rolePermissionsTable).where(eq(rolePermissionsTable.roleId, id));
await tx.delete(rolesTable).where(eq(rolesTable.id, id));
});
res.sendStatus(204);
});
export default router;