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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user