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
-13
View File
@@ -39,19 +39,6 @@ function serializeGroup(g: typeof groupsTable.$inferSelect) {
};
}
router.get("/roles", requireAdmin, async (_req, res): Promise<void> => {
const rows = await db.select().from(rolesTable).orderBy(rolesTable.name);
res.json(
rows.map((r) => ({
id: r.id,
name: r.name,
descriptionAr: r.descriptionAr,
descriptionEn: r.descriptionEn,
createdAt: r.createdAt,
})),
);
});
router.get("/groups", requireAdmin, async (req, res): Promise<void> => {
const q = typeof req.query.q === "string" ? req.query.q.trim() : "";
const where = q ? ilike(groupsTable.name, `%${q}%`) : undefined;
+2
View File
@@ -12,6 +12,7 @@ import storageRouter from "./storage";
import settingsRouter from "./settings";
import notesRouter from "./notes";
import groupsRouter from "./groups";
import rolesRouter from "./roles";
const router: IRouter = Router();
@@ -28,5 +29,6 @@ router.use(storageRouter);
router.use(settingsRouter);
router.use(notesRouter);
router.use(groupsRouter);
router.use(rolesRouter);
export default router;
+128
View File
@@ -0,0 +1,128 @@
import { Router, type IRouter } from "express";
import { eq, sql } from "drizzle-orm";
import { db } from "@workspace/db";
import {
rolesTable,
userRolesTable,
groupRolesTable,
rolePermissionsTable,
} from "@workspace/db";
import { requireAdmin } from "../middlewares/auth";
import { CreateRoleBody, UpdateRoleBody } from "@workspace/api-zod";
const router: IRouter = Router();
function serializeRole(r: typeof rolesTable.$inferSelect) {
return {
id: r.id,
name: r.name,
descriptionAr: r.descriptionAr,
descriptionEn: r.descriptionEn,
isSystem: Boolean(r.isSystem),
createdAt: r.createdAt,
};
}
router.get("/roles", requireAdmin, async (_req, res): Promise<void> => {
const rows = await db.select().from(rolesTable).orderBy(rolesTable.name);
res.json(rows.map(serializeRole));
});
router.post("/roles", requireAdmin, async (req, res): Promise<void> => {
const parsed = CreateRoleBody.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.message });
return;
}
const name = parsed.data.name.trim();
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 existing = await db
.select({ id: rolesTable.id })
.from(rolesTable)
.where(eq(rolesTable.name, name));
if (existing.length > 0) {
res.status(409).json({ error: "Role name already taken" });
return;
}
const [role] = await db
.insert(rolesTable)
.values({
name,
descriptionAr: parsed.data.descriptionAr ?? null,
descriptionEn: parsed.data.descriptionEn ?? null,
})
.returning();
res.status(201).json(serializeRole(role));
});
router.patch("/roles/:id", requireAdmin, async (req, res): Promise<void> => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) {
res.status(400).json({ error: "Invalid id" });
return;
}
const parsed = UpdateRoleBody.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.message });
return;
}
const [existing] = await db.select().from(rolesTable).where(eq(rolesTable.id, id));
if (!existing) {
res.status(404).json({ error: "Role not found" });
return;
}
const updates: Partial<typeof rolesTable.$inferInsert> = {};
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) {
await db.update(rolesTable).set(updates).where(eq(rolesTable.id, id));
}
const [role] = await db.select().from(rolesTable).where(eq(rolesTable.id, id));
res.json(serializeRole(role));
});
router.delete("/roles/:id", requireAdmin, async (req, res): Promise<void> => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) {
res.status(400).json({ error: "Invalid id" });
return;
}
const [existing] = await db.select().from(rolesTable).where(eq(rolesTable.id, id));
if (!existing) {
res.status(404).json({ error: "Role not found" });
return;
}
const PROTECTED_NAMES = new Set(["admin", "user", "order_receiver"]);
if (existing.isSystem || PROTECTED_NAMES.has(existing.name)) {
res.status(400).json({ error: "Cannot delete a system role" });
return;
}
const [userCountRow] = await db
.select({ count: sql<number>`count(*)::int` })
.from(userRolesTable)
.where(eq(userRolesTable.roleId, id));
const [groupCountRow] = await db
.select({ count: sql<number>`count(*)::int` })
.from(groupRolesTable)
.where(eq(groupRolesTable.roleId, id));
const userCount = userCountRow?.count ?? 0;
const groupCount = groupCountRow?.count ?? 0;
if (userCount > 0 || groupCount > 0) {
res.status(409).json({
error: "Role is in use",
userCount,
groupCount,
});
return;
}
await db.transaction(async (tx) => {
await tx.delete(rolePermissionsTable).where(eq(rolePermissionsTable.roleId, id));
await tx.delete(rolesTable).where(eq(rolesTable.id, id));
});
res.sendStatus(204);
});
export default router;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

+25
View File
@@ -356,6 +356,7 @@
"services": "الخدمات",
"users": "المستخدمين",
"groups": "المجموعات",
"roles": "الأدوار",
"userManagement": "إدارة المستخدمين",
"settings": "الإعدادات",
"menu": "القائمة"
@@ -381,6 +382,30 @@
"actions": "إجراءات"
}
},
"roles": {
"searchPlaceholder": "ابحث في الأدوار...",
"addRole": "إضافة دور",
"editRole": "تعديل الدور",
"name": "الاسم",
"namePlaceholder": "مثال: content_editor",
"nameHint": "حروف وأرقام وشرطات سفلية فقط. لا يمكن تغييره لاحقاً.",
"descriptionAr": "الوصف (عربي)",
"descriptionEn": "الوصف (إنجليزي)",
"system": "نظامي",
"empty": "لا توجد أدوار بعد.",
"edit": "تعديل الدور",
"delete": "حذف الدور",
"editHint": "لا يمكن تغيير اسم الدور. يمكنك تحديث الأوصاف بالعربية والإنجليزية.",
"deleteTitle": "حذف الدور \"{{name}}\"؟",
"deleteEmptyBody": "هذا الدور غير مُعيَّن لأي مستخدم. هل تريد المتابعة؟",
"deleteConflict": "هذا الدور قيد الاستخدام ولا يمكن حذفه.",
"deleteConflictHint": "أزل هذا الدور من المستخدمين والمجموعات قبل حذفه.",
"deleteConfirm": "حذف",
"usersCount": "{{count}} مستخدم",
"groupsCount": "{{count}} مجموعة",
"errorNameTaken": "يوجد دور بنفس الاسم.",
"errorInvalidName": "يجب أن يبدأ اسم الدور بحرف ويحتوي على حروف وأرقام وشرطات سفلية فقط."
},
"groups": {
"searchPlaceholder": "ابحث في المجموعات...",
"addGroup": "إضافة مجموعة",
+25
View File
@@ -353,6 +353,7 @@
"services": "Services",
"users": "Users",
"groups": "Groups",
"roles": "Roles",
"userManagement": "User Management",
"settings": "Settings",
"menu": "Menu"
@@ -378,6 +379,30 @@
"actions": "Actions"
}
},
"roles": {
"searchPlaceholder": "Search roles...",
"addRole": "Add Role",
"editRole": "Edit Role",
"name": "Name",
"namePlaceholder": "e.g. content_editor",
"nameHint": "Letters, numbers and underscores only. Cannot be changed later.",
"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.",
"deleteTitle": "Delete role \"{{name}}\"?",
"deleteEmptyBody": "This role isn't assigned to anyone. Continue?",
"deleteConflict": "This role is still in use and cannot be deleted.",
"deleteConflictHint": "Remove this role from the affected users and groups before deleting it.",
"deleteConfirm": "Delete",
"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."
},
"groups": {
"searchPlaceholder": "Search groups...",
"addGroup": "Add Group",
+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>
);
}
@@ -176,9 +176,32 @@ export interface Role {
descriptionAr?: string | null;
/** @nullable */
descriptionEn?: string | null;
isSystem: boolean;
createdAt: string;
}
export interface CreateRoleBody {
/** @minLength 1 */
name: string;
/** @nullable */
descriptionAr?: string | null;
/** @nullable */
descriptionEn?: string | null;
}
export interface UpdateRoleBody {
/** @nullable */
descriptionAr?: string | null;
/** @nullable */
descriptionEn?: string | null;
}
export interface RoleDeletionConflict {
error: string;
userCount: number;
groupCount: number;
}
export interface Group {
id: number;
name: string;
+262
View File
@@ -30,6 +30,7 @@ import type {
CreateAppBody,
CreateConversationBody,
CreateGroupBody,
CreateRoleBody,
CreateServiceBody,
CreateServiceOrderBody,
CreateUserBody,
@@ -56,6 +57,7 @@ import type {
RequestUploadUrlResponse,
ResetPasswordBody,
Role,
RoleDeletionConflict,
SendMessageBody,
Service,
ServiceCategory,
@@ -70,6 +72,7 @@ import type {
UpdateGroupBody,
UpdateLanguageBody,
UpdateMyAppOrderBody,
UpdateRoleBody,
UpdateServiceBody,
UpdateServiceOrderStatusBody,
UpdateUserBody,
@@ -4741,6 +4744,265 @@ export function useListRoles<
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Create role (admin)
*/
export const getCreateRoleUrl = () => {
return `/api/roles`;
};
export const createRole = async (
createRoleBody: CreateRoleBody,
options?: RequestInit,
): Promise<Role> => {
return customFetch<Role>(getCreateRoleUrl(), {
...options,
method: "POST",
headers: { "Content-Type": "application/json", ...options?.headers },
body: JSON.stringify(createRoleBody),
});
};
export const getCreateRoleMutationOptions = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createRole>>,
TError,
{ data: BodyType<CreateRoleBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createRole>>,
TError,
{ data: BodyType<CreateRoleBody> },
TContext
> => {
const mutationKey = ["createRole"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof createRole>>,
{ data: BodyType<CreateRoleBody> }
> = (props) => {
const { data } = props ?? {};
return createRole(data, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type CreateRoleMutationResult = NonNullable<
Awaited<ReturnType<typeof createRole>>
>;
export type CreateRoleMutationBody = BodyType<CreateRoleBody>;
export type CreateRoleMutationError = ErrorType<ErrorResponse>;
/**
* @summary Create role (admin)
*/
export const useCreateRole = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createRole>>,
TError,
{ data: BodyType<CreateRoleBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof createRole>>,
TError,
{ data: BodyType<CreateRoleBody> },
TContext
> => {
return useMutation(getCreateRoleMutationOptions(options));
};
/**
* @summary Update role description (admin)
*/
export const getUpdateRoleUrl = (id: number) => {
return `/api/roles/${id}`;
};
export const updateRole = async (
id: number,
updateRoleBody: UpdateRoleBody,
options?: RequestInit,
): Promise<Role> => {
return customFetch<Role>(getUpdateRoleUrl(id), {
...options,
method: "PATCH",
headers: { "Content-Type": "application/json", ...options?.headers },
body: JSON.stringify(updateRoleBody),
});
};
export const getUpdateRoleMutationOptions = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateRole>>,
TError,
{ id: number; data: BodyType<UpdateRoleBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateRole>>,
TError,
{ id: number; data: BodyType<UpdateRoleBody> },
TContext
> => {
const mutationKey = ["updateRole"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof updateRole>>,
{ id: number; data: BodyType<UpdateRoleBody> }
> = (props) => {
const { id, data } = props ?? {};
return updateRole(id, data, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateRoleMutationResult = NonNullable<
Awaited<ReturnType<typeof updateRole>>
>;
export type UpdateRoleMutationBody = BodyType<UpdateRoleBody>;
export type UpdateRoleMutationError = ErrorType<ErrorResponse>;
/**
* @summary Update role description (admin)
*/
export const useUpdateRole = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateRole>>,
TError,
{ id: number; data: BodyType<UpdateRoleBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateRole>>,
TError,
{ id: number; data: BodyType<UpdateRoleBody> },
TContext
> => {
return useMutation(getUpdateRoleMutationOptions(options));
};
/**
* @summary Delete role (admin)
*/
export const getDeleteRoleUrl = (id: number) => {
return `/api/roles/${id}`;
};
export const deleteRole = async (
id: number,
options?: RequestInit,
): Promise<void> => {
return customFetch<void>(getDeleteRoleUrl(id), {
...options,
method: "DELETE",
});
};
export const getDeleteRoleMutationOptions = <
TError = ErrorType<ErrorResponse | RoleDeletionConflict>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteRole>>,
TError,
{ id: number },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteRole>>,
TError,
{ id: number },
TContext
> => {
const mutationKey = ["deleteRole"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof deleteRole>>,
{ id: number }
> = (props) => {
const { id } = props ?? {};
return deleteRole(id, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteRoleMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteRole>>
>;
export type DeleteRoleMutationError = ErrorType<
ErrorResponse | RoleDeletionConflict
>;
/**
* @summary Delete role (admin)
*/
export const useDeleteRole = <
TError = ErrorType<ErrorResponse | RoleDeletionConflict>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteRole>>,
TError,
{ id: number },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof deleteRole>>,
TError,
{ id: number },
TContext
> => {
return useMutation(getDeleteRoleMutationOptions(options));
};
/**
* @summary List groups (admin)
*/
+129
View File
@@ -1258,6 +1258,97 @@ paths:
type: array
items:
$ref: "#/components/schemas/Role"
post:
operationId: createRole
tags: [users]
summary: Create role (admin)
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateRoleBody"
responses:
"201":
description: Created
content:
application/json:
schema:
$ref: "#/components/schemas/Role"
"400":
description: Invalid request
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"409":
description: Name already taken
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/roles/{id}:
patch:
operationId: updateRole
tags: [users]
summary: Update role description (admin)
parameters:
- name: id
in: path
required: true
schema:
type: integer
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UpdateRoleBody"
responses:
"200":
description: Updated
content:
application/json:
schema:
$ref: "#/components/schemas/Role"
"404":
description: Not found
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
delete:
operationId: deleteRole
tags: [users]
summary: Delete role (admin)
parameters:
- name: id
in: path
required: true
schema:
type: integer
responses:
"204":
description: Deleted
"400":
description: Cannot delete a system role
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"404":
description: Not found
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"409":
description: Role is in use
content:
application/json:
schema:
$ref: "#/components/schemas/RoleDeletionConflict"
# Groups (admin)
/groups:
@@ -1869,14 +1960,52 @@ components:
type: ["string", "null"]
descriptionEn:
type: ["string", "null"]
isSystem:
type: boolean
createdAt:
type: string
format: date-time
required:
- id
- name
- isSystem
- createdAt
CreateRoleBody:
type: object
properties:
name:
type: string
minLength: 1
descriptionAr:
type: ["string", "null"]
descriptionEn:
type: ["string", "null"]
required:
- name
UpdateRoleBody:
type: object
properties:
descriptionAr:
type: ["string", "null"]
descriptionEn:
type: ["string", "null"]
RoleDeletionConflict:
type: object
properties:
error:
type: string
userCount:
type: integer
groupCount:
type: integer
required:
- error
- userCount
- groupCount
GroupSummary:
type: object
properties:
+39
View File
@@ -1598,10 +1598,49 @@ export const ListRolesResponseItem = zod.object({
name: zod.string(),
descriptionAr: zod.string().nullish(),
descriptionEn: zod.string().nullish(),
isSystem: zod.boolean(),
createdAt: zod.coerce.date(),
});
export const ListRolesResponse = zod.array(ListRolesResponseItem);
/**
* @summary Create role (admin)
*/
export const CreateRoleBody = zod.object({
name: zod.string().min(1),
descriptionAr: zod.string().nullish(),
descriptionEn: zod.string().nullish(),
});
/**
* @summary Update role description (admin)
*/
export const UpdateRoleParams = zod.object({
id: zod.coerce.number(),
});
export const UpdateRoleBody = zod.object({
descriptionAr: zod.string().nullish(),
descriptionEn: zod.string().nullish(),
});
export const UpdateRoleResponse = zod.object({
id: zod.number(),
name: zod.string(),
descriptionAr: zod.string().nullish(),
descriptionEn: zod.string().nullish(),
isSystem: zod.boolean(),
createdAt: zod.coerce.date(),
});
/**
* @summary Delete role (admin)
*/
export const DeleteRoleParams = zod.object({
id: zod.coerce.number(),
});
/**
* @summary List groups (admin)
*/
+1
View File
@@ -8,6 +8,7 @@ export const rolesTable = pgTable("roles", {
name: varchar("name", { length: 100 }).notNull().unique(),
descriptionAr: text("description_ar"),
descriptionEn: text("description_en"),
isSystem: integer("is_system").notNull().default(0),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
+3 -2
View File
@@ -23,13 +23,13 @@ async function main() {
// Create roles
const [adminRole] = await db
.insert(rolesTable)
.values({ name: "admin", descriptionAr: "مدير النظام", descriptionEn: "System Administrator" })
.values({ name: "admin", descriptionAr: "مدير النظام", descriptionEn: "System Administrator", isSystem: 1 })
.onConflictDoNothing()
.returning();
const [userRole] = await db
.insert(rolesTable)
.values({ name: "user", descriptionAr: "مستخدم عادي", descriptionEn: "Regular User" })
.values({ name: "user", descriptionAr: "مستخدم عادي", descriptionEn: "Regular User", isSystem: 1 })
.onConflictDoNothing()
.returning();
@@ -56,6 +56,7 @@ async function main() {
name: "order_receiver",
descriptionAr: "مستلم الطلبات",
descriptionEn: "Order Receiver",
isSystem: 1,
})
.onConflictDoNothing()
.returning();