diff --git a/artifacts/api-server/src/routes/roles.ts b/artifacts/api-server/src/routes/roles.ts index 6fab3d68..2b66232a 100644 --- a/artifacts/api-server/src/routes/roles.ts +++ b/artifacts/api-server/src/routes/roles.ts @@ -77,6 +77,31 @@ router.patch("/roles/:id", requireAdmin, async (req, res): Promise => { return; } const updates: Partial = {}; + 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) { diff --git a/artifacts/tx-os/public/opengraph.jpg b/artifacts/tx-os/public/opengraph.jpg index ec995ed1..c6bb5d01 100644 Binary files a/artifacts/tx-os/public/opengraph.jpg and b/artifacts/tx-os/public/opengraph.jpg differ diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index d1d61b0d..fb5e9a80 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -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": "ابحث في المجموعات...", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index 1f122694..da855c1e 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -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...", diff --git a/artifacts/tx-os/src/pages/admin.tsx b/artifacts/tx-os/src/pages/admin.tsx index ec4cb79c..17f8d83a 100644 --- a/artifacts/tx-os/src/pages/admin.tsx +++ b/artifacts/tx-os/src/pages/admin.tsx @@ -3162,7 +3162,8 @@ function RolesPanel() { const [createOpen, setCreateOpen] = useState(false); const [createForm, setCreateForm] = useState({ name: "", descriptionAr: "", descriptionEn: "" }); const [editingId, setEditingId] = useState(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() {

{t("admin.roles.editRole")}

-

{t("admin.roles.editHint")}

+
+ + 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" + /> +

+ {editingIsSystem ? t("admin.roles.editSystemNameHint") : t("admin.roles.nameHint")} +

+