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.)

Replit-Task-Id: 01dbc785-03e0-4714-b9d5-f0dff6de9a56
This commit is contained in:
riyadhafraa
2026-04-27 12:31:09 +00:00
parent 574b93022c
commit 1e9024e5a2
9 changed files with 97 additions and 15 deletions
+25
View File
@@ -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

+4 -3
View File
@@ -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": "ابحث في المجموعات...",
+4 -3
View File
@@ -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...",
+42 -5
View File
@@ -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 */
+2 -2
View File
@@ -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>,
+16 -1
View File
@@ -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:
+2 -1
View File
@@ -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(),
});