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