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:
@@ -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[];
|
||||
|
||||
@@ -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)
|
||||
*/
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user