10a48657c7
Original task (#101): When an admin saves the role editor, role holders need their permission-driven UI (admin nav items, permission-gated action buttons, etc.) to refresh without waiting for a page reload. The server already emitted `apps_changed`, but that event is semantically about visible apps, not the broader cached permission state, and the client only invalidated the apps list and `/api/auth/me`. Changes: - artifacts/api-server/src/lib/realtime.ts - Extracted role-holder resolution (direct user_roles + indirect via group_roles -> user_groups) into a private helper. - Added `emitRolePermissionsChangedToHolders(roleId)` that emits a new `role_permissions_changed` socket event with `{ roleId }` to every holder of the role. - artifacts/api-server/src/routes/roles.ts - After `PUT /api/roles/:id/permissions` succeeds, call the new emitter alongside the existing `emitAppsChangedToRoleHolders`. - artifacts/tx-os/src/hooks/use-notifications-socket.ts - Subscribed to `role_permissions_changed`. Invalidates `getMe`, `listRoles`, `listPermissions`, and—when the payload includes the role id—`getRolePermissions`, the role permission audit, and role usage queries so admin-only UI re-evaluates without a refresh. Scope notes / deviations: - The task spec called out `PUT /api/roles/:id/permissions` only, so the matching POST/DELETE single-permission endpoints still emit only `apps_changed`. Filed as follow-up #150 to keep them consistent. - No new automated tests added (filed as follow-up #151). Pre-existing typecheck errors in artifacts/api-server/src/routes/executive-meetings.ts are unrelated and untouched. - `replit.md` not updated; this is a behavioral refinement of an existing socket flow, not an architectural change. Replit-Task-Id: bd31a9a9-dbf1-497a-8505-96672f92bc79
685 lines
22 KiB
TypeScript
685 lines
22 KiB
TypeScript
import { Router, type IRouter } from "express";
|
|
import { desc, eq, inArray, sql } from "drizzle-orm";
|
|
import { db } from "@workspace/db";
|
|
import {
|
|
rolesTable,
|
|
userRolesTable,
|
|
groupRolesTable,
|
|
groupsTable,
|
|
userGroupsTable,
|
|
rolePermissionsTable,
|
|
permissionsTable,
|
|
auditLogsTable,
|
|
rolePermissionAuditTable,
|
|
usersTable,
|
|
} from "@workspace/db";
|
|
import { requireAdmin } from "../middlewares/auth";
|
|
import {
|
|
CreateRoleBody,
|
|
UpdateRoleBody,
|
|
ReplaceRolePermissionsBody,
|
|
} from "@workspace/api-zod";
|
|
import {
|
|
emitAppsChangedToRoleHolders,
|
|
emitRolePermissionsChangedToHolders,
|
|
} 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,
|
|
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.get("/permissions", requireAdmin, async (_req, res): Promise<void> => {
|
|
const rows = await db.select().from(permissionsTable).orderBy(permissionsTable.name);
|
|
res.json(rows.map(serializePermission));
|
|
});
|
|
|
|
router.get("/roles/:id/audit", 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 limitRaw = Number(req.query.limit);
|
|
const limit =
|
|
Number.isFinite(limitRaw) && limitRaw > 0
|
|
? Math.min(Math.floor(limitRaw), 50)
|
|
: 10;
|
|
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: rolePermissionAuditTable.id,
|
|
roleId: rolePermissionAuditTable.roleId,
|
|
previousPermissionIds: rolePermissionAuditTable.previousPermissionIds,
|
|
newPermissionIds: rolePermissionAuditTable.newPermissionIds,
|
|
createdAt: rolePermissionAuditTable.createdAt,
|
|
actorUserId: rolePermissionAuditTable.actorUserId,
|
|
actorUsername: usersTable.username,
|
|
actorDisplayNameAr: usersTable.displayNameAr,
|
|
actorDisplayNameEn: usersTable.displayNameEn,
|
|
actorAvatarUrl: usersTable.avatarUrl,
|
|
})
|
|
.from(rolePermissionAuditTable)
|
|
.leftJoin(usersTable, eq(usersTable.id, rolePermissionAuditTable.actorUserId))
|
|
.where(eq(rolePermissionAuditTable.roleId, id))
|
|
.orderBy(desc(rolePermissionAuditTable.createdAt), desc(rolePermissionAuditTable.id))
|
|
.limit(limit);
|
|
const entries = rows.map((r) => {
|
|
const prev = (r.previousPermissionIds ?? []) as number[];
|
|
const next = (r.newPermissionIds ?? []) as number[];
|
|
const prevSet = new Set(prev);
|
|
const nextSet = new Set(next);
|
|
return {
|
|
id: r.id,
|
|
roleId: r.roleId,
|
|
previousPermissionIds: prev,
|
|
newPermissionIds: next,
|
|
addedPermissionIds: next.filter((p) => !prevSet.has(p)),
|
|
removedPermissionIds: prev.filter((p) => !nextSet.has(p)),
|
|
createdAt: r.createdAt,
|
|
actor:
|
|
r.actorUserId != null && r.actorUsername != null
|
|
? {
|
|
id: r.actorUserId,
|
|
username: r.actorUsername,
|
|
displayNameAr: r.actorDisplayNameAr,
|
|
displayNameEn: r.actorDisplayNameEn,
|
|
avatarUrl: r.actorAvatarUrl,
|
|
}
|
|
: null,
|
|
};
|
|
});
|
|
res.json(entries);
|
|
});
|
|
|
|
router.get("/roles/:id/usage", 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 [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 [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));
|
|
res.json({
|
|
userCount: userCountRow?.count ?? 0,
|
|
groupCount: groupCountRow?.count ?? 0,
|
|
});
|
|
});
|
|
|
|
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();
|
|
await db.insert(auditLogsTable).values({
|
|
actorUserId: req.session.userId ?? null,
|
|
action: "role.create",
|
|
targetType: "role",
|
|
targetId: role.id,
|
|
metadata: {
|
|
name: role.name,
|
|
descriptionAr: role.descriptionAr,
|
|
descriptionEn: role.descriptionEn,
|
|
},
|
|
});
|
|
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.name !== undefined) {
|
|
const name = parsed.data.name.trim();
|
|
if (name !== existing.name) {
|
|
if (isSystemRole(existing)) {
|
|
res.status(400).json({ error: "Cannot rename a system role", code: "system_role_rename" });
|
|
return;
|
|
}
|
|
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 taken = await db
|
|
.select({ id: rolesTable.id })
|
|
.from(rolesTable)
|
|
.where(eq(rolesTable.name, name));
|
|
if (taken.some((r) => r.id !== id)) {
|
|
res.status(409).json({ error: "Role name already taken" });
|
|
return;
|
|
}
|
|
updates.name = name;
|
|
}
|
|
}
|
|
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 renamed = updates.name !== undefined && updates.name !== existing.name;
|
|
await db.insert(auditLogsTable).values({
|
|
actorUserId: req.session.userId ?? null,
|
|
action: "role.update",
|
|
targetType: "role",
|
|
targetId: id,
|
|
metadata: {
|
|
previousName: existing.name,
|
|
name: updates.name ?? existing.name,
|
|
renamed,
|
|
...(renamed
|
|
? { renamedFrom: existing.name, renamedTo: updates.name }
|
|
: {}),
|
|
changes: updates,
|
|
},
|
|
});
|
|
}
|
|
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;
|
|
}
|
|
if (isSystemRole(existing)) {
|
|
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));
|
|
});
|
|
await db.insert(auditLogsTable).values({
|
|
actorUserId: req.session.userId ?? null,
|
|
action: "role.delete",
|
|
targetType: "role",
|
|
targetId: id,
|
|
metadata: {
|
|
name: existing.name,
|
|
descriptionAr: existing.descriptionAr,
|
|
descriptionEn: existing.descriptionEn,
|
|
},
|
|
});
|
|
res.sendStatus(204);
|
|
});
|
|
|
|
router.get("/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 [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,
|
|
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);
|
|
});
|
|
|
|
router.post(
|
|
"/roles/:id/permissions/impact-preview",
|
|
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 candidate = req.body?.permissionIds;
|
|
if (!Array.isArray(candidate) || !candidate.every((n) => Number.isInteger(n) && n > 0)) {
|
|
res.status(400).json({ error: "permissionIds must be an array of positive integers" });
|
|
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 candidateSet = new Set<number>(candidate as number[]);
|
|
|
|
const currentPerms = await db
|
|
.select({ id: permissionsTable.id, name: permissionsTable.name })
|
|
.from(rolePermissionsTable)
|
|
.innerJoin(permissionsTable, eq(rolePermissionsTable.permissionId, permissionsTable.id))
|
|
.where(eq(rolePermissionsTable.roleId, id));
|
|
|
|
const removed = currentPerms.filter((p) => !candidateSet.has(p.id));
|
|
|
|
if (removed.length === 0) {
|
|
res.json({ removed: [], totalAffectedUsers: 0 });
|
|
return;
|
|
}
|
|
|
|
const groups = await db
|
|
.select({ id: groupsTable.id, name: groupsTable.name })
|
|
.from(groupRolesTable)
|
|
.innerJoin(groupsTable, eq(groupRolesTable.groupId, groupsTable.id))
|
|
.where(eq(groupRolesTable.roleId, id))
|
|
.orderBy(groupsTable.name);
|
|
|
|
// Users that currently hold this role: directly OR via any group that grants it.
|
|
const directUsers = await db
|
|
.select({ userId: userRolesTable.userId })
|
|
.from(userRolesTable)
|
|
.where(eq(userRolesTable.roleId, id));
|
|
|
|
const groupIds = groups.map((g) => g.id);
|
|
const indirectUsers = groupIds.length
|
|
? await db
|
|
.select({ userId: userGroupsTable.userId })
|
|
.from(userGroupsTable)
|
|
.where(inArray(userGroupsTable.groupId, groupIds))
|
|
: [];
|
|
|
|
const roleHolderIds = Array.from(
|
|
new Set<number>([
|
|
...directUsers.map((r) => r.userId),
|
|
...indirectUsers.map((r) => r.userId),
|
|
]),
|
|
);
|
|
|
|
const allAffected = new Set<number>();
|
|
const items: Array<{
|
|
permissionId: number;
|
|
permissionName: string;
|
|
userCount: number;
|
|
groupCount: number;
|
|
groups: Array<{ id: number; name: string }>;
|
|
}> = [];
|
|
|
|
if (roleHolderIds.length === 0) {
|
|
for (const p of removed) {
|
|
items.push({
|
|
permissionId: p.id,
|
|
permissionName: p.name,
|
|
userCount: 0,
|
|
groupCount: groups.length,
|
|
groups,
|
|
});
|
|
}
|
|
res.json({ removed: items, totalAffectedUsers: 0 });
|
|
return;
|
|
}
|
|
|
|
// For each removed permission, find which role-holders would still have it
|
|
// through some OTHER role (direct or via any group).
|
|
for (const p of removed) {
|
|
const directOther = await db
|
|
.selectDistinct({ userId: userRolesTable.userId })
|
|
.from(userRolesTable)
|
|
.innerJoin(
|
|
rolePermissionsTable,
|
|
eq(rolePermissionsTable.roleId, userRolesTable.roleId),
|
|
)
|
|
.where(
|
|
sql`${inArray(userRolesTable.userId, roleHolderIds)} AND ${userRolesTable.roleId} <> ${id} AND ${rolePermissionsTable.permissionId} = ${p.id}`,
|
|
);
|
|
|
|
const indirectOther = await db
|
|
.selectDistinct({ userId: userGroupsTable.userId })
|
|
.from(userGroupsTable)
|
|
.innerJoin(
|
|
groupRolesTable,
|
|
eq(groupRolesTable.groupId, userGroupsTable.groupId),
|
|
)
|
|
.innerJoin(
|
|
rolePermissionsTable,
|
|
eq(rolePermissionsTable.roleId, groupRolesTable.roleId),
|
|
)
|
|
.where(
|
|
sql`${inArray(userGroupsTable.userId, roleHolderIds)} AND ${groupRolesTable.roleId} <> ${id} AND ${rolePermissionsTable.permissionId} = ${p.id}`,
|
|
);
|
|
|
|
const stillHave = new Set<number>([
|
|
...directOther.map((r) => r.userId),
|
|
...indirectOther.map((r) => r.userId),
|
|
]);
|
|
let lost = 0;
|
|
for (const uid of roleHolderIds) {
|
|
if (!stillHave.has(uid)) {
|
|
lost += 1;
|
|
allAffected.add(uid);
|
|
}
|
|
}
|
|
items.push({
|
|
permissionId: p.id,
|
|
permissionName: p.name,
|
|
userCount: lost,
|
|
groupCount: groups.length,
|
|
groups,
|
|
});
|
|
}
|
|
|
|
items.sort((a, b) => a.permissionName.localeCompare(b.permissionName));
|
|
res.json({ removed: items, totalAffectedUsers: allAffected.size });
|
|
},
|
|
);
|
|
|
|
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;
|
|
}
|
|
}
|
|
// Apply the permission replacement, the generic audit_logs row, and the
|
|
// dedicated role_permission_audit row in a single transaction so the
|
|
// permission set and its audit trail are guaranteed to commit together.
|
|
// Reading the previous IDs inside the transaction also avoids racing with
|
|
// a concurrent edit between the read and the write.
|
|
await db.transaction(async (tx) => {
|
|
const previousIds = (
|
|
await tx
|
|
.select({ permissionId: rolePermissionsTable.permissionId })
|
|
.from(rolePermissionsTable)
|
|
.where(eq(rolePermissionsTable.roleId, id))
|
|
).map((r) => r.permissionId);
|
|
|
|
const previousSet = new Set(previousIds);
|
|
const requestedSet = new Set(requestedIds);
|
|
const added = requestedIds.filter((p) => !previousSet.has(p));
|
|
const removed = previousIds.filter((p) => !requestedSet.has(p));
|
|
const changed = added.length > 0 || removed.length > 0;
|
|
|
|
await tx.delete(rolePermissionsTable).where(eq(rolePermissionsTable.roleId, id));
|
|
if (requestedIds.length > 0) {
|
|
await tx
|
|
.insert(rolePermissionsTable)
|
|
.values(requestedIds.map((permissionId) => ({ roleId: id, permissionId })));
|
|
}
|
|
|
|
// Dedicated role-permission audit row written on EVERY PUT call (per
|
|
// task spec) so we have a complete record of who reviewed/saved the
|
|
// permission set at any point in time, even when the resulting set
|
|
// happens to be unchanged. The History UI uses
|
|
// addedPermissionIds/removedPermissionIds (computed in the GET
|
|
// handler) to surface meaningful changes vs. no-op saves.
|
|
await tx.insert(rolePermissionAuditTable).values({
|
|
roleId: id,
|
|
actorUserId: req.session.userId ?? null,
|
|
previousPermissionIds: [...previousIds].sort((a, b) => a - b),
|
|
newPermissionIds: [...requestedIds].sort((a, b) => a - b),
|
|
});
|
|
|
|
// The legacy audit_logs entry is kept for backward compatibility
|
|
// with the existing audit log UI, but only when the set actually
|
|
// changes (matches its long-standing semantics for this action).
|
|
if (changed) {
|
|
await tx.insert(auditLogsTable).values({
|
|
actorUserId: req.session.userId ?? null,
|
|
action: "role.permissions.replace",
|
|
targetType: "role",
|
|
targetId: id,
|
|
metadata: {
|
|
roleName: role.name,
|
|
added,
|
|
removed,
|
|
},
|
|
});
|
|
}
|
|
});
|
|
await emitAppsChangedToRoleHolders(id);
|
|
await emitRolePermissionsChangedToHolders(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);
|
|
});
|
|
|
|
router.post("/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 permissionId = Number(req.body?.permissionId);
|
|
if (!Number.isInteger(permissionId)) {
|
|
res.status(400).json({ error: "permissionId is required" });
|
|
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 [perm] = await db
|
|
.select({ id: permissionsTable.id, name: permissionsTable.name })
|
|
.from(permissionsTable)
|
|
.where(eq(permissionsTable.id, permissionId));
|
|
if (!perm) {
|
|
res.status(404).json({ error: "Permission not found" });
|
|
return;
|
|
}
|
|
const existing = await db
|
|
.select({ roleId: rolePermissionsTable.roleId })
|
|
.from(rolePermissionsTable)
|
|
.where(sql`${rolePermissionsTable.roleId} = ${id} AND ${rolePermissionsTable.permissionId} = ${permissionId}`);
|
|
if (existing.length === 0) {
|
|
await db.insert(rolePermissionsTable).values({ roleId: id, permissionId });
|
|
await emitAppsChangedToRoleHolders(id);
|
|
await db.insert(auditLogsTable).values({
|
|
actorUserId: req.session.userId ?? null,
|
|
action: "role.permission.add",
|
|
targetType: "role",
|
|
targetId: id,
|
|
metadata: {
|
|
roleName: role.name,
|
|
permissionId,
|
|
permissionName: perm.name,
|
|
},
|
|
});
|
|
}
|
|
res.status(201).json({ roleId: id, permissionId });
|
|
});
|
|
|
|
router.delete("/roles/:id/permissions/:permissionId", requireAdmin, async (req, res): Promise<void> => {
|
|
const id = Number(req.params.id);
|
|
const permissionId = Number(req.params.permissionId);
|
|
if (!Number.isInteger(id) || !Number.isInteger(permissionId)) {
|
|
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 [perm] = await db
|
|
.select({ name: permissionsTable.name })
|
|
.from(permissionsTable)
|
|
.where(eq(permissionsTable.id, permissionId));
|
|
const deleted = await db
|
|
.delete(rolePermissionsTable)
|
|
.where(
|
|
sql`${rolePermissionsTable.roleId} = ${id} AND ${rolePermissionsTable.permissionId} = ${permissionId}`,
|
|
)
|
|
.returning();
|
|
if (deleted.length > 0) {
|
|
await emitAppsChangedToRoleHolders(id);
|
|
await db.insert(auditLogsTable).values({
|
|
actorUserId: req.session.userId ?? null,
|
|
action: "role.permission.remove",
|
|
targetType: "role",
|
|
targetId: id,
|
|
metadata: {
|
|
roleName: role.name,
|
|
permissionId,
|
|
permissionName: perm?.name ?? null,
|
|
},
|
|
});
|
|
}
|
|
res.sendStatus(204);
|
|
});
|
|
|
|
export default router;
|