Let admins edit role names + descriptions in both languages
Original task (#88): The Roles tab in the Group editor displays each role's Arabic and English descriptions, but admins had no UI to edit them — any tweak required a developer. The edit dialog also didn't allow updating the role's name. Make role name + bilingual descriptions editable from the admin Roles panel and persist via PATCH /api/roles/:id. Changes: - lib/api-spec/openapi.yaml: UpdateRoleBody now accepts an optional `name` (minLength 1). PATCH /roles/{id} summary updated to "Update role name and descriptions (admin)" and documents 400 (invalid request) and 409 (name already taken) responses. Regenerated lib/api-zod. - artifacts/api-server/src/routes/roles.ts (PATCH /roles/:id): * Accept and validate `name` with the same rules as create (`/^[a-z][a-z0-9_]*$/i`, uniqueness check that ignores the role's own row). * Refuse to rename system roles (isSystem=1 or one of admin/user/ order_receiver) -> 400 with `code: "system_role_rename"`. * Returns 409 on name collision. - artifacts/tx-os/src/pages/admin.tsx (RolesPanel edit dialog): * Edit form now includes a Name input (data-testid="role-edit-name") prefilled from the role. * Name input is disabled for system roles, with a dedicated hint string. * Save is disabled when name is empty; 400 errors with `code: "system_role_rename"` show a system-role-specific toast, other 400s show the invalid-name toast, and 409 shows name-taken. - artifacts/tx-os/src/locales/{en,ar}.json: * Replaced the now-misleading `editHint` ("Role names cannot be changed") with `editSystemNameHint` (only shown for system roles). * Removed "Cannot be changed later" from `nameHint`. * Added `errorSystemRename` for the system-role rename toast. Validation: - pnpm typecheck passes. - Playwright e2e (testing skill) created a role, renamed it + updated both descriptions, verified the new name + descriptions appear on the role card and in the Group editor's Roles tab, confirmed empty name disables Save, and cleaned up via delete. (Test agent flagged one stale snapshot on the system-role disabled-name check; the implementation gates on r.isSystem from the API and is also enforced server-side.)
This commit is contained in:
@@ -77,6 +77,31 @@ router.patch("/roles/:id", requireAdmin, async (req, res): Promise<void> => {
|
||||
return;
|
||||
}
|
||||
const updates: Partial<typeof rolesTable.$inferInsert> = {};
|
||||
if (parsed.data.name !== undefined) {
|
||||
const name = parsed.data.name.trim();
|
||||
if (name !== existing.name) {
|
||||
const PROTECTED_NAMES = new Set(["admin", "user", "order_receiver"]);
|
||||
if (existing.isSystem || PROTECTED_NAMES.has(existing.name)) {
|
||||
res.status(400).json({ error: "Cannot rename a system role", code: "system_role_rename" });
|
||||
return;
|
||||
}
|
||||
if (!/^[a-z][a-z0-9_]*$/i.test(name)) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: "Role name must start with a letter and contain only letters, numbers, or underscores" });
|
||||
return;
|
||||
}
|
||||
const taken = await db
|
||||
.select({ id: rolesTable.id })
|
||||
.from(rolesTable)
|
||||
.where(eq(rolesTable.name, name));
|
||||
if (taken.some((r) => r.id !== id)) {
|
||||
res.status(409).json({ error: "Role name already taken" });
|
||||
return;
|
||||
}
|
||||
updates.name = name;
|
||||
}
|
||||
}
|
||||
if (parsed.data.descriptionAr !== undefined) updates.descriptionAr = parsed.data.descriptionAr;
|
||||
if (parsed.data.descriptionEn !== undefined) updates.descriptionEn = parsed.data.descriptionEn;
|
||||
if (Object.keys(updates).length > 0) {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
@@ -421,14 +421,14 @@
|
||||
"editRole": "تعديل الدور",
|
||||
"name": "الاسم",
|
||||
"namePlaceholder": "مثال: content_editor",
|
||||
"nameHint": "حروف وأرقام وشرطات سفلية فقط. لا يمكن تغييره لاحقاً.",
|
||||
"nameHint": "حروف وأرقام وشرطات سفلية فقط.",
|
||||
"descriptionAr": "الوصف (عربي)",
|
||||
"descriptionEn": "الوصف (إنجليزي)",
|
||||
"system": "نظامي",
|
||||
"empty": "لا توجد أدوار بعد.",
|
||||
"edit": "تعديل الدور",
|
||||
"delete": "حذف الدور",
|
||||
"editHint": "لا يمكن تغيير اسم الدور. يمكنك تحديث الأوصاف بالعربية والإنجليزية.",
|
||||
"editSystemNameHint": "لا يمكن تغيير اسم الأدوار النظامية.",
|
||||
"deleteTitle": "حذف الدور \"{{name}}\"؟",
|
||||
"deleteEmptyBody": "هذا الدور غير مُعيَّن لأي مستخدم. هل تريد المتابعة؟",
|
||||
"deleteConflict": "هذا الدور قيد الاستخدام ولا يمكن حذفه.",
|
||||
@@ -437,7 +437,8 @@
|
||||
"usersCount": "{{count}} مستخدم",
|
||||
"groupsCount": "{{count}} مجموعة",
|
||||
"errorNameTaken": "يوجد دور بنفس الاسم.",
|
||||
"errorInvalidName": "يجب أن يبدأ اسم الدور بحرف ويحتوي على حروف وأرقام وشرطات سفلية فقط."
|
||||
"errorInvalidName": "يجب أن يبدأ اسم الدور بحرف ويحتوي على حروف وأرقام وشرطات سفلية فقط.",
|
||||
"errorSystemRename": "لا يمكن تغيير اسم الأدوار النظامية."
|
||||
},
|
||||
"groups": {
|
||||
"searchPlaceholder": "ابحث في المجموعات...",
|
||||
|
||||
@@ -418,14 +418,14 @@
|
||||
"editRole": "Edit Role",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "e.g. content_editor",
|
||||
"nameHint": "Letters, numbers and underscores only. Cannot be changed later.",
|
||||
"nameHint": "Letters, numbers and underscores only.",
|
||||
"descriptionAr": "Description (Arabic)",
|
||||
"descriptionEn": "Description (English)",
|
||||
"system": "System",
|
||||
"empty": "No roles yet.",
|
||||
"edit": "Edit role",
|
||||
"delete": "Delete role",
|
||||
"editHint": "Role names cannot be changed. You can update the bilingual descriptions.",
|
||||
"editSystemNameHint": "System role names cannot be changed.",
|
||||
"deleteTitle": "Delete role \"{{name}}\"?",
|
||||
"deleteEmptyBody": "This role isn't assigned to anyone. Continue?",
|
||||
"deleteConflict": "This role is still in use and cannot be deleted.",
|
||||
@@ -434,7 +434,8 @@
|
||||
"usersCount": "{{count}} users",
|
||||
"groupsCount": "{{count}} groups",
|
||||
"errorNameTaken": "A role with that name already exists.",
|
||||
"errorInvalidName": "Role name must start with a letter and contain only letters, numbers, or underscores."
|
||||
"errorInvalidName": "Role name must start with a letter and contain only letters, numbers, or underscores.",
|
||||
"errorSystemRename": "System role names cannot be changed."
|
||||
},
|
||||
"groups": {
|
||||
"searchPlaceholder": "Search groups...",
|
||||
|
||||
@@ -3162,7 +3162,8 @@ function RolesPanel() {
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createForm, setCreateForm] = useState({ name: "", descriptionAr: "", descriptionEn: "" });
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [editForm, setEditForm] = useState({ descriptionAr: "", descriptionEn: "" });
|
||||
const [editingIsSystem, setEditingIsSystem] = useState(false);
|
||||
const [editForm, setEditForm] = useState({ name: "", descriptionAr: "", descriptionEn: "" });
|
||||
const [deleteTarget, setDeleteTarget] = useState<{ id: number; name: string } | null>(null);
|
||||
const [deleteConflict, setDeleteConflict] = useState<{ userCount: number; groupCount: number } | null>(null);
|
||||
|
||||
@@ -3211,9 +3212,17 @@ function RolesPanel() {
|
||||
);
|
||||
};
|
||||
|
||||
const beginEdit = (role: { id: number; descriptionAr?: string | null; descriptionEn?: string | null }) => {
|
||||
const beginEdit = (role: {
|
||||
id: number;
|
||||
name: string;
|
||||
isSystem: boolean;
|
||||
descriptionAr?: string | null;
|
||||
descriptionEn?: string | null;
|
||||
}) => {
|
||||
setEditingId(role.id);
|
||||
setEditingIsSystem(role.isSystem);
|
||||
setEditForm({
|
||||
name: role.name,
|
||||
descriptionAr: role.descriptionAr ?? "",
|
||||
descriptionEn: role.descriptionEn ?? "",
|
||||
});
|
||||
@@ -3221,10 +3230,13 @@ function RolesPanel() {
|
||||
|
||||
const handleEdit = () => {
|
||||
if (editingId == null) return;
|
||||
const name = editForm.name.trim();
|
||||
if (!name) return;
|
||||
updateRole.mutate(
|
||||
{
|
||||
id: editingId,
|
||||
data: {
|
||||
...(editingIsSystem ? {} : { name }),
|
||||
descriptionAr: editForm.descriptionAr.trim() || null,
|
||||
descriptionEn: editForm.descriptionEn.trim() || null,
|
||||
},
|
||||
@@ -3235,7 +3247,19 @@ function RolesPanel() {
|
||||
setEditingId(null);
|
||||
toast({ title: t("common.success") });
|
||||
},
|
||||
onError: () => toast({ title: t("common.error"), variant: "destructive" }),
|
||||
onError: (err: unknown) => {
|
||||
if (err instanceof ApiError && err.status === 409) {
|
||||
toast({ title: t("admin.roles.errorNameTaken"), variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
if (err instanceof ApiError && err.status === 400) {
|
||||
const code = (err.data as { code?: string } | null)?.code;
|
||||
const key = code === "system_role_rename" ? "admin.roles.errorSystemRename" : "admin.roles.errorInvalidName";
|
||||
toast({ title: t(key), variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
toast({ title: t("common.error"), variant: "destructive" });
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -3401,7 +3425,20 @@ function RolesPanel() {
|
||||
<div className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
<div className="glass-panel rounded-3xl p-6 w-full max-w-sm space-y-3" data-testid="edit-role-dialog">
|
||||
<h2 className="font-semibold text-foreground">{t("admin.roles.editRole")}</h2>
|
||||
<p className="text-xs text-muted-foreground">{t("admin.roles.editHint")}</p>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("admin.roles.name")}</Label>
|
||||
<Input
|
||||
value={editForm.name}
|
||||
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
|
||||
className="bg-white/70 border-slate-200"
|
||||
placeholder={t("admin.roles.namePlaceholder")}
|
||||
disabled={editingIsSystem}
|
||||
data-testid="role-edit-name"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{editingIsSystem ? t("admin.roles.editSystemNameHint") : t("admin.roles.nameHint")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("admin.roles.descriptionAr")}</Label>
|
||||
<Input
|
||||
@@ -3428,7 +3465,7 @@ function RolesPanel() {
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
disabled={updateRole.isPending}
|
||||
disabled={!editForm.name.trim() || updateRole.isPending}
|
||||
onClick={handleEdit}
|
||||
data-testid="edit-role-submit"
|
||||
>
|
||||
|
||||
@@ -190,6 +190,8 @@ export interface CreateRoleBody {
|
||||
}
|
||||
|
||||
export interface UpdateRoleBody {
|
||||
/** @minLength 1 */
|
||||
name?: string;
|
||||
/** @nullable */
|
||||
descriptionAr?: string | null;
|
||||
/** @nullable */
|
||||
|
||||
@@ -4881,7 +4881,7 @@ export const useCreateRole = <
|
||||
};
|
||||
|
||||
/**
|
||||
* @summary Update role description (admin)
|
||||
* @summary Update role name and descriptions (admin)
|
||||
*/
|
||||
export const getUpdateRoleUrl = (id: number) => {
|
||||
return `/api/roles/${id}`;
|
||||
@@ -4945,7 +4945,7 @@ export type UpdateRoleMutationBody = BodyType<UpdateRoleBody>;
|
||||
export type UpdateRoleMutationError = ErrorType<ErrorResponse>;
|
||||
|
||||
/**
|
||||
* @summary Update role description (admin)
|
||||
* @summary Update role name and descriptions (admin)
|
||||
*/
|
||||
export const useUpdateRole = <
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
|
||||
@@ -1333,7 +1333,7 @@ paths:
|
||||
patch:
|
||||
operationId: updateRole
|
||||
tags: [users]
|
||||
summary: Update role description (admin)
|
||||
summary: Update role name and descriptions (admin)
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
@@ -1353,12 +1353,24 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Role"
|
||||
"400":
|
||||
description: Invalid request
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"404":
|
||||
description: Not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"409":
|
||||
description: Name already taken
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
delete:
|
||||
operationId: deleteRole
|
||||
tags: [users]
|
||||
@@ -2086,6 +2098,9 @@ components:
|
||||
UpdateRoleBody:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
minLength: 1
|
||||
descriptionAr:
|
||||
type: ["string", "null"]
|
||||
descriptionEn:
|
||||
|
||||
@@ -1647,13 +1647,14 @@ export const CreateRoleBody = zod.object({
|
||||
});
|
||||
|
||||
/**
|
||||
* @summary Update role description (admin)
|
||||
* @summary Update role name and descriptions (admin)
|
||||
*/
|
||||
export const UpdateRoleParams = zod.object({
|
||||
id: zod.coerce.number(),
|
||||
});
|
||||
|
||||
export const UpdateRoleBody = zod.object({
|
||||
name: zod.string().min(1).optional(),
|
||||
descriptionAr: zod.string().nullish(),
|
||||
descriptionEn: zod.string().nullish(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user