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:
Riyadh
2026-04-22 08:28:31 +00:00
parent 1ef663d6f4
commit 2f28260fb2
16 changed files with 2576 additions and 194 deletions
+62
View File
@@ -0,0 +1,62 @@
import { pgTable, text, serial, timestamp, integer, varchar, primaryKey } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { z } from "zod/v4";
import { usersTable } from "./users";
import { rolesTable } from "./roles";
import { appsTable } from "./apps";
export const groupsTable = pgTable("groups", {
id: serial("id").primaryKey(),
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(),
});
export const userGroupsTable = pgTable(
"user_groups",
{
userId: integer("user_id")
.notNull()
.references(() => usersTable.id, { onDelete: "cascade" }),
groupId: integer("group_id")
.notNull()
.references(() => groupsTable.id, { onDelete: "cascade" }),
assignedAt: timestamp("assigned_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => [primaryKey({ columns: [t.userId, t.groupId] })],
);
export const groupAppsTable = pgTable(
"group_apps",
{
groupId: integer("group_id")
.notNull()
.references(() => groupsTable.id, { onDelete: "cascade" }),
appId: integer("app_id")
.notNull()
.references(() => appsTable.id, { onDelete: "cascade" }),
},
(t) => [primaryKey({ columns: [t.groupId, t.appId] })],
);
export const groupRolesTable = pgTable(
"group_roles",
{
groupId: integer("group_id")
.notNull()
.references(() => groupsTable.id, { onDelete: "cascade" }),
roleId: integer("role_id")
.notNull()
.references(() => rolesTable.id, { onDelete: "cascade" }),
},
(t) => [primaryKey({ columns: [t.groupId, t.roleId] })],
);
export const insertGroupSchema = createInsertSchema(groupsTable).omit({
id: true,
createdAt: true,
});
export type InsertGroup = z.infer<typeof insertGroupSchema>;
export type Group = typeof groupsTable.$inferSelect;
+1
View File
@@ -10,3 +10,4 @@ export * from "./user-app-orders";
export * from "./app-opens";
export * from "./password-reset-tokens";
export * from "./notes";
export * from "./groups";