Task #109: Admin UI to manage app required permissions
- Added 3 admin-only API endpoints in artifacts/api-server/src/routes/apps.ts:
- GET /api/apps/:id/permissions — list permissions gating an app
- POST /api/apps/:id/permissions — add a permission (idempotent via
onConflictDoNothing() on the (app_id, permission_id) composite PK)
- DELETE /api/apps/:id/permissions/:permissionId — remove (idempotent, 204)
- Documented the new endpoints in lib/api-spec/openapi.yaml with two new schemas
(AddAppPermissionBody, AppPermissionLink) and re-ran codegen.
- Added a "Required permissions" section (AppPermissionsEditor) to the existing
Edit App dialog in artifacts/tx-os/src/pages/admin.tsx, using the generated
hooks. The section is shown only when editing an existing app (it needs an
app id). Wired up admin.appPermissions.* i18n keys in en.json + ar.json.
- Added artifacts/api-server/tests/app-permissions-crud.test.mjs with 5 tests
(empty list, idempotent add, 404 on unknown app/perm, idempotent delete,
403 for non-admins). All 5 pass; related tests
(app-permissions-unique, apps-group-visibility, list-dependency-counts) still pass.
- Verified the new admin UI end-to-end with the testing skill: admin login,
open Edit App dialog, add/remove a required permission, and confirm the
section is hidden in the Add App dialog.
Notes / scope:
- Pre-existing duplicate rows in app_permissions had to be deduped and
`pnpm --filter @workspace/db run push` was run once so the composite PK
could be added (separate task "Re-run the Drizzle schema push" already
exists for this project-wide chore).
- No audit logging here — separate existing task already covers it.
- The "test" workflow shows a pre-existing ECONNREFUSED race; an existing
task already tracks making the test workflow wait for the API server.
Replit-Task-Id: 912bb163-6b6f-43d3-9934-41fc97519337
This commit is contained in:
@@ -235,6 +235,16 @@ export interface Permission {
|
||||
descriptionEn?: string | null;
|
||||
}
|
||||
|
||||
export interface AddAppPermissionBody {
|
||||
/** ID of the permission to require for this app. */
|
||||
permissionId: number;
|
||||
}
|
||||
|
||||
export interface AppPermissionLink {
|
||||
appId: number;
|
||||
permissionId: number;
|
||||
}
|
||||
|
||||
export interface ReplaceRolePermissionsBody {
|
||||
/** Full set of permission IDs the role should grant. Existing assignments not present here are removed. */
|
||||
permissionIds: number[];
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import type {
|
||||
AddAppPermissionBody,
|
||||
AddParticipantsBody,
|
||||
AddUserRoleBody,
|
||||
AdminAppOpensByApp,
|
||||
@@ -25,6 +26,7 @@ import type {
|
||||
AdminStats,
|
||||
App,
|
||||
AppDeletionConflict,
|
||||
AppPermissionLink,
|
||||
AppSettings,
|
||||
AuditLogList,
|
||||
AuthUser,
|
||||
@@ -1507,6 +1509,274 @@ export const useDeleteApp = <
|
||||
return useMutation(getDeleteAppMutationOptions(options));
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the permissions currently required for users to see this app
|
||||
in their launcher. An app with no rows here is unrestricted (visible
|
||||
to anyone). When more than one permission is configured, holding any
|
||||
one of them is sufficient.
|
||||
|
||||
* @summary List the permissions that gate this app (admin)
|
||||
*/
|
||||
export const getListAppPermissionsUrl = (id: number) => {
|
||||
return `/api/apps/${id}/permissions`;
|
||||
};
|
||||
|
||||
export const listAppPermissions = async (
|
||||
id: number,
|
||||
options?: RequestInit,
|
||||
): Promise<Permission[]> => {
|
||||
return customFetch<Permission[]>(getListAppPermissionsUrl(id), {
|
||||
...options,
|
||||
method: "GET",
|
||||
});
|
||||
};
|
||||
|
||||
export const getListAppPermissionsQueryKey = (id: number) => {
|
||||
return [`/api/apps/${id}/permissions`] as const;
|
||||
};
|
||||
|
||||
export const getListAppPermissionsQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listAppPermissions>>,
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
>(
|
||||
id: number,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listAppPermissions>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions, request: requestOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getListAppPermissionsQueryKey(id);
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof listAppPermissions>>
|
||||
> = ({ signal }) => listAppPermissions(id, { signal, ...requestOptions });
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!id,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listAppPermissions>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListAppPermissionsQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listAppPermissions>>
|
||||
>;
|
||||
export type ListAppPermissionsQueryError = ErrorType<ErrorResponse>;
|
||||
|
||||
/**
|
||||
* @summary List the permissions that gate this app (admin)
|
||||
*/
|
||||
|
||||
export function useListAppPermissions<
|
||||
TData = Awaited<ReturnType<typeof listAppPermissions>>,
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
>(
|
||||
id: number,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listAppPermissions>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListAppPermissionsQueryOptions(id, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a row to `app_permissions` for the (app_id, permission_id) pair.
|
||||
Uses `ON CONFLICT DO NOTHING` against the composite primary key so
|
||||
re-adding an existing pair is a no-op.
|
||||
|
||||
* @summary Add a required permission to this app (admin)
|
||||
*/
|
||||
export const getAddAppPermissionUrl = (id: number) => {
|
||||
return `/api/apps/${id}/permissions`;
|
||||
};
|
||||
|
||||
export const addAppPermission = async (
|
||||
id: number,
|
||||
addAppPermissionBody: AddAppPermissionBody,
|
||||
options?: RequestInit,
|
||||
): Promise<AppPermissionLink> => {
|
||||
return customFetch<AppPermissionLink>(getAddAppPermissionUrl(id), {
|
||||
...options,
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...options?.headers },
|
||||
body: JSON.stringify(addAppPermissionBody),
|
||||
});
|
||||
};
|
||||
|
||||
export const getAddAppPermissionMutationOptions = <
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof addAppPermission>>,
|
||||
TError,
|
||||
{ id: number; data: BodyType<AddAppPermissionBody> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof addAppPermission>>,
|
||||
TError,
|
||||
{ id: number; data: BodyType<AddAppPermissionBody> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ["addAppPermission"];
|
||||
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 addAppPermission>>,
|
||||
{ id: number; data: BodyType<AddAppPermissionBody> }
|
||||
> = (props) => {
|
||||
const { id, data } = props ?? {};
|
||||
|
||||
return addAppPermission(id, data, requestOptions);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type AddAppPermissionMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof addAppPermission>>
|
||||
>;
|
||||
export type AddAppPermissionMutationBody = BodyType<AddAppPermissionBody>;
|
||||
export type AddAppPermissionMutationError = ErrorType<ErrorResponse>;
|
||||
|
||||
/**
|
||||
* @summary Add a required permission to this app (admin)
|
||||
*/
|
||||
export const useAddAppPermission = <
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof addAppPermission>>,
|
||||
TError,
|
||||
{ id: number; data: BodyType<AddAppPermissionBody> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof addAppPermission>>,
|
||||
TError,
|
||||
{ id: number; data: BodyType<AddAppPermissionBody> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getAddAppPermissionMutationOptions(options));
|
||||
};
|
||||
|
||||
/**
|
||||
* @summary Remove a required permission from this app (admin)
|
||||
*/
|
||||
export const getRemoveAppPermissionUrl = (id: number, permissionId: number) => {
|
||||
return `/api/apps/${id}/permissions/${permissionId}`;
|
||||
};
|
||||
|
||||
export const removeAppPermission = async (
|
||||
id: number,
|
||||
permissionId: number,
|
||||
options?: RequestInit,
|
||||
): Promise<void> => {
|
||||
return customFetch<void>(getRemoveAppPermissionUrl(id, permissionId), {
|
||||
...options,
|
||||
method: "DELETE",
|
||||
});
|
||||
};
|
||||
|
||||
export const getRemoveAppPermissionMutationOptions = <
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof removeAppPermission>>,
|
||||
TError,
|
||||
{ id: number; permissionId: number },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof removeAppPermission>>,
|
||||
TError,
|
||||
{ id: number; permissionId: number },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ["removeAppPermission"];
|
||||
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 removeAppPermission>>,
|
||||
{ id: number; permissionId: number }
|
||||
> = (props) => {
|
||||
const { id, permissionId } = props ?? {};
|
||||
|
||||
return removeAppPermission(id, permissionId, requestOptions);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type RemoveAppPermissionMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof removeAppPermission>>
|
||||
>;
|
||||
|
||||
export type RemoveAppPermissionMutationError = ErrorType<ErrorResponse>;
|
||||
|
||||
/**
|
||||
* @summary Remove a required permission from this app (admin)
|
||||
*/
|
||||
export const useRemoveAppPermission = <
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof removeAppPermission>>,
|
||||
TError,
|
||||
{ id: number; permissionId: number },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof removeAppPermission>>,
|
||||
TError,
|
||||
{ id: number; permissionId: number },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getRemoveAppPermissionMutationOptions(options));
|
||||
};
|
||||
|
||||
/**
|
||||
* @summary Log an app open event for the current user
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user