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:
+110
-1
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user