Let admins create and edit roles from the dashboard (Task #82)

What changed:
- Added an `is_system` column to the `roles` table (default 0). The
  built-in roles (admin, user, order_receiver) are flagged as system roles
  in the seed and via a one-off UPDATE on the existing dev DB.
- Created a new `/api/roles` route module with admin-guarded endpoints:
    - POST /api/roles – create a role (validates name format, rejects duplicates).
    - PATCH /api/roles/:id – update bilingual descriptions; name is immutable.
    - DELETE /api/roles/:id – returns 400 for system roles (also defended
      by name allowlist), 409 with userCount/groupCount when in use, 204 on
      success. Cleans up role_permissions in a transaction.
  GET /api/roles was moved out of groups.ts into the new module.
- OpenAPI updated with createRole / updateRole / deleteRole operations,
  isSystem on the Role schema, and CreateRoleBody / UpdateRoleBody /
  RoleDeletionConflict schemas. Regenerated api-zod and api-client-react.
- Added a "Roles" item under the User Management nav group in the admin
  dashboard, plus a new RolesPanel with search, create dialog, edit dialog
  (description-only), and delete dialog with conflict messaging.
- Bilingual translations added in en.json and ar.json (admin.nav.roles +
  admin.roles.*).

Notes / deviations:
- Also marked `order_receiver` as a system role since it is seeded by the
  system and required by the orders feature, even though the task brief
  only called out admin and user.
- Verified end-to-end via the testing skill: list, create, edit, delete,
  duplicate-name validation, and protection of system roles all work.

Replit-Task-Id: b1187555-be09-4687-a9ae-83b123d908bd
This commit is contained in:
riyadhafraa
2026-04-27 10:26:24 +00:00
parent b0b1d673b7
commit 19a20cc5a4
13 changed files with 981 additions and 17 deletions
@@ -176,9 +176,32 @@ export interface Role {
descriptionAr?: string | null;
/** @nullable */
descriptionEn?: string | null;
isSystem: boolean;
createdAt: string;
}
export interface CreateRoleBody {
/** @minLength 1 */
name: string;
/** @nullable */
descriptionAr?: string | null;
/** @nullable */
descriptionEn?: string | null;
}
export interface UpdateRoleBody {
/** @nullable */
descriptionAr?: string | null;
/** @nullable */
descriptionEn?: string | null;
}
export interface RoleDeletionConflict {
error: string;
userCount: number;
groupCount: number;
}
export interface Group {
id: number;
name: string;
+262
View File
@@ -30,6 +30,7 @@ import type {
CreateAppBody,
CreateConversationBody,
CreateGroupBody,
CreateRoleBody,
CreateServiceBody,
CreateServiceOrderBody,
CreateUserBody,
@@ -56,6 +57,7 @@ import type {
RequestUploadUrlResponse,
ResetPasswordBody,
Role,
RoleDeletionConflict,
SendMessageBody,
Service,
ServiceCategory,
@@ -70,6 +72,7 @@ import type {
UpdateGroupBody,
UpdateLanguageBody,
UpdateMyAppOrderBody,
UpdateRoleBody,
UpdateServiceBody,
UpdateServiceOrderStatusBody,
UpdateUserBody,
@@ -4741,6 +4744,265 @@ export function useListRoles<
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Create role (admin)
*/
export const getCreateRoleUrl = () => {
return `/api/roles`;
};
export const createRole = async (
createRoleBody: CreateRoleBody,
options?: RequestInit,
): Promise<Role> => {
return customFetch<Role>(getCreateRoleUrl(), {
...options,
method: "POST",
headers: { "Content-Type": "application/json", ...options?.headers },
body: JSON.stringify(createRoleBody),
});
};
export const getCreateRoleMutationOptions = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createRole>>,
TError,
{ data: BodyType<CreateRoleBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createRole>>,
TError,
{ data: BodyType<CreateRoleBody> },
TContext
> => {
const mutationKey = ["createRole"];
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 createRole>>,
{ data: BodyType<CreateRoleBody> }
> = (props) => {
const { data } = props ?? {};
return createRole(data, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type CreateRoleMutationResult = NonNullable<
Awaited<ReturnType<typeof createRole>>
>;
export type CreateRoleMutationBody = BodyType<CreateRoleBody>;
export type CreateRoleMutationError = ErrorType<ErrorResponse>;
/**
* @summary Create role (admin)
*/
export const useCreateRole = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createRole>>,
TError,
{ data: BodyType<CreateRoleBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof createRole>>,
TError,
{ data: BodyType<CreateRoleBody> },
TContext
> => {
return useMutation(getCreateRoleMutationOptions(options));
};
/**
* @summary Update role description (admin)
*/
export const getUpdateRoleUrl = (id: number) => {
return `/api/roles/${id}`;
};
export const updateRole = async (
id: number,
updateRoleBody: UpdateRoleBody,
options?: RequestInit,
): Promise<Role> => {
return customFetch<Role>(getUpdateRoleUrl(id), {
...options,
method: "PATCH",
headers: { "Content-Type": "application/json", ...options?.headers },
body: JSON.stringify(updateRoleBody),
});
};
export const getUpdateRoleMutationOptions = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateRole>>,
TError,
{ id: number; data: BodyType<UpdateRoleBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateRole>>,
TError,
{ id: number; data: BodyType<UpdateRoleBody> },
TContext
> => {
const mutationKey = ["updateRole"];
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 updateRole>>,
{ id: number; data: BodyType<UpdateRoleBody> }
> = (props) => {
const { id, data } = props ?? {};
return updateRole(id, data, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateRoleMutationResult = NonNullable<
Awaited<ReturnType<typeof updateRole>>
>;
export type UpdateRoleMutationBody = BodyType<UpdateRoleBody>;
export type UpdateRoleMutationError = ErrorType<ErrorResponse>;
/**
* @summary Update role description (admin)
*/
export const useUpdateRole = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateRole>>,
TError,
{ id: number; data: BodyType<UpdateRoleBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateRole>>,
TError,
{ id: number; data: BodyType<UpdateRoleBody> },
TContext
> => {
return useMutation(getUpdateRoleMutationOptions(options));
};
/**
* @summary Delete role (admin)
*/
export const getDeleteRoleUrl = (id: number) => {
return `/api/roles/${id}`;
};
export const deleteRole = async (
id: number,
options?: RequestInit,
): Promise<void> => {
return customFetch<void>(getDeleteRoleUrl(id), {
...options,
method: "DELETE",
});
};
export const getDeleteRoleMutationOptions = <
TError = ErrorType<ErrorResponse | RoleDeletionConflict>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteRole>>,
TError,
{ id: number },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteRole>>,
TError,
{ id: number },
TContext
> => {
const mutationKey = ["deleteRole"];
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 deleteRole>>,
{ id: number }
> = (props) => {
const { id } = props ?? {};
return deleteRole(id, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteRoleMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteRole>>
>;
export type DeleteRoleMutationError = ErrorType<
ErrorResponse | RoleDeletionConflict
>;
/**
* @summary Delete role (admin)
*/
export const useDeleteRole = <
TError = ErrorType<ErrorResponse | RoleDeletionConflict>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteRole>>,
TError,
{ id: number },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof deleteRole>>,
TError,
{ id: number },
TContext
> => {
return useMutation(getDeleteRoleMutationOptions(options));
};
/**
* @summary List groups (admin)
*/