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
+344 -2
View File
@@ -40,6 +40,9 @@ import {
useDeleteGroup,
useListRoles,
getListRolesQueryKey,
useCreateRole,
useUpdateRole,
useDeleteRole,
ApiError,
} from "@workspace/api-client-react";
import type {
@@ -72,7 +75,7 @@ import { useToast } from "@/hooks/use-toast";
import { cn } from "@/lib/utils";
import { formatDate as formatDateUtil, formatWeekday as formatWeekdayUtil } from "@/lib/i18n-format";
type AdminSection = "dashboard" | "apps" | "services" | "users" | "groups" | "settings";
type AdminSection = "dashboard" | "apps" | "services" | "users" | "groups" | "roles" | "settings";
function LeaderboardAvatar({
src,
@@ -266,6 +269,7 @@ export default function AdminPage() {
children: [
{ key: "users", label: t("admin.nav.users"), Icon: UsersIcon },
{ key: "groups", label: t("admin.nav.groups"), Icon: Shield },
{ key: "roles", label: t("admin.nav.roles"), Icon: KeyRound },
],
},
{ key: "settings", label: t("admin.nav.settings"), Icon: Settings },
@@ -278,7 +282,7 @@ export default function AdminPage() {
const sectionMatch = hash.match(/section=([a-z-]+)/);
if (sectionMatch) {
const s = sectionMatch[1] as AdminSection;
if (["dashboard", "apps", "services", "users", "groups", "settings"].includes(s)) {
if (["dashboard", "apps", "services", "users", "groups", "roles", "settings"].includes(s)) {
setSection(s);
}
}
@@ -792,6 +796,7 @@ export default function AdminPage() {
/>
)}
{section === "groups" && <GroupsPanel />}
{section === "roles" && <RolesPanel />}
{section === "settings" && (
<div className="space-y-3">
@@ -2892,3 +2897,340 @@ function GroupDetailEditor({ groupId, onClose, onSaved }: { groupId: number; onC
</div>
);
}
function RolesPanel() {
const { t } = useTranslation();
const { toast } = useToast();
const queryClient = useQueryClient();
const { data: roles } = useListRoles({ query: { queryKey: getListRolesQueryKey() } });
const createRole = useCreateRole();
const updateRole = useUpdateRole();
const deleteRole = useDeleteRole();
const [search, setSearch] = useState("");
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 [deleteTarget, setDeleteTarget] = useState<{ id: number; name: string } | null>(null);
const [deleteConflict, setDeleteConflict] = useState<{ userCount: number; groupCount: number } | null>(null);
const invalidate = () => queryClient.invalidateQueries({ queryKey: getListRolesQueryKey() });
const visibleRoles = (roles ?? []).filter((r) => {
const q = search.trim().toLowerCase();
if (!q) return true;
return (
r.name.toLowerCase().includes(q) ||
(r.descriptionAr ?? "").toLowerCase().includes(q) ||
(r.descriptionEn ?? "").toLowerCase().includes(q)
);
});
const handleCreate = () => {
const name = createForm.name.trim();
if (!name) return;
createRole.mutate(
{
data: {
name,
descriptionAr: createForm.descriptionAr.trim() || null,
descriptionEn: createForm.descriptionEn.trim() || null,
},
},
{
onSuccess: () => {
invalidate();
setCreateOpen(false);
setCreateForm({ name: "", descriptionAr: "", descriptionEn: "" });
toast({ title: t("common.success") });
},
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) {
toast({ title: t("admin.roles.errorInvalidName"), variant: "destructive" });
return;
}
toast({ title: t("common.error"), variant: "destructive" });
},
},
);
};
const beginEdit = (role: { id: number; descriptionAr?: string | null; descriptionEn?: string | null }) => {
setEditingId(role.id);
setEditForm({
descriptionAr: role.descriptionAr ?? "",
descriptionEn: role.descriptionEn ?? "",
});
};
const handleEdit = () => {
if (editingId == null) return;
updateRole.mutate(
{
id: editingId,
data: {
descriptionAr: editForm.descriptionAr.trim() || null,
descriptionEn: editForm.descriptionEn.trim() || null,
},
},
{
onSuccess: () => {
invalidate();
setEditingId(null);
toast({ title: t("common.success") });
},
onError: () => toast({ title: t("common.error"), variant: "destructive" }),
},
);
};
const handleDelete = () => {
if (!deleteTarget) return;
deleteRole.mutate(
{ id: deleteTarget.id },
{
onSuccess: () => {
invalidate();
setDeleteTarget(null);
setDeleteConflict(null);
toast({ title: t("common.success") });
},
onError: (err: unknown) => {
if (err instanceof ApiError && err.status === 409 && err.data) {
const data = err.data as { userCount: number; groupCount: number };
setDeleteConflict({ userCount: data.userCount, groupCount: data.groupCount });
return;
}
toast({ title: t("common.error"), variant: "destructive" });
setDeleteTarget(null);
},
},
);
};
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Input
placeholder={t("admin.roles.searchPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="bg-white/70 border-slate-200 flex-1"
data-testid="role-search"
/>
<Button
size="sm"
onClick={() => {
setCreateForm({ name: "", descriptionAr: "", descriptionEn: "" });
setCreateOpen(true);
}}
data-testid="create-role-btn"
>
<Plus size={14} className="me-1" />
{t("admin.roles.addRole")}
</Button>
</div>
<div className="grid gap-3 md:grid-cols-2">
{visibleRoles.map((r) => (
<div key={r.id} className="glass-panel rounded-2xl p-4 space-y-2" data-testid={`role-card-${r.id}`}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="font-semibold text-foreground flex items-center gap-2">
<KeyRound size={16} className="text-primary" />
<span className="truncate">{r.name}</span>
{r.isSystem && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-100 text-amber-700 uppercase tracking-wider">
{t("admin.roles.system")}
</span>
)}
</div>
{(r.descriptionAr || r.descriptionEn) && (
<p className="text-xs text-muted-foreground mt-1">
{r.descriptionEn || r.descriptionAr}
</p>
)}
{r.descriptionAr && r.descriptionEn && (
<p className="text-xs text-muted-foreground mt-0.5" dir="rtl">
{r.descriptionAr}
</p>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => beginEdit(r)}
className="p-1.5 hover:bg-slate-100 rounded-lg text-muted-foreground hover:text-foreground"
data-testid={`edit-role-${r.id}`}
aria-label={t("admin.roles.edit")}
>
<Pencil size={14} />
</button>
{!r.isSystem && (
<button
onClick={() => {
setDeleteConflict(null);
setDeleteTarget({ id: r.id, name: r.name });
}}
className="p-1.5 hover:bg-destructive/20 rounded-lg text-muted-foreground hover:text-destructive"
data-testid={`delete-role-${r.id}`}
aria-label={t("admin.roles.delete")}
>
<Trash2 size={14} />
</button>
)}
</div>
</div>
</div>
))}
{visibleRoles.length === 0 && (
<div className="glass-panel rounded-2xl p-6 text-center text-sm text-muted-foreground md:col-span-2">
{t("admin.roles.empty")}
</div>
)}
</div>
{createOpen && (
<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="create-role-dialog">
<h2 className="font-semibold text-foreground">{t("admin.roles.addRole")}</h2>
<div className="space-y-1">
<Label>{t("admin.roles.name")}</Label>
<Input
value={createForm.name}
onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })}
className="bg-white/70 border-slate-200"
placeholder={t("admin.roles.namePlaceholder")}
data-testid="role-name-input"
/>
<p className="text-xs text-muted-foreground">{t("admin.roles.nameHint")}</p>
</div>
<div className="space-y-1">
<Label>{t("admin.roles.descriptionAr")}</Label>
<Input
value={createForm.descriptionAr}
onChange={(e) => setCreateForm({ ...createForm, descriptionAr: e.target.value })}
className="bg-white/70 border-slate-200"
dir="rtl"
data-testid="role-desc-ar-input"
/>
</div>
<div className="space-y-1">
<Label>{t("admin.roles.descriptionEn")}</Label>
<Input
value={createForm.descriptionEn}
onChange={(e) => setCreateForm({ ...createForm, descriptionEn: e.target.value })}
className="bg-white/70 border-slate-200"
dir="ltr"
data-testid="role-desc-en-input"
/>
</div>
<div className="flex gap-2 pt-2">
<Button variant="outline" className="flex-1" onClick={() => setCreateOpen(false)}>
{t("common.cancel")}
</Button>
<Button
className="flex-1"
disabled={!createForm.name.trim() || createRole.isPending}
onClick={handleCreate}
data-testid="create-role-submit"
>
{createRole.isPending ? <Loader2 size={14} className="animate-spin" /> : t("common.save")}
</Button>
</div>
</div>
</div>
)}
{editingId != null && (
<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.descriptionAr")}</Label>
<Input
value={editForm.descriptionAr}
onChange={(e) => setEditForm({ ...editForm, descriptionAr: e.target.value })}
className="bg-white/70 border-slate-200"
dir="rtl"
data-testid="role-edit-desc-ar"
/>
</div>
<div className="space-y-1">
<Label>{t("admin.roles.descriptionEn")}</Label>
<Input
value={editForm.descriptionEn}
onChange={(e) => setEditForm({ ...editForm, descriptionEn: e.target.value })}
className="bg-white/70 border-slate-200"
dir="ltr"
data-testid="role-edit-desc-en"
/>
</div>
<div className="flex gap-2 pt-2">
<Button variant="outline" className="flex-1" onClick={() => setEditingId(null)}>
{t("common.cancel")}
</Button>
<Button
className="flex-1"
disabled={updateRole.isPending}
onClick={handleEdit}
data-testid="edit-role-submit"
>
{updateRole.isPending ? <Loader2 size={14} className="animate-spin" /> : t("common.save")}
</Button>
</div>
</div>
</div>
)}
{deleteTarget && (
<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="delete-role-dialog">
<h2 className="font-semibold text-foreground">
{t("admin.roles.deleteTitle", { name: deleteTarget.name })}
</h2>
{deleteConflict ? (
<>
<p className="text-sm text-destructive">{t("admin.roles.deleteConflict")}</p>
<ul className="text-sm text-foreground space-y-1 ps-4 list-disc">
<li>{t("admin.roles.usersCount", { count: deleteConflict.userCount })}</li>
<li>{t("admin.roles.groupsCount", { count: deleteConflict.groupCount })}</li>
</ul>
<p className="text-xs text-muted-foreground">{t("admin.roles.deleteConflictHint")}</p>
</>
) : (
<p className="text-sm text-muted-foreground">{t("admin.roles.deleteEmptyBody")}</p>
)}
<div className="flex gap-2 pt-2">
<Button
variant="outline"
className="flex-1"
onClick={() => {
setDeleteTarget(null);
setDeleteConflict(null);
}}
>
{t("common.cancel")}
</Button>
{!deleteConflict && (
<Button
variant="destructive"
className="flex-1"
disabled={deleteRole.isPending}
onClick={handleDelete}
data-testid="delete-role-confirm"
>
{deleteRole.isPending ? <Loader2 size={14} className="animate-spin" /> : t("admin.roles.deleteConfirm")}
</Button>
)}
</div>
</div>
</div>
)}
</div>
);
}