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
+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>
);
}