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
+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)
*/