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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user