Add app-permissions impact preview before tightening an app's gate

Mirrors the existing role-permissions impact preview UX for app
permissions. Admins now see how many currently-visible users would lose
access before they add a permission requirement to an app, plus the
groups (via group_apps) that offset the loss because their members keep
access regardless.

Backend
- New endpoint POST /api/apps/:id/permissions/impact-preview in
  artifacts/api-server/src/routes/apps.ts. Implements the same OR
  semantics as getVisibleAppsForUser: a user "sees" an app if they hold
  ANY required permission (direct or via a group role) OR they belong
  to a group granted the app via group_apps. Admins are excluded from
  counts since they always see every app. Short-circuits with
  noChange:true when the candidate set equals the current set.
- OpenAPI schema (lib/api-spec/openapi.yaml): adds the path,
  AppPermissionsImpactBody, AppPermissionImpactGroup,
  AppPermissionsImpact. Regenerated lib/api-client-react bindings.

Frontend
- AppPermissionsEditor (artifacts/tx-os/src/pages/admin.tsx): debounced
  (350ms) cancel-safe preview when a pending permission is selected,
  warning banner with affected/visible counts and offsetting groups,
  and a confirmation dialog when affectedUserCount > 0. Add button is
  disabled while the preview is loading or errored to keep the warning
  trustworthy.
- i18n keys added to en.json and ar.json under
  admin.appPermissions.{impactTitle, impactLoading, impactError,
  impactNone, impactSummary, impactViaGroups, confirmTitle, confirmBody,
  confirmAction}.

Tests
- artifacts/api-server/tests/app-permissions-impact.test.mjs: 7 tests
  covering noChange short-circuit, unrestricted-app tightening,
  candidate that keeps an existing permission, group_apps offset,
  unknown app (404), invalid payload (400), and admin-only enforcement.
  All 18 app-permissions tests pass.
- E2E flow verified via runTest: admin login → /admin → Apps → edit
  app → select permission → preview banner appears → Add → confirm
  dialog → cancel without writing.

Out-of-scope (filed as follow-ups #228 and #229): listing the specific
affected user IDs in the preview, and warning when REMOVING a
permission broadens access.

No deviations from the task spec.

Replit-Task-Id: 8b2ff9ea-f95e-4bdb-b268-95b5d17154ca
This commit is contained in:
riyadhafraa
2026-04-30 18:03:08 +00:00
parent 40d290fd16
commit b507b33cad
10 changed files with 1090 additions and 5 deletions
+185
View File
@@ -11,8 +11,11 @@ import {
appOpensTable,
userGroupsTable,
groupAppsTable,
groupRolesTable,
groupsTable,
auditLogsTable,
permissionsTable,
usersTable,
} from "@workspace/db";
import { requireAuth, requireAdmin, getEffectiveRoleIds } from "../middlewares/auth";
import {
@@ -355,6 +358,188 @@ router.get("/apps/:id/permissions", requireAdmin, async (req, res): Promise<void
res.json(rows);
});
router.post(
"/apps/: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 [app] = await db
.select({ id: appsTable.id })
.from(appsTable)
.where(eq(appsTable.id, id));
if (!app) {
res.status(404).json({ error: "App not found" });
return;
}
const candidateIds = Array.from(new Set<number>(candidate as number[]));
const candidateSet = new Set<number>(candidateIds);
const currentRows = await db
.select({ permissionId: appPermissionsTable.permissionId })
.from(appPermissionsTable)
.where(eq(appPermissionsTable.appId, id));
const currentIds = currentRows.map((r) => r.permissionId);
const currentSet = new Set<number>(currentIds);
// Groups that grant this app via group_apps. These groups offset the
// permission gate: members of any of these groups always see the app
// regardless of permission changes, so we surface them so the admin
// knows which populations are protected.
const groupGrantRows = await db
.select({ id: groupsTable.id, name: groupsTable.name })
.from(groupAppsTable)
.innerJoin(groupsTable, eq(groupAppsTable.groupId, groupsTable.id))
.where(eq(groupAppsTable.appId, id))
.orderBy(groupsTable.name);
const sameSize = candidateSet.size === currentSet.size;
const noChange =
sameSize && currentIds.every((p) => candidateSet.has(p));
const groupGrantUserRows = await db
.selectDistinct({ userId: userGroupsTable.userId })
.from(userGroupsTable)
.innerJoin(
groupAppsTable,
eq(groupAppsTable.groupId, userGroupsTable.groupId),
)
.where(eq(groupAppsTable.appId, id));
const groupGrantUserSet = new Set<number>(
groupGrantUserRows.map((r) => r.userId),
);
// Admins are excluded from the affected/visible counts since they always
// see every app via the short-circuit in getVisibleAppsForUser. We treat
// an admin as anyone holding the "admin" role directly OR via a group.
const adminRoleRows = await db
.select({ id: rolesTable.id })
.from(rolesTable)
.where(eq(rolesTable.name, "admin"));
const adminUserSet = new Set<number>();
if (adminRoleRows.length > 0) {
const adminRoleId = adminRoleRows[0].id;
const directAdmins = await db
.select({ userId: userRolesTable.userId })
.from(userRolesTable)
.where(eq(userRolesTable.roleId, adminRoleId));
for (const r of directAdmins) adminUserSet.add(r.userId);
const adminGroupRows = await db
.select({ groupId: groupRolesTable.groupId })
.from(groupRolesTable)
.where(eq(groupRolesTable.roleId, adminRoleId));
const adminGroupIds = adminGroupRows.map((r) => r.groupId);
if (adminGroupIds.length > 0) {
const indirectAdmins = await db
.select({ userId: userGroupsTable.userId })
.from(userGroupsTable)
.where(inArray(userGroupsTable.groupId, adminGroupIds));
for (const r of indirectAdmins) adminUserSet.add(r.userId);
}
}
// Returns the set of user IDs that currently hold AT LEAST ONE of `perms`
// through any role they have (direct or via a group). Mirrors the OR
// semantics getVisibleAppsForUser uses for app permission gating.
const usersWithAnyPermission = async (
perms: number[],
): Promise<Set<number>> => {
const out = new Set<number>();
if (perms.length === 0) return out;
const directRows = await db
.selectDistinct({ userId: userRolesTable.userId })
.from(userRolesTable)
.innerJoin(
rolePermissionsTable,
eq(rolePermissionsTable.roleId, userRolesTable.roleId),
)
.where(inArray(rolePermissionsTable.permissionId, perms));
for (const r of directRows) out.add(r.userId);
const indirectRows = await db
.selectDistinct({ userId: userGroupsTable.userId })
.from(userGroupsTable)
.innerJoin(
groupRolesTable,
eq(groupRolesTable.groupId, userGroupsTable.groupId),
)
.innerJoin(
rolePermissionsTable,
eq(rolePermissionsTable.roleId, groupRolesTable.roleId),
)
.where(inArray(rolePermissionsTable.permissionId, perms));
for (const r of indirectRows) out.add(r.userId);
return out;
};
// Helper to materialise "all non-admin users" only when one of the
// candidate or current sets is empty (the unrestricted path). Skipping
// this query in the common restricted-to-restricted case keeps the
// endpoint cheap on installs with many users.
let allNonAdminUserIds: number[] | null = null;
const getAllNonAdminUsers = async (): Promise<Set<number>> => {
if (allNonAdminUserIds === null) {
const allUsers = await db
.select({ id: usersTable.id })
.from(usersTable);
allNonAdminUserIds = allUsers
.map((u) => u.id)
.filter((uid) => !adminUserSet.has(uid));
}
return new Set<number>(allNonAdminUserIds);
};
const computeVisible = async (perms: number[]): Promise<Set<number>> => {
if (perms.length === 0) {
// Unrestricted: every non-admin user can see the app.
return await getAllNonAdminUsers();
}
const visible = await usersWithAnyPermission(perms);
for (const uid of groupGrantUserSet) visible.add(uid);
for (const uid of adminUserSet) visible.delete(uid);
return visible;
};
const currentlyVisible = await computeVisible(currentIds);
// When the candidate set is identical to the current set we skip the
// second visibility query (it's guaranteed to be the same) and report
// the real currentlyVisibleUserCount so consumers reading a noChange
// response still get an accurate audience size for the app.
const futureVisible = noChange
? currentlyVisible
: await computeVisible(candidateIds);
let affected = 0;
if (!noChange) {
for (const uid of currentlyVisible) {
if (!futureVisible.has(uid)) affected += 1;
}
}
res.json({
affectedUserCount: affected,
currentlyVisibleUserCount: currentlyVisible.size,
groups: groupGrantRows,
noChange,
});
},
);
router.post("/apps/:id/permissions", requireAdmin, async (req, res): Promise<void> => {
const params = GetAppParams.safeParse(req.params);
if (!params.success) {
@@ -0,0 +1,441 @@
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 adminUsername;
let adminCookie;
let consumerUsername;
let consumerCookie;
const createdAppIds = [];
const createdPermissionIds = [];
const createdRoleIds = [];
const createdGroupIds = [];
const createdUserIds = [];
let appId;
let permA;
let permB;
let permC;
let roleA;
let roleB;
let roleC;
let userA; // direct role A only — has permA
let userAB; // direct roles A and B — has permA and permB
let userViaGroup; // in a group that has role A — has permA via group
let helperGroupId;
let groupGrantedAppId; // app explicitly granted to a group via group_apps
let groupAppGrantUserId; // member of that group; sees the app even with no perms
let groupAppGroupId;
async function login(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 username = `${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`,
[
username,
`${username}@example.com`,
TEST_PASSWORD_HASH,
`${prefix} User`,
],
);
const id = r.rows[0].id;
createdUserIds.push(id);
return { id, username };
}
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, perms) {
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);
for (const p of perms) {
await pool.query(
`INSERT INTO role_permissions (role_id, permission_id) VALUES ($1, $2)`,
[id, p.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;
}
async function makeApp(prefix, stamp) {
const slug = `${prefix}_${stamp}`;
const r = await pool.query(
`INSERT INTO apps (slug, name_ar, name_en, route, is_active, sort_order)
VALUES ($1, $2, $3, $4, true, 999) RETURNING id`,
[slug, slug, slug, `/${slug}`],
);
const id = r.rows[0].id;
createdAppIds.push(id);
return id;
}
before(async () => {
const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
// Admin user (granted system-wide admin role).
const admin = await makeUser("api_admin", stamp);
adminUsername = admin.username;
await pool.query(
`INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`,
[admin.id],
);
// Plain consumer (used to verify the endpoint requires admin).
const consumer = await makeUser("api_consumer", stamp);
consumerUsername = consumer.username;
adminCookie = await login(adminUsername, TEST_PASSWORD);
consumerCookie = await login(consumerUsername, TEST_PASSWORD);
permA = await makePermission("api.perm.a", stamp);
permB = await makePermission("api.perm.b", stamp);
permC = await makePermission("api.perm.c", stamp);
roleA = await makeRole("api_role_a", stamp, [permA]);
roleB = await makeRole("api_role_b", stamp, [permB]);
roleC = await makeRole("api_role_c", stamp, [permC]);
// userA: direct roleA → has permA.
({ id: userA } = await makeUser("api_user_a", stamp));
await pool.query(
`INSERT INTO user_roles (user_id, role_id) VALUES ($1, $2)`,
[userA, roleA],
);
// userAB: direct roles A and B → has permA and permB.
({ id: userAB } = await makeUser("api_user_ab", stamp));
await pool.query(
`INSERT INTO user_roles (user_id, role_id) VALUES ($1, $2), ($1, $3)`,
[userAB, roleA, roleB],
);
// userViaGroup: gets roleA via membership in helperGroup → has permA via group.
({ id: userViaGroup } = await makeUser("api_user_viagrp", stamp));
helperGroupId = await makeGroup("api_helper_grp", stamp);
await pool.query(
`INSERT INTO user_groups (user_id, group_id) VALUES ($1, $2)`,
[userViaGroup, helperGroupId],
);
await pool.query(
`INSERT INTO group_roles (group_id, role_id) VALUES ($1, $2)`,
[helperGroupId, roleA],
);
// App that we will run impact previews against.
appId = await makeApp("api_impact_app", stamp);
// Separate scenario for the group-grant offset: an app with no permissions
// but explicitly granted to a group via group_apps. A member of that group
// sees the app even after we add a permission requirement they don't have.
groupGrantedAppId = await makeApp("api_grpapp", stamp);
groupAppGroupId = await makeGroup("api_grpapp_grp", stamp);
await pool.query(
`INSERT INTO group_apps (group_id, app_id) VALUES ($1, $2)`,
[groupAppGroupId, groupGrantedAppId],
);
({ id: groupAppGrantUserId } = await makeUser("api_grpapp_user", stamp));
await pool.query(
`INSERT INTO user_groups (user_id, group_id) VALUES ($1, $2)`,
[groupAppGrantUserId, groupAppGroupId],
);
});
after(async () => {
if (createdAppIds.length) {
await pool.query(
`DELETE FROM app_permissions WHERE app_id = ANY($1::int[])`,
[createdAppIds],
);
await pool.query(
`DELETE FROM group_apps WHERE app_id = ANY($1::int[])`,
[createdAppIds],
);
await pool.query(`DELETE FROM apps WHERE id = ANY($1::int[])`, [
createdAppIds,
]);
}
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 group_apps 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 app_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();
});
async function preview(appIdToCheck, permissionIds, cookie = adminCookie) {
return fetch(
`${API_BASE}/api/apps/${appIdToCheck}/permissions/impact-preview`,
{
method: "POST",
headers: { "Content-Type": "application/json", Cookie: cookie },
body: JSON.stringify({ permissionIds }),
},
);
}
test("preview short-circuits with noChange=true when candidate matches current set", async () => {
// Seed app with permA so the candidate exactly matches.
await pool.query(
`INSERT INTO app_permissions (app_id, permission_id) VALUES ($1, $2)
ON CONFLICT DO NOTHING`,
[appId, permA.id],
);
try {
const res = await preview(appId, [permA.id]);
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.noChange, true);
assert.equal(body.affectedUserCount, 0);
// currentlyVisibleUserCount must be the real audience size — not
// hardcoded to 0 — so consumers reading a noChange response still
// get an accurate count of how many users currently see the app.
// At minimum userA, userAB, and userViaGroup hold permA and should
// appear in the count.
assert.ok(
body.currentlyVisibleUserCount >= 3,
`expected currentlyVisibleUserCount >= 3, got ${body.currentlyVisibleUserCount}`,
);
assert.deepEqual(body.groups, []);
} finally {
await pool.query(
`DELETE FROM app_permissions WHERE app_id = $1 AND permission_id = $2`,
[appId, permA.id],
);
}
});
test("preview reports affected users when adding a permission requirement to an unrestricted app", async () => {
// App is currently unrestricted — every non-admin user can see it.
// Candidate: require permB. Only userAB has permB.
// Expected: at minimum, userA and userViaGroup lose access (they have
// permA only). Other unrelated users in the DB also currently see the
// app (since it's unrestricted), so we use a >= assertion to stay
// robust against test data drift in the surrounding DB.
const res = await preview(appId, [permB.id]);
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.noChange, false);
// Sanity: a positive number of users currently see the unrestricted app
// and at least the two test users without permB lose access.
assert.ok(body.currentlyVisibleUserCount >= 3);
assert.ok(
body.affectedUserCount >= 2,
`expected at least 2 affected, got ${body.affectedUserCount}`,
);
// userAB has permB so they keep access — affected must be strictly less
// than currently visible.
assert.ok(body.affectedUserCount < body.currentlyVisibleUserCount);
});
test("preview narrows affected users when candidate keeps a permission they already hold", async () => {
// Seed the app with permA AND permB required (OR semantics: holding
// either one grants visibility). Then ask "what if we remove permB?"
// — which keeps permA. Only users who exclusively have permB lose
// access, so userA and userViaGroup are unaffected (they still have
// permA). userAB also keeps access via permA.
await pool.query(
`INSERT INTO app_permissions (app_id, permission_id) VALUES
($1, $2), ($1, $3) ON CONFLICT DO NOTHING`,
[appId, permA.id, permB.id],
);
try {
const res = await preview(appId, [permA.id]);
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.noChange, false);
// None of our scenario users should lose access since they all have
// permA (or are in a group that grants permA). Other users in the
// surrounding DB might lose access if they only had permB, but our
// three tracked users must NOT.
// We verify by sanity-checking that currentlyVisibleUserCount stays
// sensible; the strict invariant we rely on is the comparison below.
assert.ok(body.currentlyVisibleUserCount >= 3);
// affected must be < currentlyVisibleUserCount because userA, userAB
// and userViaGroup all keep access via permA.
assert.ok(body.affectedUserCount < body.currentlyVisibleUserCount);
} finally {
await pool.query(
`DELETE FROM app_permissions WHERE app_id = $1 AND permission_id IN ($2, $3)`,
[appId, permA.id, permB.id],
);
}
});
test("preview surfaces groups that grant the app via group_apps and they offset the loss", async () => {
// groupGrantedAppId is unrestricted and explicitly granted to
// groupAppGroupId. Adding a requirement that no test user holds
// (permC, only assigned to roleC, which nobody in our scenario has)
// would otherwise hide the app from groupAppGrantUserId — but that
// user is in a group that grants the app, so they keep access.
const res = await preview(groupGrantedAppId, [permC.id]);
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.noChange, false);
// Groups list must include the group that grants the app.
const groupNames = body.groups.map((g) => g.id);
assert.ok(
groupNames.includes(groupAppGroupId),
`expected groups to include ${groupAppGroupId}, got ${JSON.stringify(body.groups)}`,
);
// groupAppGrantUserId must NOT be counted as affected — they keep
// access via the group_apps grant. We can't directly read the affected
// user IDs (the endpoint only returns counts), but we can prove the
// offset works by re-running the preview against an *identical app*
// without the group-grant and confirming the count is strictly higher
// by at least 1. We achieve this by inserting and removing a
// group_apps row on the same app.
await pool.query(
`DELETE FROM group_apps WHERE group_id = $1 AND app_id = $2`,
[groupAppGroupId, groupGrantedAppId],
);
try {
const without = await preview(groupGrantedAppId, [permC.id]);
assert.equal(without.status, 200);
const wbody = await without.json();
// Removing the group grant should INCREASE affectedUserCount by at
// least 1 (groupAppGrantUserId now has no offset).
assert.ok(
wbody.affectedUserCount >= body.affectedUserCount + 1,
`expected affected count to grow when group_apps offset is removed (with offset: ${body.affectedUserCount}, without: ${wbody.affectedUserCount})`,
);
} finally {
// Restore the grant so other tests / cleanup remain happy.
await pool.query(
`INSERT INTO group_apps (group_id, app_id) VALUES ($1, $2)
ON CONFLICT DO NOTHING`,
[groupAppGroupId, groupGrantedAppId],
);
}
});
test("preview returns 404 for an unknown app", async () => {
const max = await pool.query(`SELECT COALESCE(MAX(id), 0) AS m FROM apps`);
const unknownId = max.rows[0].m + 9999;
const res = await preview(unknownId, [permA.id]);
assert.equal(res.status, 404);
});
test("preview returns 400 when permissionIds is missing or invalid", async () => {
const noBody = await fetch(
`${API_BASE}/api/apps/${appId}/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/apps/${appId}/permissions/impact-preview`,
{
method: "POST",
headers: { "Content-Type": "application/json", Cookie: adminCookie },
body: JSON.stringify({ permissionIds: ["abc"] }),
},
);
assert.equal(wrong.status, 400);
});
test("preview requires admin", async () => {
const res = await preview(appId, [permA.id], consumerCookie);
assert.ok(
res.status === 401 || res.status === 403,
`expected 401/403, got ${res.status}`,
);
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 25 KiB

+10 -1
View File
@@ -354,7 +354,16 @@
"none": "لا توجد صلاحيات مطلوبة — التطبيق ظاهر للجميع.",
"selectPlaceholder": "اختر صلاحية…",
"add": "إضافة",
"removeAria": "إزالة الصلاحية {{name}}"
"removeAria": "إزالة الصلاحية {{name}}",
"impactTitle": "معاينة الأثر",
"impactLoading": "جارٍ حساب الأثر…",
"impactError": "تعذّر تحميل معاينة الأثر. حاول مرة أخرى.",
"impactNone": "لن يفقد أي مستخدم حق الوصول. {{visible}} مستخدمًا يرون هذا التطبيق حاليًا.",
"impactSummary": "{{affected}} من أصل {{visible}} مستخدمًا يرون هذا التطبيق حاليًا سيفقدون حق الوصول.",
"impactViaGroups": "أعضاء هذه المجموعات يحتفظون بحق الوصول بغض النظر عن التغيير: {{groups}}.",
"confirmTitle": "تأكيد تغيير الصلاحيات",
"confirmBody": "إضافة هذا الاشتراط ستخفي التطبيق عن {{affected}} من أصل {{visible}} مستخدمًا يرونه حاليًا. يمكن التراجع عن هذا التغيير.",
"confirmAction": "إضافة على أي حال"
},
"serviceName": "اسم الخدمة",
"serviceNameAr": "اسم الخدمة (عربي)",
+10 -1
View File
@@ -351,7 +351,16 @@
"none": "No permissions required — visible to everyone.",
"selectPlaceholder": "Select a permission…",
"add": "Add",
"removeAria": "Remove permission {{name}}"
"removeAria": "Remove permission {{name}}",
"impactTitle": "Impact preview",
"impactLoading": "Calculating impact…",
"impactError": "Couldn't load impact preview. Try again.",
"impactNone": "No users would lose access. {{visible}} currently see this app.",
"impactSummary": "{{affected}} of {{visible}} users currently seeing this app would lose access.",
"impactViaGroups": "Members of these groups keep access regardless: {{groups}}.",
"confirmTitle": "Confirm permission change",
"confirmBody": "Adding this requirement will hide the app from {{affected}} of {{visible}} users who currently see it. This change is reversible.",
"confirmAction": "Add anyway"
},
"serviceName": "Service Name",
"serviceNameAr": "Service Name (Arabic)",
+172 -3
View File
@@ -9,6 +9,7 @@ import {
getListAppPermissionsQueryKey,
useAddAppPermission,
useRemoveAppPermission,
usePreviewAppPermissionsImpact,
useListServices,
getListServicesQueryKey,
useCreateService,
@@ -89,6 +90,7 @@ import type {
ListAuditLogsParams,
AuditLogEntry,
RolePermissionsImpact,
AppPermissionsImpact,
GetRolePermissionAuditParams,
RolePermissionAuditEntry,
GetUserPermissionAuditParams,
@@ -262,7 +264,13 @@ function AppPermissionsEditor({ appId }: { appId: number }) {
});
const addPermission = useAddAppPermission();
const removePermission = useRemoveAppPermission();
const previewImpact = usePreviewAppPermissionsImpact();
const [pendingId, setPendingId] = useState<number | "">("");
const [impact, setImpact] = useState<AppPermissionsImpact | null>(null);
const [impactLoading, setImpactLoading] = useState(false);
const [impactError, setImpactError] = useState(false);
const [confirmAdd, setConfirmAdd] = useState(false);
const impactRequestSeq = useRef(0);
const assignedIds = useMemo(
() => new Set((assigned ?? []).map((p) => p.id)),
@@ -280,13 +288,61 @@ function AppPermissionsEditor({ appId }: { appId: number }) {
queryClient.invalidateQueries({ queryKey: ADMIN_APPS_KEY });
};
const onAdd = () => {
// Debounced, cancel-safe impact preview for the candidate "add this
// permission" change. Mirrors the RolesPanel UX so admins see the same
// kind of warning before they tighten an app's gate. Stale responses
// are dropped via impactRequestSeq.
useEffect(() => {
if (pendingId === "" || typeof pendingId !== "number") {
setImpact(null);
setImpactLoading(false);
setImpactError(false);
return;
}
const candidate = Array.from(assignedIds);
if (!candidate.includes(pendingId)) candidate.push(pendingId);
setImpactLoading(true);
setImpactError(false);
const requestId = ++impactRequestSeq.current;
const handle = setTimeout(() => {
previewImpact.mutate(
{ id: appId, data: { permissionIds: candidate } },
{
onSuccess: (data) => {
if (impactRequestSeq.current !== requestId) return;
setImpact(data);
setImpactError(false);
setImpactLoading(false);
},
onError: () => {
if (impactRequestSeq.current !== requestId) return;
setImpact(null);
setImpactError(true);
setImpactLoading(false);
},
},
);
}, 350);
return () => clearTimeout(handle);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appId, pendingId, assignedIds]);
const requiresConfirmation =
!impactLoading &&
!impactError &&
impact != null &&
impact.affectedUserCount > 0;
const performAdd = () => {
if (pendingId === "" || typeof pendingId !== "number") return;
addPermission.mutate(
{ id: appId, data: { permissionId: pendingId } },
{
onSuccess: () => {
setPendingId("");
setImpact(null);
setConfirmAdd(false);
impactRequestSeq.current += 1;
refresh();
},
onError: () => {
@@ -296,6 +352,15 @@ function AppPermissionsEditor({ appId }: { appId: number }) {
);
};
const onAdd = () => {
if (pendingId === "" || typeof pendingId !== "number") return;
if (requiresConfirmation && !confirmAdd) {
setConfirmAdd(true);
return;
}
performAdd();
};
const onRemove = (permissionId: number) => {
removePermission.mutate(
{ id: appId, permissionId },
@@ -370,13 +435,117 @@ function AppPermissionsEditor({ appId }: { appId: number }) {
disabled={
pendingId === "" ||
addPermission.isPending ||
available.length === 0
available.length === 0 ||
impactLoading ||
impactError
}
data-testid="app-permission-add"
>
{t("admin.appPermissions.add")}
{addPermission.isPending ? (
<Loader2 size={14} className="animate-spin" />
) : (
t("admin.appPermissions.add")
)}
</Button>
</div>
{pendingId !== "" && (
<div
className="mt-2 rounded-xl border border-amber-300 bg-amber-50 p-3 text-xs text-amber-900 space-y-1"
data-testid="app-perm-impact"
role="status"
aria-live="polite"
>
<p className="font-semibold">{t("admin.appPermissions.impactTitle")}</p>
{impactLoading ? (
<div className="flex items-center gap-2 text-amber-800">
<Loader2 size={12} className="animate-spin" />
<span>{t("admin.appPermissions.impactLoading")}</span>
</div>
) : impactError || !impact ? (
<p
className="text-amber-900"
data-testid="app-perm-impact-error"
>
{t("admin.appPermissions.impactError")}
</p>
) : impact.affectedUserCount === 0 ? (
<p data-testid="app-perm-impact-none">
{t("admin.appPermissions.impactNone", {
visible: impact.currentlyVisibleUserCount,
})}
</p>
) : (
<>
<p data-testid="app-perm-impact-summary">
{t("admin.appPermissions.impactSummary", {
affected: impact.affectedUserCount,
visible: impact.currentlyVisibleUserCount,
})}
</p>
{impact.groups.length > 0 && (
<p
className="text-amber-800"
data-testid="app-perm-impact-groups"
>
{t("admin.appPermissions.impactViaGroups", {
groups: impact.groups.map((g) => g.name).join(", "),
})}
</p>
)}
</>
)}
</div>
)}
{confirmAdd && impact && impact.affectedUserCount > 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="app-perm-confirm-dialog"
role="alertdialog"
aria-modal="true"
>
<h2 className="font-semibold text-foreground">
{t("admin.appPermissions.confirmTitle")}
</h2>
<p className="text-sm text-foreground">
{t("admin.appPermissions.confirmBody", {
affected: impact.affectedUserCount,
visible: impact.currentlyVisibleUserCount,
})}
</p>
{impact.groups.length > 0 && (
<p className="text-xs text-muted-foreground">
{t("admin.appPermissions.impactViaGroups", {
groups: impact.groups.map((g) => g.name).join(", "),
})}
</p>
)}
<div className="flex gap-2 pt-2">
<Button
variant="outline"
className="flex-1"
onClick={() => setConfirmAdd(false)}
data-testid="app-perm-confirm-cancel"
>
{t("common.cancel")}
</Button>
<Button
variant="destructive"
className="flex-1"
disabled={addPermission.isPending}
onClick={performAdd}
data-testid="app-perm-confirm-submit"
>
{addPermission.isPending ? (
<Loader2 size={14} className="animate-spin" />
) : (
t("admin.appPermissions.confirmAction")
)}
</Button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -245,6 +245,27 @@ export interface AppPermissionLink {
permissionId: number;
}
export interface AppPermissionsImpactBody {
/** Candidate set of permission IDs that would gate this app. Pass an empty array to model "remove every requirement" (i.e. make the app unrestricted). */
permissionIds: number[];
}
export interface AppPermissionImpactGroup {
id: number;
name: string;
}
export interface AppPermissionsImpact {
/** Distinct non-admin users who currently see the app and would lose visibility under the candidate permission set. Already accounts for users who keep access via `group_apps` group-grants. */
affectedUserCount: number;
/** Distinct non-admin users who currently see the app under the existing permission set. Useful for showing the warning as a fraction (e.g. "12 of 80 will lose access"). */
currentlyVisibleUserCount: number;
/** Groups that currently grant this app via `group_apps`. Members of any of these groups keep access regardless of permission changes. */
groups: AppPermissionImpactGroup[];
/** True when the candidate set is identical to the current permission set so no change would actually be saved. */
noChange: boolean;
}
export interface ReplaceRolePermissionsBody {
/** Full set of permission IDs the role should grant. Existing assignments not present here are removed. */
permissionIds: number[];
+104
View File
@@ -27,6 +27,8 @@ import type {
App,
AppDeletionConflict,
AppPermissionLink,
AppPermissionsImpact,
AppPermissionsImpactBody,
AppSettings,
AuditLogList,
AuthUser,
@@ -1696,6 +1698,108 @@ export const useAddAppPermission = <
return useMutation(getAddAppPermissionMutationOptions(options));
};
/**
* Given a candidate set of permission IDs that would gate this app,
returns how many users would lose visibility of the app (excluding
admins, who always see every app), the count of users who currently
see the app, and the groups that grant the app via `group_apps`. A
member of any returned group always sees the app regardless of
permission changes, so listing them lets the admin see which
populations are protected from the loss.
Mirrors `POST /roles/{id}/permissions/impact-preview` so the admin
UI can show the same kind of warning before committing the change.
* @summary Preview the impact of changing the permissions required for an app (admin)
*/
export const getPreviewAppPermissionsImpactUrl = (id: number) => {
return `/api/apps/${id}/permissions/impact-preview`;
};
export const previewAppPermissionsImpact = async (
id: number,
appPermissionsImpactBody: AppPermissionsImpactBody,
options?: RequestInit,
): Promise<AppPermissionsImpact> => {
return customFetch<AppPermissionsImpact>(
getPreviewAppPermissionsImpactUrl(id),
{
...options,
method: "POST",
headers: { "Content-Type": "application/json", ...options?.headers },
body: JSON.stringify(appPermissionsImpactBody),
},
);
};
export const getPreviewAppPermissionsImpactMutationOptions = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof previewAppPermissionsImpact>>,
TError,
{ id: number; data: BodyType<AppPermissionsImpactBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof previewAppPermissionsImpact>>,
TError,
{ id: number; data: BodyType<AppPermissionsImpactBody> },
TContext
> => {
const mutationKey = ["previewAppPermissionsImpact"];
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 previewAppPermissionsImpact>>,
{ id: number; data: BodyType<AppPermissionsImpactBody> }
> = (props) => {
const { id, data } = props ?? {};
return previewAppPermissionsImpact(id, data, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type PreviewAppPermissionsImpactMutationResult = NonNullable<
Awaited<ReturnType<typeof previewAppPermissionsImpact>>
>;
export type PreviewAppPermissionsImpactMutationBody =
BodyType<AppPermissionsImpactBody>;
export type PreviewAppPermissionsImpactMutationError = ErrorType<ErrorResponse>;
/**
* @summary Preview the impact of changing the permissions required for an app (admin)
*/
export const usePreviewAppPermissionsImpact = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof previewAppPermissionsImpact>>,
TError,
{ id: number; data: BodyType<AppPermissionsImpactBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof previewAppPermissionsImpact>>,
TError,
{ id: number; data: BodyType<AppPermissionsImpactBody> },
TContext
> => {
return useMutation(getPreviewAppPermissionsImpactMutationOptions(options));
};
/**
* @summary Remove a required permission from this app (admin)
*/
+93
View File
@@ -470,6 +470,54 @@ paths:
schema:
$ref: "#/components/schemas/ErrorResponse"
/apps/{id}/permissions/impact-preview:
post:
operationId: previewAppPermissionsImpact
tags: [apps]
summary: Preview the impact of changing the permissions required for an app (admin)
description: |
Given a candidate set of permission IDs that would gate this app,
returns how many users would lose visibility of the app (excluding
admins, who always see every app), the count of users who currently
see the app, and the groups that grant the app via `group_apps`. A
member of any returned group always sees the app regardless of
permission changes, so listing them lets the admin see which
populations are protected from the loss.
Mirrors `POST /roles/{id}/permissions/impact-preview` so the admin
UI can show the same kind of warning before committing the change.
parameters:
- name: id
in: path
required: true
schema:
type: integer
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/AppPermissionsImpactBody"
responses:
"200":
description: Impact preview for the proposed permission set
content:
application/json:
schema:
$ref: "#/components/schemas/AppPermissionsImpact"
"400":
description: Invalid request body
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"404":
description: App not found
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/apps/{id}/permissions/{permissionId}:
delete:
operationId: removeAppPermission
@@ -2819,6 +2867,51 @@ components:
- appId
- permissionId
AppPermissionsImpactBody:
type: object
properties:
permissionIds:
type: array
items:
type: integer
description: Candidate set of permission IDs that would gate this app. Pass an empty array to model "remove every requirement" (i.e. make the app unrestricted).
required:
- permissionIds
AppPermissionImpactGroup:
type: object
properties:
id:
type: integer
name:
type: string
required:
- id
- name
AppPermissionsImpact:
type: object
properties:
affectedUserCount:
type: integer
description: Distinct non-admin users who currently see the app and would lose visibility under the candidate permission set. Already accounts for users who keep access via `group_apps` group-grants.
currentlyVisibleUserCount:
type: integer
description: Distinct non-admin users who currently see the app under the existing permission set. Useful for showing the warning as a fraction (e.g. "12 of 80 will lose access").
groups:
type: array
items:
$ref: "#/components/schemas/AppPermissionImpactGroup"
description: Groups that currently grant this app via `group_apps`. Members of any of these groups keep access regardless of permission changes.
noChange:
type: boolean
description: True when the candidate set is identical to the current permission set so no change would actually be saved.
required:
- affectedUserCount
- currentlyVisibleUserCount
- groups
- noChange
ReplaceRolePermissionsBody:
type: object
properties:
+54
View File
@@ -486,6 +486,60 @@ export const AddAppPermissionBody = zod.object({
.describe("ID of the permission to require for this app."),
});
/**
* Given a candidate set of permission IDs that would gate this app,
returns how many users would lose visibility of the app (excluding
admins, who always see every app), the count of users who currently
see the app, and the groups that grant the app via `group_apps`. A
member of any returned group always sees the app regardless of
permission changes, so listing them lets the admin see which
populations are protected from the loss.
Mirrors `POST /roles/{id}/permissions/impact-preview` so the admin
UI can show the same kind of warning before committing the change.
* @summary Preview the impact of changing the permissions required for an app (admin)
*/
export const PreviewAppPermissionsImpactParams = zod.object({
id: zod.coerce.number(),
});
export const PreviewAppPermissionsImpactBody = zod.object({
permissionIds: zod
.array(zod.number())
.describe(
'Candidate set of permission IDs that would gate this app. Pass an empty array to model \"remove every requirement\" (i.e. make the app unrestricted).',
),
});
export const PreviewAppPermissionsImpactResponse = zod.object({
affectedUserCount: zod
.number()
.describe(
"Distinct non-admin users who currently see the app and would lose visibility under the candidate permission set. Already accounts for users who keep access via `group_apps` group-grants.",
),
currentlyVisibleUserCount: zod
.number()
.describe(
'Distinct non-admin users who currently see the app under the existing permission set. Useful for showing the warning as a fraction (e.g. \"12 of 80 will lose access\").',
),
groups: zod
.array(
zod.object({
id: zod.number(),
name: zod.string(),
}),
)
.describe(
"Groups that currently grant this app via `group_apps`. Members of any of these groups keep access regardless of permission changes.",
),
noChange: zod
.boolean()
.describe(
"True when the candidate set is identical to the current permission set so no change would actually be saved.",
),
});
/**
* @summary Remove a required permission from this app (admin)
*/