From 59c7338d639556b19e3bfb9b70e6dc588d3a386f Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Wed, 29 Apr 2026 10:57:44 +0000 Subject: [PATCH] Task #98: Warn admins before renaming a role used in code references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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). Replit-Task-Id: c1ee048d-50c6-4439-a430-ee0b7ec09959 --- artifacts/api-server/src/routes/roles.ts | 30 +++++++ artifacts/tx-os/src/locales/ar.json | 3 + artifacts/tx-os/src/locales/en.json | 3 + artifacts/tx-os/src/pages/admin.tsx | 44 ++++++++++ .../src/generated/api.schemas.ts | 5 ++ lib/api-client-react/src/generated/api.ts | 88 +++++++++++++++++++ lib/api-spec/openapi.yaml | 36 ++++++++ lib/api-spec/package.json | 2 +- lib/api-zod/src/generated/api.ts | 12 +++ 9 files changed, 222 insertions(+), 1 deletion(-) diff --git a/artifacts/api-server/src/routes/roles.ts b/artifacts/api-server/src/routes/roles.ts index a83d0277..5ae86deb 100644 --- a/artifacts/api-server/src/routes/roles.ts +++ b/artifacts/api-server/src/routes/roles.ts @@ -55,6 +55,31 @@ router.get("/permissions", requireAdmin, async (_req, res): Promise => { res.json(rows.map(serializePermission)); }); +router.get("/roles/:id/usage", requireAdmin, async (req, res): Promise => { + 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`count(*)::int` }) + .from(userRolesTable) + .where(eq(userRolesTable.roleId, id)); + const [groupCountRow] = await db + .select({ count: sql`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 => { const parsed = CreateRoleBody.safeParse(req.body); if (!parsed.success) { @@ -141,6 +166,7 @@ router.patch("/roles/:id", requireAdmin, async (req, res): Promise => { 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 => { metadata: { previousName: existing.name, name: updates.name ?? existing.name, + renamed, + ...(renamed + ? { renamedFrom: existing.name, renamedTo: updates.name } + : {}), changes: updates, }, }); diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index 6828357a..6571d993 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -440,6 +440,9 @@ "errorNameTaken": "يوجد دور بنفس الاسم.", "errorInvalidName": "يجب أن يبدأ اسم الدور بحرف ويحتوي على حروف وأرقام وشرطات سفلية فقط.", "errorSystemRename": "لا يمكن تغيير اسم الأدوار النظامية.", + "renameWarningTitle": "إعادة تسمية \"{{from}}\" إلى \"{{to}}\"", + "renameWarningBody": "قد يُشار إلى اسم هذا الدور في الشيفرة أو التكاملات أو استعلامات سجل التدقيق. إعادة التسمية قد تُعطّل هذه التدفقات بصمت. حدّث جميع المراجع قبل الحفظ.", + "renameUsage": "مستخدم حالياً من قِبل {{users}} مستخدم و{{groups}} مجموعة.", "permissions": "الصلاحيات", "permissionsHint": "اختر الصلاحيات التي يمنحها هذا الدور.", "permissionsSystemHint": "صلاحيات الأدوار النظامية للقراءة فقط.", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index 1ef0b637..1ffaf393 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -437,6 +437,9 @@ "errorNameTaken": "A role with that name already exists.", "errorInvalidName": "Role name must start with a letter and contain only letters, numbers, or underscores.", "errorSystemRename": "System role names cannot be changed.", + "renameWarningTitle": "Renaming \"{{from}}\" to \"{{to}}\"", + "renameWarningBody": "This role's name may be referenced by code, integrations, or audit log queries. Renaming can quietly break those flows. Update any callers before saving.", + "renameUsage": "Currently used by {{users}} users and {{groups}} groups.", "permissions": "Permissions", "permissionsHint": "Select which permissions this role grants.", "permissionsSystemHint": "System role permissions are read-only.", diff --git a/artifacts/tx-os/src/pages/admin.tsx b/artifacts/tx-os/src/pages/admin.tsx index 9e119c1e..61396919 100644 --- a/artifacts/tx-os/src/pages/admin.tsx +++ b/artifacts/tx-os/src/pages/admin.tsx @@ -45,6 +45,8 @@ import { getListPermissionsQueryKey, useGetRolePermissions, getGetRolePermissionsQueryKey, + useGetRoleUsage, + getGetRoleUsageQueryKey, useReplaceRolePermissions, useListAuditLogs, getListAuditLogsQueryKey, @@ -3226,6 +3228,7 @@ function RolesPanel() { const [editingId, setEditingId] = useState(null); const [editingIsSystem, setEditingIsSystem] = useState(false); const [editForm, setEditForm] = useState({ name: "", descriptionAr: "", descriptionEn: "" }); + const [originalEditName, setOriginalEditName] = useState(""); const [editPermissionIds, setEditPermissionIds] = useState>(new Set()); const [initialPermissionIds, setInitialPermissionIds] = useState>(new Set()); const [deleteTarget, setDeleteTarget] = useState<{ id: number; name: string } | null>(null); @@ -3241,6 +3244,12 @@ function RolesPanel() { }, }, ); + const { data: editingRoleUsage } = useGetRoleUsage(editingPermissionsId, { + query: { + queryKey: getGetRoleUsageQueryKey(editingPermissionsId), + enabled: editingId != null && !editingIsSystem, + }, + }); useEffect(() => { if (editingId == null) return; @@ -3309,14 +3318,23 @@ function RolesPanel() { descriptionAr: role.descriptionAr ?? "", descriptionEn: role.descriptionEn ?? "", }); + setOriginalEditName(role.name); }; const closeEditDialog = () => { setEditingId(null); setEditPermissionIds(new Set()); setInitialPermissionIds(new Set()); + setOriginalEditName(""); }; + const editNameTrimmed = editForm.name.trim(); + const isRenaming = + !editingIsSystem && + originalEditName.length > 0 && + editNameTrimmed.length > 0 && + editNameTrimmed !== originalEditName; + const permissionsChanged = (() => { if (editPermissionIds.size !== initialPermissionIds.size) return true; for (const id of editPermissionIds) { @@ -3585,6 +3603,32 @@ function RolesPanel() {

{editingIsSystem ? t("admin.roles.editSystemNameHint") : t("admin.roles.nameHint")}

+ {isRenaming && ( +
+

+ {t("admin.roles.renameWarningTitle", { + from: originalEditName, + to: editNameTrimmed, + })} +

+

{t("admin.roles.renameWarningBody")}

+ {editingRoleUsage && ( +

+ {t("admin.roles.renameUsage", { + users: editingRoleUsage.userCount, + groups: editingRoleUsage.groupCount, + })} +

+ )} +
+ )}
diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index 41a1b1a3..d542df25 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -215,6 +215,11 @@ export interface UpdateRoleBody { descriptionEn?: string | null; } +export interface RoleUsage { + userCount: number; + groupCount: number; +} + export interface RoleDeletionConflict { error: string; userCount: number; diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index 7144e574..cfd958d0 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -67,6 +67,7 @@ import type { ResetPasswordBody, Role, RoleDeletionConflict, + RoleUsage, SendMessageBody, Service, ServiceCategory, @@ -5131,6 +5132,93 @@ export const useDeleteRole = < return useMutation(getDeleteRoleMutationOptions(options)); }; +/** + * @summary Count users and groups currently assigned to a role (admin) + */ +export const getGetRoleUsageUrl = (id: number) => { + return `/api/roles/${id}/usage`; +}; + +export const getRoleUsage = async ( + id: number, + options?: RequestInit, +): Promise => { + return customFetch(getGetRoleUsageUrl(id), { + ...options, + method: "GET", + }); +}; + +export const getGetRoleUsageQueryKey = (id: number) => { + return [`/api/roles/${id}/usage`] as const; +}; + +export const getGetRoleUsageQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + id: number, + options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetRoleUsageQueryKey(id); + + const queryFn: QueryFunction>> = ({ + signal, + }) => getRoleUsage(id, { signal, ...requestOptions }); + + return { + queryKey, + queryFn, + enabled: !!id, + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: QueryKey }; +}; + +export type GetRoleUsageQueryResult = NonNullable< + Awaited> +>; +export type GetRoleUsageQueryError = ErrorType; + +/** + * @summary Count users and groups currently assigned to a role (admin) + */ + +export function useGetRoleUsage< + TData = Awaited>, + TError = ErrorType, +>( + id: number, + options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; + }, +): UseQueryResult & { queryKey: QueryKey } { + const queryOptions = getGetRoleUsageQueryOptions(id, options); + + const query = useQuery(queryOptions) as UseQueryResult & { + queryKey: QueryKey; + }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + /** * @summary List permissions assigned to a role (admin) */ diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index b8bf2e5a..40340c70 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -1425,6 +1425,31 @@ paths: schema: $ref: "#/components/schemas/RoleDeletionConflict" + /roles/{id}/usage: + get: + operationId: getRoleUsage + tags: [roles] + summary: Count users and groups currently assigned to a role (admin) + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + "200": + description: Counts of users and groups using the role + content: + application/json: + schema: + $ref: "#/components/schemas/RoleUsage" + "404": + description: Role not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + /roles/{id}/permissions: get: operationId: getRolePermissions @@ -2282,6 +2307,17 @@ components: descriptionEn: type: ["string", "null"] + RoleUsage: + type: object + properties: + userCount: + type: integer + groupCount: + type: integer + required: + - userCount + - groupCount + RoleDeletionConflict: type: object properties: diff --git a/lib/api-spec/package.json b/lib/api-spec/package.json index f849ad9a..d238c462 100644 --- a/lib/api-spec/package.json +++ b/lib/api-spec/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "scripts": { - "codegen": "orval --config ./orval.config.ts && echo \"export * from './generated/api';\" > ../api-zod/src/index.ts && pnpm -w run typecheck:libs" + "codegen": "orval --config ./orval.config.ts && printf \"export * from './generated/api';\\nexport * from './manual';\\n\" > ../api-zod/src/index.ts && pnpm -w run typecheck:libs" }, "devDependencies": { "orval": "^8.5.2" diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index 78f1ce29..f8123186 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -1896,6 +1896,18 @@ export const DeleteRoleParams = zod.object({ id: zod.coerce.number(), }); +/** + * @summary Count users and groups currently assigned to a role (admin) + */ +export const GetRoleUsageParams = zod.object({ + id: zod.coerce.number(), +}); + +export const GetRoleUsageResponse = zod.object({ + userCount: zod.number(), + groupCount: zod.number(), +}); + /** * @summary List permissions assigned to a role (admin) */