Task #89: Permissions picker for roles in admin dashboard

Original task: let admins assign permissions to roles from the dashboard.

Changes:
- lib/api-spec/openapi.yaml: added Permission and ReplaceRolePermissionsBody
  schemas; added GET /permissions, expanded GET /roles/{id}/permissions to
  return full Permission objects, and added admin-guarded
  PUT /roles/{id}/permissions. Ran codegen to refresh generated hooks/zod.
- artifacts/api-server/src/routes/roles.ts:
  * Added a single isSystemRole helper and PROTECTED_ROLE_NAMES set.
  * Added serializePermission + GET /permissions.
  * Expanded GET /roles/:id/permissions (404 on missing role, full
    Permission objects).
  * New PUT /roles/:id/permissions: blocks system/protected roles
    (400 with code "system_role_permissions"), rejects non-positive /
    non-integer permissionIds with 400, validates all ids exist (404),
    deletes+inserts atomically in a transaction, and notifies role
    holders via emitAppsChangedToRoleHolders.
  * Hardened legacy POST /roles/:id/permissions and
    DELETE /roles/:id/permissions/:permissionId so they also reject
    system-role mutations with the same code, closing the bypass that
    the new endpoint was meant to prevent.
  * Re-used isSystemRole in PATCH/DELETE role flows.
- artifacts/tx-os/src/pages/admin.tsx: added a permissions checkbox list
  inside the role editor dialog, disabled (read-only) for system /
  protected roles with a hint. Save flow runs updateRole then
  replaceRolePermissions only when the set actually changed and the role
  is non-system. Front-end ROLES_PROTECTED_NAMES set mirrors the
  back-end fallback so admin/user/order_receiver are recognised even
  when the legacy DB column is 0 (also gates the role-card system badge
  and delete button).
- artifacts/tx-os/src/locales/en.json + ar.json: new translation keys
  for the permissions picker, system-role read-only hint, empty list,
  and error toasts.

Verification: tx-os and api-server typecheck clean. End-to-end test
passes: admin login → edit a created role → toggle/save permissions →
re-open and confirm round-trip → admin role dialog shows disabled
checkboxes and read-only hint → PUT on admin role returns 400
"system_role_permissions". curl regression checks: legacy POST/DELETE
on admin role return the same 400/code; PUT rejects float / negative /
string ids with 400.

Replit-Task-Id: 74180397-afdf-4489-976a-a6fe099684bd
This commit is contained in:
riyadhafraa
2026-04-27 12:58:16 +00:00
parent 1e9024e5a2
commit aa6866936d
9 changed files with 737 additions and 23 deletions
@@ -204,6 +204,20 @@ export interface RoleDeletionConflict {
groupCount: number;
}
export interface Permission {
id: number;
name: string;
/** @nullable */
descriptionAr?: string | null;
/** @nullable */
descriptionEn?: string | null;
}
export interface ReplaceRolePermissionsBody {
/** Full set of permission IDs the role should grant. Existing assignments not present here are removed. */
permissionIds: number[];
}
export interface Group {
id: number;
name: string;
+252
View File
@@ -58,7 +58,9 @@ import type {
LoginBody,
MessageWithSender,
Notification,
Permission,
RegisterBody,
ReplaceRolePermissionsBody,
RequestUploadUrlBody,
RequestUploadUrlResponse,
ResetPasswordBody,
@@ -4729,6 +4731,81 @@ export const useRemoveUserRole = <
return useMutation(getRemoveUserRoleMutationOptions(options));
};
/**
* @summary List all available permissions (admin)
*/
export const getListPermissionsUrl = () => {
return `/api/permissions`;
};
export const listPermissions = async (
options?: RequestInit,
): Promise<Permission[]> => {
return customFetch<Permission[]>(getListPermissionsUrl(), {
...options,
method: "GET",
});
};
export const getListPermissionsQueryKey = () => {
return [`/api/permissions`] as const;
};
export const getListPermissionsQueryOptions = <
TData = Awaited<ReturnType<typeof listPermissions>>,
TError = ErrorType<ErrorResponse>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listPermissions>>,
TError,
TData
>;
request?: SecondParameter<typeof customFetch>;
}) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListPermissionsQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof listPermissions>>> = ({
signal,
}) => listPermissions({ signal, ...requestOptions });
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listPermissions>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListPermissionsQueryResult = NonNullable<
Awaited<ReturnType<typeof listPermissions>>
>;
export type ListPermissionsQueryError = ErrorType<ErrorResponse>;
/**
* @summary List all available permissions (admin)
*/
export function useListPermissions<
TData = Awaited<ReturnType<typeof listPermissions>>,
TError = ErrorType<ErrorResponse>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listPermissions>>,
TError,
TData
>;
request?: SecondParameter<typeof customFetch>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListPermissionsQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary List roles (admin)
*/
@@ -5053,6 +5130,181 @@ export const useDeleteRole = <
return useMutation(getDeleteRoleMutationOptions(options));
};
/**
* @summary List permissions assigned to a role (admin)
*/
export const getGetRolePermissionsUrl = (id: number) => {
return `/api/roles/${id}/permissions`;
};
export const getRolePermissions = async (
id: number,
options?: RequestInit,
): Promise<Permission[]> => {
return customFetch<Permission[]>(getGetRolePermissionsUrl(id), {
...options,
method: "GET",
});
};
export const getGetRolePermissionsQueryKey = (id: number) => {
return [`/api/roles/${id}/permissions`] as const;
};
export const getGetRolePermissionsQueryOptions = <
TData = Awaited<ReturnType<typeof getRolePermissions>>,
TError = ErrorType<ErrorResponse>,
>(
id: number,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getRolePermissions>>,
TError,
TData
>;
request?: SecondParameter<typeof customFetch>;
},
) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetRolePermissionsQueryKey(id);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getRolePermissions>>
> = ({ signal }) => getRolePermissions(id, { signal, ...requestOptions });
return {
queryKey,
queryFn,
enabled: !!id,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getRolePermissions>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetRolePermissionsQueryResult = NonNullable<
Awaited<ReturnType<typeof getRolePermissions>>
>;
export type GetRolePermissionsQueryError = ErrorType<ErrorResponse>;
/**
* @summary List permissions assigned to a role (admin)
*/
export function useGetRolePermissions<
TData = Awaited<ReturnType<typeof getRolePermissions>>,
TError = ErrorType<ErrorResponse>,
>(
id: number,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getRolePermissions>>,
TError,
TData
>;
request?: SecondParameter<typeof customFetch>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetRolePermissionsQueryOptions(id, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Atomically replace the permissions assigned to a role (admin)
*/
export const getReplaceRolePermissionsUrl = (id: number) => {
return `/api/roles/${id}/permissions`;
};
export const replaceRolePermissions = async (
id: number,
replaceRolePermissionsBody: ReplaceRolePermissionsBody,
options?: RequestInit,
): Promise<Permission[]> => {
return customFetch<Permission[]>(getReplaceRolePermissionsUrl(id), {
...options,
method: "PUT",
headers: { "Content-Type": "application/json", ...options?.headers },
body: JSON.stringify(replaceRolePermissionsBody),
});
};
export const getReplaceRolePermissionsMutationOptions = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof replaceRolePermissions>>,
TError,
{ id: number; data: BodyType<ReplaceRolePermissionsBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof replaceRolePermissions>>,
TError,
{ id: number; data: BodyType<ReplaceRolePermissionsBody> },
TContext
> => {
const mutationKey = ["replaceRolePermissions"];
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 replaceRolePermissions>>,
{ id: number; data: BodyType<ReplaceRolePermissionsBody> }
> = (props) => {
const { id, data } = props ?? {};
return replaceRolePermissions(id, data, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type ReplaceRolePermissionsMutationResult = NonNullable<
Awaited<ReturnType<typeof replaceRolePermissions>>
>;
export type ReplaceRolePermissionsMutationBody =
BodyType<ReplaceRolePermissionsBody>;
export type ReplaceRolePermissionsMutationError = ErrorType<ErrorResponse>;
/**
* @summary Atomically replace the permissions assigned to a role (admin)
*/
export const useReplaceRolePermissions = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof replaceRolePermissions>>,
TError,
{ id: number; data: BodyType<ReplaceRolePermissionsBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof replaceRolePermissions>>,
TError,
{ id: number; data: BodyType<ReplaceRolePermissionsBody> },
TContext
> => {
return useMutation(getReplaceRolePermissionsMutationOptions(options));
};
/**
* @summary List groups (admin)
*/