Task #98: Warn admins before renaming a role used in code references

- Added GET /api/roles/:id/usage endpoint returning {userCount, groupCount},
  guarded by requireAdmin and validating the role id.
- Updated PATCH /api/roles/:id audit metadata to record renamed=true,
  renamedFrom, and renamedTo when the role name changes (in addition to
  existing name fields), satisfying the traceability requirement.
- Added RoleUsage schema + /roles/{id}/usage operation to openapi.yaml,
  regenerated lib/api-zod via orval. Codegen script in lib/api-spec was
  updated intentionally to preserve `export * from './manual'` in
  api-zod's index, which orval was overwriting.
- RolesPanel edit dialog (admin.tsx) now tracks the original role name
  on open and shows an amber warning banner with from/to text plus a
  usage line (users + groups counts) whenever the trimmed name input
  differs from the original. Warning is suppressed for system or
  protected roles since their name input is disabled.
- Added en/ar locale strings: renameWarningTitle, renameWarningBody,
  renameUsage.
- Reverted accidental opengraph.jpg change picked up earlier in the
  session — it is unrelated to this task.

Verification: e2e test created a non-system role, assigned the admin
user to it, opened the edit dialog, observed the warning + usage line,
saved the rename, and confirmed both the role name persisted and the
audit log captured renamed/renamedFrom/renamedTo.

Note: The pnpm `test` workflow shows pre-existing failures in
executive-meetings.ts that are unrelated to this task (verified via
git stash before changes).
This commit is contained in:
Riyadh
2026-04-29 10:57:44 +00:00
parent 4cfa236fa3
commit 3dbd43c8cd
9 changed files with 222 additions and 1 deletions
+30
View File
@@ -55,6 +55,31 @@ router.get("/permissions", requireAdmin, async (_req, res): Promise<void> => {
res.json(rows.map(serializePermission));
});
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) {
@@ -141,6 +166,7 @@ router.patch("/roles/:id", requireAdmin, async (req, res): Promise<void> => {
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",
@@ -149,6 +175,10 @@ router.patch("/roles/:id", requireAdmin, async (req, res): Promise<void> => {
metadata: {
previousName: existing.name,
name: updates.name ?? existing.name,
renamed,
...(renamed
? { renamedFrom: existing.name, renamedTo: updates.name }
: {}),
changes: updates,
},
});