Show users/groups impact before removing role permissions (Task #99)
Adds a per-permission impact preview to the role editor so admins can see
who would lose each permission before saving — and a confirmation step
when the change would actually revoke access from at least one user.
API
- New endpoint POST /api/roles/:id/permissions/impact-preview
- Body: { permissionIds: number[] } (the proposed kept set)
- Response: { removed: [{ permissionId, permissionName, userCount,
groupCount, groups: [{id,name}] }], totalAffectedUsers }
- A user is counted as "affected" only when no other role they hold
(direct or via groups) still grants the removed permission.
- Implemented in artifacts/api-server/src/routes/roles.ts using two
drizzle selectDistinct queries (direct + via groups) merged in JS.
Switched away from a sql`${arr}::int[]` approach that failed because
drizzle expands JS arrays as a parameter list, not as a single array
parameter. Now uses inArray().
OpenAPI / client
- Added schemas RolePermissionsImpactBody, RolePermissionImpactGroup,
RolePermissionImpactItem, RolePermissionsImpact in
lib/api-spec/openapi.yaml.
- Regenerated lib/api-client-react/src/generated/* (new
usePreviewRolePermissionsImpact hook).
UI
- artifacts/tx-os/src/pages/admin.tsx RolesPanel:
- Debounced (350ms) on-demand fetch only when at least one permission
has been unchecked relative to the saved state.
- Inline amber summary panel listing each removed permission with
user/group counts, plus a total-affected-users line.
- Confirmation modal before save when totalAffectedUsers > 0.
- Added EN/AR translations (impactTitle, impactPerPermission,
impactViaGroups, impactTotal, confirmRemoval*) using i18next
_one/_other plural keys to match existing pluralized strings.
Tests
- New artifacts/api-server/tests/role-permissions-impact.test.mjs
(6 tests, all green): empty removals, group-derived impact,
"covered by another role" exclusion, 404 on unknown role, 400 on
invalid body, 403 for non-admin.
- Existing roles-crud tests still pass.
- E2E flow verified end to end via the testing tool: amber summary
appears, confirmation dialog gates the destructive save, and the
resulting permission set persists.
Code-review follow-ups (applied in this same task)
- Tightened the OpenAPI 404 description for the new endpoint to clarify
that unknown permission IDs in the body are tolerated (only a missing
role yields 404). Regenerated the API client.
- Added a request-sequencing guard in the role editor so a stale
in-flight impact-preview response cannot overwrite a newer selection.
- When the impact preview fails while removals exist, the inline panel
now shows an explicit error message and the Save button is disabled
until the user resolves it (e.g. by changing the selection so the
preview can re-run successfully). EN/AR translations added under
admin.roles.impactError.
Notes
- The pre-existing test workflow failure (ECONNREFUSED on 127.0.0.1:8080
before the API server is ready) is unrelated and already tracked.
- replit.md not updated — change is endpoint-level and does not alter
documented architecture.
- A modification to artifacts/tx-os/public/opengraph.jpg may show up in
the diff; it is a build artifact regenerated by the vite dev server
on restart and is not part of this change.
Replit-Task-Id: 30b7a2f7-7b60-429a-89d4-79280a81803f
This commit is contained in:
@@ -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<voi
|
||||
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)) {
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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": "ابحث في المجموعات...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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<Set<number>>(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<RolePermissionsImpact | null>(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() {
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
{!editingIsSystem && hasRemovals && (
|
||||
<div
|
||||
className="mt-2 rounded-xl border border-amber-300 bg-amber-50 p-3 text-xs text-amber-900 space-y-2"
|
||||
data-testid="role-perm-impact"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<p className="font-semibold">
|
||||
{t("admin.roles.impactTitle", { count: removedPermissionIds.length })}
|
||||
</p>
|
||||
{impactLoading ? (
|
||||
<div className="flex items-center gap-2 text-amber-800">
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
<span>{t("admin.roles.impactLoading")}</span>
|
||||
</div>
|
||||
) : impactError || !permissionsImpact ? (
|
||||
<p
|
||||
className="text-amber-900"
|
||||
data-testid="role-perm-impact-error"
|
||||
>
|
||||
{t("admin.roles.impactError")}
|
||||
</p>
|
||||
) : permissionsImpact.removed.length === 0 ? (
|
||||
<p>{t("admin.roles.impactNoUsers")}</p>
|
||||
) : (
|
||||
<>
|
||||
<ul
|
||||
className="space-y-1.5 ps-4 list-disc"
|
||||
data-testid="role-perm-impact-list"
|
||||
>
|
||||
{permissionsImpact.removed.map((item) => (
|
||||
<li key={item.permissionId}>
|
||||
<span className="font-mono">{item.permissionName}</span>
|
||||
{": "}
|
||||
<span data-testid={`role-perm-impact-counts-${item.permissionId}`}>
|
||||
{t("admin.roles.impactPerPermission", {
|
||||
users: item.userCount,
|
||||
groups: item.groupCount,
|
||||
})}
|
||||
</span>
|
||||
{item.groupCount > 0 && (
|
||||
<span className="text-amber-800">
|
||||
{" "}
|
||||
{t("admin.roles.impactViaGroups", {
|
||||
groups: item.groups.map((g) => g.name).join(", "),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{permissionsImpact.totalAffectedUsers > 0 && (
|
||||
<p
|
||||
className="font-semibold text-amber-900"
|
||||
data-testid="role-perm-impact-total"
|
||||
>
|
||||
{t("admin.roles.impactTotal", {
|
||||
count: permissionsImpact.totalAffectedUsers,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button variant="outline" className="flex-1" onClick={closeEditDialog}>
|
||||
@@ -3716,7 +3851,8 @@ function RolesPanel() {
|
||||
disabled={
|
||||
!editForm.name.trim() ||
|
||||
updateRole.isPending ||
|
||||
replaceRolePermissions.isPending
|
||||
replaceRolePermissions.isPending ||
|
||||
(!editingIsSystem && hasRemovals && (impactLoading || impactError))
|
||||
}
|
||||
onClick={handleEdit}
|
||||
data-testid="edit-role-submit"
|
||||
@@ -3732,6 +3868,63 @@ function RolesPanel() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirmRemoval && permissionsImpact && permissionsImpact.totalAffectedUsers > 0 && (
|
||||
<div className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-[60] flex items-center justify-center p-4">
|
||||
<div
|
||||
className="glass-panel rounded-3xl p-6 w-full max-w-md space-y-3"
|
||||
data-testid="role-perm-confirm-dialog"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<h2 className="font-semibold text-foreground">
|
||||
{t("admin.roles.confirmRemovalTitle")}
|
||||
</h2>
|
||||
<p className="text-sm text-foreground">
|
||||
{t("admin.roles.confirmRemovalBody", {
|
||||
count: permissionsImpact.totalAffectedUsers,
|
||||
})}
|
||||
</p>
|
||||
<ul
|
||||
className="text-xs text-muted-foreground space-y-1 ps-4 list-disc max-h-40 overflow-y-auto"
|
||||
>
|
||||
{permissionsImpact.removed.map((item) => (
|
||||
<li key={item.permissionId}>
|
||||
<span className="font-mono text-foreground">{item.permissionName}</span>
|
||||
{": "}
|
||||
{t("admin.roles.impactPerPermission", {
|
||||
users: item.userCount,
|
||||
groups: item.groupCount,
|
||||
})}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => setConfirmRemoval(false)}
|
||||
data-testid="role-perm-confirm-cancel"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="flex-1"
|
||||
disabled={updateRole.isPending || replaceRolePermissions.isPending}
|
||||
onClick={handleEdit}
|
||||
data-testid="role-perm-confirm-submit"
|
||||
>
|
||||
{updateRole.isPending || replaceRolePermissions.isPending ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
t("admin.roles.confirmRemovalAction")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deleteTarget && (
|
||||
<div className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
<div className="glass-panel rounded-3xl p-6 w-full max-w-sm space-y-3" data-testid="delete-role-dialog">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<RolePermissionsImpact> => {
|
||||
return customFetch<RolePermissionsImpact>(
|
||||
getPreviewRolePermissionsImpactUrl(id),
|
||||
{
|
||||
...options,
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...options?.headers },
|
||||
body: JSON.stringify(rolePermissionsImpactBody),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const getPreviewRolePermissionsImpactMutationOptions = <
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof previewRolePermissionsImpact>>,
|
||||
TError,
|
||||
{ id: number; data: BodyType<RolePermissionsImpactBody> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof previewRolePermissionsImpact>>,
|
||||
TError,
|
||||
{ id: number; data: BodyType<RolePermissionsImpactBody> },
|
||||
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<ReturnType<typeof previewRolePermissionsImpact>>,
|
||||
{ id: number; data: BodyType<RolePermissionsImpactBody> }
|
||||
> = (props) => {
|
||||
const { id, data } = props ?? {};
|
||||
|
||||
return previewRolePermissionsImpact(id, data, requestOptions);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type PreviewRolePermissionsImpactMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof previewRolePermissionsImpact>>
|
||||
>;
|
||||
export type PreviewRolePermissionsImpactMutationBody =
|
||||
BodyType<RolePermissionsImpactBody>;
|
||||
export type PreviewRolePermissionsImpactMutationError =
|
||||
ErrorType<ErrorResponse>;
|
||||
|
||||
/**
|
||||
* @summary Preview the impact of changing a role's permission set (admin)
|
||||
*/
|
||||
export const usePreviewRolePermissionsImpact = <
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof previewRolePermissionsImpact>>,
|
||||
TError,
|
||||
{ id: number; data: BodyType<RolePermissionsImpactBody> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof previewRolePermissionsImpact>>,
|
||||
TError,
|
||||
{ id: number; data: BodyType<RolePermissionsImpactBody> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getPreviewRolePermissionsImpactMutationOptions(options));
|
||||
};
|
||||
|
||||
/**
|
||||
* @summary List permissions assigned to a role (admin)
|
||||
*/
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user