diff --git a/artifacts/api-server/src/routes/roles.ts b/artifacts/api-server/src/routes/roles.ts index 5ae86deb..bf37ea2f 100644 --- a/artifacts/api-server/src/routes/roles.ts +++ b/artifacts/api-server/src/routes/roles.ts @@ -5,6 +5,8 @@ import { rolesTable, userRolesTable, groupRolesTable, + groupsTable, + userGroupsTable, rolePermissionsTable, permissionsTable, auditLogsTable, @@ -263,6 +265,149 @@ router.get("/roles/:id/permissions", requireAdmin, async (req, res): Promise => { + 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(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([ + ...directUsers.map((r) => r.userId), + ...indirectUsers.map((r) => r.userId), + ]), + ); + + const allAffected = new Set(); + 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([ + ...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 => { const id = Number(req.params.id); if (!Number.isInteger(id)) { diff --git a/artifacts/api-server/tests/role-permissions-impact.test.mjs b/artifacts/api-server/tests/role-permissions-impact.test.mjs new file mode 100644 index 00000000..a34ef910 --- /dev/null +++ b/artifacts/api-server/tests/role-permissions-impact.test.mjs @@ -0,0 +1,337 @@ +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import pg from "pg"; + +const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080"; +const DATABASE_URL = process.env.DATABASE_URL; +if (!DATABASE_URL) { + throw new Error("DATABASE_URL must be set to run these tests"); +} + +const TEST_PASSWORD = "TestPass123!"; +const TEST_PASSWORD_HASH = + "$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu"; + +const pool = new pg.Pool({ connectionString: DATABASE_URL }); + +let adminId; +let adminUsername; +let adminCookie; +const createdRoleIds = []; +const createdGroupIds = []; +const createdUserIds = []; +const createdPermissionIds = []; + +let directUserId; +let groupedUserId; +let bothUserId; +let helperGroupId; +let mainRoleId; +let aloneRoleId; +let alsoGrantsP1RoleId; +let permA; +let permB; +let permC; + +async function loginAndGetCookie(username, password) { + const res = await fetch(`${API_BASE}/api/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + assert.equal(res.status, 200, `login expected 200, got ${res.status}`); + const setCookie = res.headers.get("set-cookie"); + return setCookie + .split(",") + .map((c) => c.split(";")[0].trim()) + .find((c) => c.startsWith("connect.sid=")); +} + +async function makeUser(prefix, stamp) { + const r = await pool.query( + `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active) + VALUES ($1, $2, $3, $4, 'en', true) RETURNING id`, + [ + `${prefix}_${stamp}`, + `${prefix}_${stamp}@example.com`, + TEST_PASSWORD_HASH, + `${prefix} User`, + ], + ); + const id = r.rows[0].id; + createdUserIds.push(id); + return id; +} + +async function makePermission(prefix, stamp) { + const r = await pool.query( + `INSERT INTO permissions (name, description_en) VALUES ($1, $2) RETURNING id, name`, + [`${prefix}_${stamp}`, `${prefix} permission`], + ); + const row = r.rows[0]; + createdPermissionIds.push(row.id); + return row; +} + +async function makeRole(prefix, stamp) { + const r = await pool.query( + `INSERT INTO roles (name, description_en) VALUES ($1, $2) RETURNING id`, + [`${prefix}_${stamp}`, `${prefix} role`], + ); + const id = r.rows[0].id; + createdRoleIds.push(id); + return id; +} + +async function makeGroup(prefix, stamp) { + const r = await pool.query( + `INSERT INTO groups (name, description_en) VALUES ($1, $2) RETURNING id`, + [`${prefix}_${stamp}`, `${prefix} group`], + ); + const id = r.rows[0].id; + createdGroupIds.push(id); + return id; +} + +before(async () => { + const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; + adminUsername = `imp_admin_${stamp}`; + adminId = await makeUser("imp_admin", stamp); + await pool.query( + `INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`, + [adminId], + ); + // overwrite the username we want to log in with (makeUser uses prefix_stamp) + adminUsername = ( + await pool.query(`SELECT username FROM users WHERE id = $1`, [adminId]) + ).rows[0].username; + + adminCookie = await loginAndGetCookie(adminUsername, TEST_PASSWORD); + + permA = await makePermission("perm_a", stamp); + permB = await makePermission("perm_b", stamp); + permC = await makePermission("perm_c", stamp); + + // Main role: has perms A, B, C. + mainRoleId = await makeRole("imp_main", stamp); + for (const p of [permA, permB, permC]) { + await pool.query( + `INSERT INTO role_permissions (role_id, permission_id) VALUES ($1, $2)`, + [mainRoleId, p.id], + ); + } + + // Alone role: has only perm A. Used to ensure a user keeps perm A after main loses it. + aloneRoleId = await makeRole("imp_alone", stamp); + alsoGrantsP1RoleId = aloneRoleId; + await pool.query( + `INSERT INTO role_permissions (role_id, permission_id) VALUES ($1, $2)`, + [aloneRoleId, permA.id], + ); + + // directUser: has main role directly. No other roles. + directUserId = await makeUser("imp_direct", stamp); + await pool.query( + `INSERT INTO user_roles (user_id, role_id) VALUES ($1, $2)`, + [directUserId, mainRoleId], + ); + + // groupedUser: receives main role via a group. + groupedUserId = await makeUser("imp_grouped", stamp); + helperGroupId = await makeGroup("imp_helper", stamp); + await pool.query( + `INSERT INTO user_groups (user_id, group_id) VALUES ($1, $2)`, + [groupedUserId, helperGroupId], + ); + await pool.query( + `INSERT INTO group_roles (group_id, role_id) VALUES ($1, $2)`, + [helperGroupId, mainRoleId], + ); + + // bothUser: has main role directly AND aloneRole (which also grants perm A). + bothUserId = await makeUser("imp_both", stamp); + await pool.query( + `INSERT INTO user_roles (user_id, role_id) VALUES ($1, $2)`, + [bothUserId, mainRoleId], + ); + await pool.query( + `INSERT INTO user_roles (user_id, role_id) VALUES ($1, $2)`, + [bothUserId, aloneRoleId], + ); +}); + +after(async () => { + if (createdRoleIds.length) { + await pool.query(`DELETE FROM user_roles WHERE role_id = ANY($1::int[])`, [ + createdRoleIds, + ]); + await pool.query(`DELETE FROM group_roles WHERE role_id = ANY($1::int[])`, [ + createdRoleIds, + ]); + await pool.query( + `DELETE FROM role_permissions WHERE role_id = ANY($1::int[])`, + [createdRoleIds], + ); + await pool.query(`DELETE FROM roles WHERE id = ANY($1::int[])`, [ + createdRoleIds, + ]); + } + if (createdGroupIds.length) { + await pool.query( + `DELETE FROM group_roles WHERE group_id = ANY($1::int[])`, + [createdGroupIds], + ); + await pool.query( + `DELETE FROM user_groups WHERE group_id = ANY($1::int[])`, + [createdGroupIds], + ); + await pool.query(`DELETE FROM groups WHERE id = ANY($1::int[])`, [ + createdGroupIds, + ]); + } + if (createdPermissionIds.length) { + await pool.query( + `DELETE FROM role_permissions WHERE permission_id = ANY($1::int[])`, + [createdPermissionIds], + ); + await pool.query(`DELETE FROM permissions WHERE id = ANY($1::int[])`, [ + createdPermissionIds, + ]); + } + for (const uid of createdUserIds) { + await pool.query(`DELETE FROM user_groups WHERE user_id = $1`, [uid]); + await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [uid]); + await pool.query(`DELETE FROM users WHERE id = $1`, [uid]); + } + await pool.end(); +}); + +test("POST /api/roles/:id/permissions/impact-preview returns empty when no permissions are removed", async () => { + const res = await fetch( + `${API_BASE}/api/roles/${mainRoleId}/permissions/impact-preview`, + { + method: "POST", + headers: { "Content-Type": "application/json", Cookie: adminCookie }, + body: JSON.stringify({ permissionIds: [permA.id, permB.id, permC.id] }), + }, + ); + assert.equal(res.status, 200); + const body = await res.json(); + assert.deepEqual(body.removed, []); + assert.equal(body.totalAffectedUsers, 0); +}); + +test("POST /api/roles/:id/permissions/impact-preview reports affected users and groups for each removed permission", async () => { + // Remove perm B and perm C from the role (keep perm A). + const res = await fetch( + `${API_BASE}/api/roles/${mainRoleId}/permissions/impact-preview`, + { + method: "POST", + headers: { "Content-Type": "application/json", Cookie: adminCookie }, + body: JSON.stringify({ permissionIds: [permA.id] }), + }, + ); + assert.equal(res.status, 200); + const body = await res.json(); + + assert.equal(body.removed.length, 2); + const byName = new Map(body.removed.map((r) => [r.permissionName, r])); + const b = byName.get(permB.name); + const c = byName.get(permC.name); + assert.ok(b, "expected perm B in removed list"); + assert.ok(c, "expected perm C in removed list"); + + // Three role-holders: directUser (direct), groupedUser (via helperGroup), bothUser (direct). + // For perm B and perm C, no other role grants them, so all 3 lose them. + assert.equal(b.userCount, 3); + assert.equal(c.userCount, 3); + + // The role is granted by 1 group (helperGroup). + assert.equal(b.groupCount, 1); + assert.equal(c.groupCount, 1); + assert.equal(b.groups.length, 1); + assert.equal(b.groups[0].id, helperGroupId); + assert.ok(typeof b.groups[0].name === "string" && b.groups[0].name.length > 0); + + assert.equal(body.totalAffectedUsers, 3); +}); + +test("impact-preview excludes users that still get the permission via another role", async () => { + // Remove ALL permissions, including perm A which is also granted to bothUser via aloneRole. + const res = await fetch( + `${API_BASE}/api/roles/${mainRoleId}/permissions/impact-preview`, + { + method: "POST", + headers: { "Content-Type": "application/json", Cookie: adminCookie }, + body: JSON.stringify({ permissionIds: [] }), + }, + ); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.removed.length, 3); + const byName = new Map(body.removed.map((r) => [r.permissionName, r])); + const a = byName.get(permA.name); + assert.ok(a, "expected perm A in removed list"); + // bothUser still has perm A via aloneRole, so only 2 of 3 lose it. + assert.equal(a.userCount, 2); + // For B and C nobody else grants them, so all 3 lose them. + assert.equal(byName.get(permB.name).userCount, 3); + assert.equal(byName.get(permC.name).userCount, 3); + // Total distinct affected users is 3 (all three lose at least one permission). + assert.equal(body.totalAffectedUsers, 3); +}); + +test("impact-preview returns 404 for an unknown role", async () => { + const res = await fetch( + `${API_BASE}/api/roles/9999999/permissions/impact-preview`, + { + method: "POST", + headers: { "Content-Type": "application/json", Cookie: adminCookie }, + body: JSON.stringify({ permissionIds: [] }), + }, + ); + assert.equal(res.status, 404); +}); + +test("impact-preview returns 400 when permissionIds is missing or invalid", async () => { + const noBody = await fetch( + `${API_BASE}/api/roles/${mainRoleId}/permissions/impact-preview`, + { + method: "POST", + headers: { "Content-Type": "application/json", Cookie: adminCookie }, + body: JSON.stringify({}), + }, + ); + assert.equal(noBody.status, 400); + + const wrong = await fetch( + `${API_BASE}/api/roles/${mainRoleId}/permissions/impact-preview`, + { + method: "POST", + headers: { "Content-Type": "application/json", Cookie: adminCookie }, + body: JSON.stringify({ permissionIds: ["abc"] }), + }, + ); + assert.equal(wrong.status, 400); +}); + +test("impact-preview requires admin", async () => { + const username = `imp_consumer_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; + const r = await pool.query( + `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active) + VALUES ($1, $2, $3, 'consumer', 'en', true) RETURNING id`, + [username, `${username}@example.com`, TEST_PASSWORD_HASH], + ); + const consumerId = r.rows[0].id; + createdUserIds.push(consumerId); + const consumerCookie = await loginAndGetCookie(username, TEST_PASSWORD); + const res = await fetch( + `${API_BASE}/api/roles/${mainRoleId}/permissions/impact-preview`, + { + method: "POST", + headers: { "Content-Type": "application/json", Cookie: consumerCookie }, + body: JSON.stringify({ permissionIds: [] }), + }, + ); + assert.ok(res.status === 401 || res.status === 403, `expected 401/403, got ${res.status}`); +}); diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index eb78ec22..e813e36e 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -448,7 +448,20 @@ "permissionsSystemHint": "صلاحيات الأدوار النظامية للقراءة فقط.", "permissionsEmpty": "لا توجد صلاحيات معرّفة.", "errorSystemPermissions": "لا يمكن تغيير صلاحيات الأدوار النظامية.", - "errorPermissionsSave": "تعذّر حفظ صلاحيات الدور." + "errorPermissionsSave": "تعذّر حفظ صلاحيات الدور.", + "impactTitle_one": "إزالة {{count}} صلاحية", + "impactTitle_other": "إزالة {{count}} صلاحيات", + "impactLoading": "جارٍ احتساب الأثر...", + "impactNoUsers": "لا يحصل أي مستخدم حالياً على هذه الصلاحيات عبر هذا الدور.", + "impactError": "تعذّر حساب المستخدمين المتأثرين. يرجى تعديل اختيارك أو المحاولة مرة أخرى.", + "impactPerPermission": "{{users}} مستخدم سيفقد هذه الصلاحية ({{groups}} مجموعة تمنح الدور)", + "impactViaGroups": "عبر المجموعات: {{groups}}", + "impactTotal_one": "{{count}} مستخدم في المجموع سيفقد صلاحية واحدة على الأقل.", + "impactTotal_other": "{{count}} مستخدم في المجموع سيفقدون صلاحية واحدة على الأقل.", + "confirmRemovalTitle": "تأكيد إزالة الصلاحيات", + "confirmRemovalBody_one": "سيؤدي الحفظ إلى إزالة الصلاحيات من {{count}} مستخدم. قد يُلغى وصوله فوراً. هل تريد المتابعة؟", + "confirmRemovalBody_other": "سيؤدي الحفظ إلى إزالة الصلاحيات من {{count}} مستخدماً. قد يُلغى وصولهم فوراً. هل تريد المتابعة؟", + "confirmRemovalAction": "إزالة الصلاحيات" }, "groups": { "searchPlaceholder": "ابحث في المجموعات...", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index cbb46566..3774af01 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -445,7 +445,20 @@ "permissionsSystemHint": "System role permissions are read-only.", "permissionsEmpty": "No permissions are defined.", "errorSystemPermissions": "System role permissions cannot be changed.", - "errorPermissionsSave": "Could not save the role's permissions." + "errorPermissionsSave": "Could not save the role's permissions.", + "impactTitle_one": "Removing {{count}} permission", + "impactTitle_other": "Removing {{count}} permissions", + "impactLoading": "Calculating impact...", + "impactNoUsers": "No users currently get any of these permissions through this role.", + "impactError": "Could not check who would be affected. Please adjust your selection or try again.", + "impactPerPermission": "{{users}} users would lose this ({{groups}} groups grant the role)", + "impactViaGroups": "via groups: {{groups}}", + "impactTotal_one": "{{count}} user in total would lose at least one permission.", + "impactTotal_other": "{{count}} users in total would lose at least one permission.", + "confirmRemovalTitle": "Confirm permission removal", + "confirmRemovalBody_one": "Saving will remove permissions from {{count}} user. This may revoke their access immediately. Continue?", + "confirmRemovalBody_other": "Saving will remove permissions from {{count}} users. This may revoke their access immediately. Continue?", + "confirmRemovalAction": "Remove permissions" }, "groups": { "searchPlaceholder": "Search groups...", diff --git a/artifacts/tx-os/src/pages/admin.tsx b/artifacts/tx-os/src/pages/admin.tsx index 61396919..2edc29d2 100644 --- a/artifacts/tx-os/src/pages/admin.tsx +++ b/artifacts/tx-os/src/pages/admin.tsx @@ -48,6 +48,7 @@ import { useGetRoleUsage, getGetRoleUsageQueryKey, useReplaceRolePermissions, + usePreviewRolePermissionsImpact, useListAuditLogs, getListAuditLogsQueryKey, getExportAuditLogsCsvUrl, @@ -71,6 +72,7 @@ import type { ListUsersParams, ListAuditLogsParams, AuditLogEntry, + RolePermissionsImpact, } from "@workspace/api-client-react"; import { ListUsersStatus } from "@workspace/api-client-react"; import { useQueryClient, useQuery } from "@tanstack/react-query"; @@ -3222,6 +3224,7 @@ function RolesPanel() { const updateRole = useUpdateRole(); const deleteRole = useDeleteRole(); const replaceRolePermissions = useReplaceRolePermissions(); + const previewRolePermissionsImpact = usePreviewRolePermissionsImpact(); const [search, setSearch] = useState(""); const [createOpen, setCreateOpen] = useState(false); const [createForm, setCreateForm] = useState({ name: "", descriptionAr: "", descriptionEn: "" }); @@ -3233,6 +3236,11 @@ function RolesPanel() { const [initialPermissionIds, setInitialPermissionIds] = useState>(new Set()); const [deleteTarget, setDeleteTarget] = useState<{ id: number; name: string } | null>(null); const [deleteConflict, setDeleteConflict] = useState<{ userCount: number; groupCount: number } | null>(null); + const [permissionsImpact, setPermissionsImpact] = useState(null); + const [impactLoading, setImpactLoading] = useState(false); + const [impactError, setImpactError] = useState(false); + const [confirmRemoval, setConfirmRemoval] = useState(false); + const impactRequestSeq = useRef(0); const editingPermissionsId = editingId ?? 0; const { data: editingRolePermissions, isLoading: editingPermsLoading } = useGetRolePermissions( @@ -3326,6 +3334,10 @@ function RolesPanel() { setEditPermissionIds(new Set()); setInitialPermissionIds(new Set()); setOriginalEditName(""); + setPermissionsImpact(null); + setImpactLoading(false); + setImpactError(false); + setConfirmRemoval(false); }; const editNameTrimmed = editForm.name.trim(); @@ -3343,8 +3355,66 @@ function RolesPanel() { return false; })(); + const removedPermissionIds = useMemo(() => { + const removed: number[] = []; + for (const id of initialPermissionIds) { + if (!editPermissionIds.has(id)) removed.push(id); + } + return removed; + }, [editPermissionIds, initialPermissionIds]); + + const hasRemovals = removedPermissionIds.length > 0; + + // Debounced on-demand fetch of impact preview when removals are present. + useEffect(() => { + if (editingId == null) return; + if (editingIsSystem) return; + if (!hasRemovals) { + setPermissionsImpact(null); + setImpactLoading(false); + setImpactError(false); + return; + } + setImpactLoading(true); + setImpactError(false); + const editingRoleId = editingId; + const candidate = Array.from(editPermissionIds); + const requestId = ++impactRequestSeq.current; + const handle = setTimeout(() => { + previewRolePermissionsImpact.mutate( + { id: editingRoleId, data: { permissionIds: candidate } }, + { + onSuccess: (data) => { + if (impactRequestSeq.current !== requestId) return; + setPermissionsImpact(data); + setImpactError(false); + setImpactLoading(false); + }, + onError: () => { + if (impactRequestSeq.current !== requestId) return; + setPermissionsImpact(null); + setImpactError(true); + setImpactLoading(false); + }, + }, + ); + }, 350); + return () => clearTimeout(handle); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [editingId, editingIsSystem, hasRemovals, editPermissionIds, initialPermissionIds]); + const handleEdit = () => { if (editingId == null) return; + if ( + !editingIsSystem && + hasRemovals && + !confirmRemoval && + permissionsImpact && + permissionsImpact.totalAffectedUsers > 0 + ) { + setConfirmRemoval(true); + return; + } const editingRoleId = editingId; const name = editForm.name.trim(); if (!name) return; @@ -3706,6 +3776,71 @@ function RolesPanel() { }) )} + {!editingIsSystem && hasRemovals && ( +
+

+ {t("admin.roles.impactTitle", { count: removedPermissionIds.length })} +

+ {impactLoading ? ( +
+ + {t("admin.roles.impactLoading")} +
+ ) : impactError || !permissionsImpact ? ( +

+ {t("admin.roles.impactError")} +

+ ) : permissionsImpact.removed.length === 0 ? ( +

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

+ ) : ( + <> +
    + {permissionsImpact.removed.map((item) => ( +
  • + {item.permissionName} + {": "} + + {t("admin.roles.impactPerPermission", { + users: item.userCount, + groups: item.groupCount, + })} + + {item.groupCount > 0 && ( + + {" "} + {t("admin.roles.impactViaGroups", { + groups: item.groups.map((g) => g.name).join(", "), + })} + + )} +
  • + ))} +
+ {permissionsImpact.totalAffectedUsers > 0 && ( +

+ {t("admin.roles.impactTotal", { + count: permissionsImpact.totalAffectedUsers, + })} +

+ )} + + )} +
+ )}
)} + {confirmRemoval && permissionsImpact && permissionsImpact.totalAffectedUsers > 0 && ( +
+
+

+ {t("admin.roles.confirmRemovalTitle")} +

+

+ {t("admin.roles.confirmRemovalBody", { + count: permissionsImpact.totalAffectedUsers, + })} +

+
    + {permissionsImpact.removed.map((item) => ( +
  • + {item.permissionName} + {": "} + {t("admin.roles.impactPerPermission", { + users: item.userCount, + groups: item.groupCount, + })} +
  • + ))} +
+
+ + +
+
+
+ )} + {deleteTarget && (
diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index d542df25..6a023959 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -240,6 +240,34 @@ export interface ReplaceRolePermissionsBody { permissionIds: number[]; } +export interface RolePermissionsImpactBody { + /** Candidate set of permission IDs to evaluate against the role's current assignments. */ + permissionIds: number[]; +} + +export interface RolePermissionImpactGroup { + id: number; + name: string; +} + +export interface RolePermissionImpactItem { + permissionId: number; + permissionName: string; + /** Distinct users who currently have this permission via this role and would lose it (no other role they hold grants the permission). */ + userCount: number; + /** Number of groups that currently grant this role. */ + groupCount: number; + /** Groups that currently grant this role and therefore propagate the removal to their members. */ + groups: RolePermissionImpactGroup[]; +} + +export interface RolePermissionsImpact { + /** One entry per permission that is currently assigned to the role but not in the candidate set. */ + removed: RolePermissionImpactItem[]; + /** Distinct users that would lose at least one permission across all removals. */ + totalAffectedUsers: number; +} + export interface Group { id: number; name: string; diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index cfd958d0..9bffcdda 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -67,6 +67,8 @@ import type { ResetPasswordBody, Role, RoleDeletionConflict, + RolePermissionsImpact, + RolePermissionsImpactBody, RoleUsage, SendMessageBody, Service, @@ -5219,6 +5221,104 @@ export function useGetRoleUsage< return { ...query, queryKey: queryOptions.queryKey }; } +/** + * Given a candidate set of permission IDs for the role, returns the list of +permissions that would be removed and, for each one, how many users would +lose the permission (because they hold this role directly or via a group +and have no other role granting the permission), plus the groups that +currently grant this role. + + * @summary Preview the impact of changing a role's permission set (admin) + */ +export const getPreviewRolePermissionsImpactUrl = (id: number) => { + return `/api/roles/${id}/permissions/impact-preview`; +}; + +export const previewRolePermissionsImpact = async ( + id: number, + rolePermissionsImpactBody: RolePermissionsImpactBody, + options?: RequestInit, +): Promise => { + return customFetch( + getPreviewRolePermissionsImpactUrl(id), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(rolePermissionsImpactBody), + }, + ); +}; + +export const getPreviewRolePermissionsImpactMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number; data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { id: number; data: BodyType }, + TContext +> => { + const mutationKey = ["previewRolePermissionsImpact"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { id: number; data: BodyType } + > = (props) => { + const { id, data } = props ?? {}; + + return previewRolePermissionsImpact(id, data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type PreviewRolePermissionsImpactMutationResult = NonNullable< + Awaited> +>; +export type PreviewRolePermissionsImpactMutationBody = + BodyType; +export type PreviewRolePermissionsImpactMutationError = + ErrorType; + +/** + * @summary Preview the impact of changing a role's permission set (admin) + */ +export const usePreviewRolePermissionsImpact = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number; data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationResult< + Awaited>, + TError, + { id: number; data: BodyType }, + TContext +> => { + return useMutation(getPreviewRolePermissionsImpactMutationOptions(options)); +}; + /** * @summary List permissions assigned to a role (admin) */ diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index 40340c70..58f91d56 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -1450,6 +1450,49 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" + /roles/{id}/permissions/impact-preview: + post: + operationId: previewRolePermissionsImpact + tags: [roles] + summary: Preview the impact of changing a role's permission set (admin) + description: | + Given a candidate set of permission IDs for the role, returns the list of + permissions that would be removed and, for each one, how many users would + lose the permission (because they hold this role directly or via a group + and have no other role granting the permission), plus the groups that + currently grant this role. + parameters: + - name: id + in: path + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RolePermissionsImpactBody" + responses: + "200": + description: Impact preview for the proposed permission set + content: + application/json: + schema: + $ref: "#/components/schemas/RolePermissionsImpact" + "400": + description: Invalid request body + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "404": + description: Role was not found. Unknown permission IDs in the request body are tolerated and simply do not appear in `removed`. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + /roles/{id}/permissions: get: operationId: getRolePermissions @@ -2358,6 +2401,68 @@ components: required: - permissionIds + RolePermissionsImpactBody: + type: object + properties: + permissionIds: + type: array + items: + type: integer + description: Candidate set of permission IDs to evaluate against the role's current assignments. + required: + - permissionIds + + RolePermissionImpactGroup: + type: object + properties: + id: + type: integer + name: + type: string + required: + - id + - name + + RolePermissionImpactItem: + type: object + properties: + permissionId: + type: integer + permissionName: + type: string + userCount: + type: integer + description: Distinct users who currently have this permission via this role and would lose it (no other role they hold grants the permission). + groupCount: + type: integer + description: Number of groups that currently grant this role. + groups: + type: array + items: + $ref: "#/components/schemas/RolePermissionImpactGroup" + description: Groups that currently grant this role and therefore propagate the removal to their members. + required: + - permissionId + - permissionName + - userCount + - groupCount + - groups + + RolePermissionsImpact: + type: object + properties: + removed: + type: array + items: + $ref: "#/components/schemas/RolePermissionImpactItem" + description: One entry per permission that is currently assigned to the role but not in the candidate set. + totalAffectedUsers: + type: integer + description: Distinct users that would lose at least one permission across all removals. + required: + - removed + - totalAffectedUsers + GroupSummary: type: object properties: diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index f8123186..515f95b9 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -1908,6 +1908,63 @@ export const GetRoleUsageResponse = zod.object({ groupCount: zod.number(), }); +/** + * Given a candidate set of permission IDs for the role, returns the list of +permissions that would be removed and, for each one, how many users would +lose the permission (because they hold this role directly or via a group +and have no other role granting the permission), plus the groups that +currently grant this role. + + * @summary Preview the impact of changing a role's permission set (admin) + */ +export const PreviewRolePermissionsImpactParams = zod.object({ + id: zod.coerce.number(), +}); + +export const PreviewRolePermissionsImpactBody = zod.object({ + permissionIds: zod + .array(zod.number()) + .describe( + "Candidate set of permission IDs to evaluate against the role's current assignments.", + ), +}); + +export const PreviewRolePermissionsImpactResponse = zod.object({ + removed: zod + .array( + zod.object({ + permissionId: zod.number(), + permissionName: zod.string(), + userCount: zod + .number() + .describe( + "Distinct users who currently have this permission via this role and would lose it (no other role they hold grants the permission).", + ), + groupCount: zod + .number() + .describe("Number of groups that currently grant this role."), + groups: zod + .array( + zod.object({ + id: zod.number(), + name: zod.string(), + }), + ) + .describe( + "Groups that currently grant this role and therefore propagate the removal to their members.", + ), + }), + ) + .describe( + "One entry per permission that is currently assigned to the role but not in the candidate set.", + ), + totalAffectedUsers: zod + .number() + .describe( + "Distinct users that would lose at least one permission across all removals.", + ), +}); + /** * @summary List permissions assigned to a role (admin) */