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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user