Task #74: Add groups system + admin User Management UI

Backend:
- New schema: groups, user_groups, group_apps, group_roles (lib/db/src/schema/groups.ts)
- Seeds Admins, TeaBoy, Everyone system groups idempotently and maps existing users
- /api/groups CRUD with admin guard, batch counts, system-group delete protection
- Validates appIds/roleIds/userIds (400 on missing) and wraps assignment writes in
  a single DB transaction (no partial state on failure)
- /api/users gains q/groupId/status filters, batch role+group loading, groupIds
  replacement on PATCH, auto-assigns Everyone on admin-create
- /auth/register also auto-assigns Everyone for consistent default linkage
- buildAuthUser now returns groups (matches updated AuthUser OpenAPI schema)
- App visibility (getVisibleAppsForUser) unions group-granted apps via
  group_apps + user_groups in addition to existing permission gating

Frontend (admin.tsx):
- Nav restructured: User Management section with Users + Groups children
- Section deep-linked via #section=… URL hash
- Users page rebuilt: search, group filter, status filter, sortable table,
  groups column, edit-groups dialog, mobile cards
- New Groups page: cards with member/app/role counts, create dialog,
  detail editor with Info/Apps/Users tabs and system-group guard
- ar/en translations added for all new keys

Testing:
- pnpm typecheck clean (api + web)
- 25/26 api tests pass; the only failure is pre-existing flaky pagination test
  (admin-app-opens-pagination) — left as-is per scratchpad note
- Code review feedback addressed (validation, transactions, register auto-assign)
This commit is contained in:
riyadhafraa
2026-04-22 08:28:31 +00:00
parent a772607636
commit f5273af19f
16 changed files with 2576 additions and 194 deletions
+110 -1
View File
@@ -9,8 +9,12 @@ import {
appPermissionsTable,
serviceCategoriesTable,
servicesTable,
groupsTable,
userGroupsTable,
groupAppsTable,
groupRolesTable,
} from "@workspace/db";
import { eq } from "drizzle-orm";
import { eq, inArray } from "drizzle-orm";
import bcrypt from "bcryptjs";
async function main() {
@@ -354,6 +358,111 @@ async function main() {
await db.insert(servicesTable).values(services).onConflictDoNothing();
console.log("Services created");
// ----- Groups: idempotent migration -----
await db
.insert(groupsTable)
.values([
{
name: "Admins",
descriptionAr: "مديرو النظام",
descriptionEn: "System administrators",
isSystem: 1,
},
{
name: "TeaBoy",
descriptionAr: "فريق إعداد وتقديم الطلبات",
descriptionEn: "Team that prepares and delivers orders",
isSystem: 1,
},
{
name: "Everyone",
descriptionAr: "كل المستخدمين",
descriptionEn: "All users",
isSystem: 1,
},
])
.onConflictDoNothing();
const allGroups = await db.select().from(groupsTable);
const adminsGroup = allGroups.find((g) => g.name === "Admins");
const teaboyGroup = allGroups.find((g) => g.name === "TeaBoy");
const everyoneGroup = allGroups.find((g) => g.name === "Everyone");
const allRolesNow = await db.select().from(rolesTable);
const adminRoleNow = allRolesNow.find((r) => r.name === "admin");
const orderReceiverNow = allRolesNow.find((r) => r.name === "order_receiver");
// Group → roles
if (adminsGroup && adminRoleNow) {
await db
.insert(groupRolesTable)
.values({ groupId: adminsGroup.id, roleId: adminRoleNow.id })
.onConflictDoNothing();
}
if (teaboyGroup && orderReceiverNow) {
await db
.insert(groupRolesTable)
.values({ groupId: teaboyGroup.id, roleId: orderReceiverNow.id })
.onConflictDoNothing();
}
// Group → apps
const allAppsNow = await db.select().from(appsTable);
if (adminsGroup) {
await db
.insert(groupAppsTable)
.values(allAppsNow.map((a) => ({ groupId: adminsGroup.id, appId: a.id })))
.onConflictDoNothing();
}
// TeaBoy gets the services app (which is where receivers see incoming orders)
if (teaboyGroup) {
const servicesApp = allAppsNow.find((a) => a.slug === "services");
if (servicesApp) {
await db
.insert(groupAppsTable)
.values({ groupId: teaboyGroup.id, appId: servicesApp.id })
.onConflictDoNothing();
}
}
// Map existing users to groups idempotently
const allUsersNow = await db.select({ id: usersTable.id }).from(usersTable);
if (everyoneGroup && allUsersNow.length > 0) {
await db
.insert(userGroupsTable)
.values(allUsersNow.map((u) => ({ userId: u.id, groupId: everyoneGroup.id })))
.onConflictDoNothing();
}
if (adminsGroup && adminRoleNow) {
const adminUserRows = await db
.select({ userId: userRolesTable.userId })
.from(userRolesTable)
.where(eq(userRolesTable.roleId, adminRoleNow.id));
if (adminUserRows.length > 0) {
await db
.insert(userGroupsTable)
.values(adminUserRows.map((r) => ({ userId: r.userId, groupId: adminsGroup.id })))
.onConflictDoNothing();
}
}
if (teaboyGroup && orderReceiverNow) {
const receiverRows = await db
.select({ userId: userRolesTable.userId })
.from(userRolesTable)
.where(eq(userRolesTable.roleId, orderReceiverNow.id));
if (receiverRows.length > 0) {
await db
.insert(userGroupsTable)
.values(receiverRows.map((r) => ({ userId: r.userId, groupId: teaboyGroup.id })))
.onConflictDoNothing();
}
}
// Reference inArray to keep import live for future use
void inArray;
console.log("Groups created and existing users mapped");
console.log("\n✅ Seeding complete!");
console.log("\nDemo accounts:");
console.log(" Admin: username=admin, password=admin123");