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
+186
View File
@@ -55,6 +55,14 @@ export const LoginResponse = zod.object({
avatarUrl: zod.string().nullish(),
isActive: zod.boolean(),
roles: zod.array(zod.string()),
groups: zod.array(
zod.object({
id: zod.number(),
name: zod.string(),
descriptionAr: zod.string().nullish(),
descriptionEn: zod.string().nullish(),
}),
),
createdAt: zod.coerce.date(),
});
@@ -135,6 +143,14 @@ export const GetMeResponse = zod.object({
avatarUrl: zod.string().nullish(),
isActive: zod.boolean(),
roles: zod.array(zod.string()),
groups: zod.array(
zod.object({
id: zod.number(),
name: zod.string(),
descriptionAr: zod.string().nullish(),
descriptionEn: zod.string().nullish(),
}),
),
createdAt: zod.coerce.date(),
});
@@ -164,6 +180,14 @@ export const UpdateLanguageResponse = zod.object({
avatarUrl: zod.string().nullish(),
isActive: zod.boolean(),
roles: zod.array(zod.string()),
groups: zod.array(
zod.object({
id: zod.number(),
name: zod.string(),
descriptionAr: zod.string().nullish(),
descriptionEn: zod.string().nullish(),
}),
),
createdAt: zod.coerce.date(),
});
@@ -200,6 +224,14 @@ export const UpdateClockStyleResponse = zod.object({
avatarUrl: zod.string().nullish(),
isActive: zod.boolean(),
roles: zod.array(zod.string()),
groups: zod.array(
zod.object({
id: zod.number(),
name: zod.string(),
descriptionAr: zod.string().nullish(),
descriptionEn: zod.string().nullish(),
}),
),
createdAt: zod.coerce.date(),
});
@@ -234,6 +266,14 @@ export const UpdateClockHour12Response = zod.object({
avatarUrl: zod.string().nullish(),
isActive: zod.boolean(),
roles: zod.array(zod.string()),
groups: zod.array(
zod.object({
id: zod.number(),
name: zod.string(),
descriptionAr: zod.string().nullish(),
descriptionEn: zod.string().nullish(),
}),
),
createdAt: zod.coerce.date(),
});
@@ -1294,6 +1334,21 @@ export const MarkAllNotificationsReadResponse = zod.object({
/**
* @summary List all users (admin)
*/
export const ListUsersQueryParams = zod.object({
q: zod.coerce
.string()
.optional()
.describe("Free-text search across username, email, and display names"),
groupId: zod.coerce
.number()
.optional()
.describe("Filter to users that belong to the given group"),
status: zod
.enum(["active", "disabled"])
.optional()
.describe("Filter by active\/disabled state"),
});
export const ListUsersResponseItem = zod.object({
id: zod.number(),
username: zod.string(),
@@ -1313,6 +1368,14 @@ export const ListUsersResponseItem = zod.object({
avatarUrl: zod.string().nullish(),
isActive: zod.boolean(),
roles: zod.array(zod.string()),
groups: zod.array(
zod.object({
id: zod.number(),
name: zod.string(),
descriptionAr: zod.string().nullish(),
descriptionEn: zod.string().nullish(),
}),
),
createdAt: zod.coerce.date(),
});
export const ListUsersResponse = zod.array(ListUsersResponseItem);
@@ -1359,6 +1422,14 @@ export const GetUserResponse = zod.object({
avatarUrl: zod.string().nullish(),
isActive: zod.boolean(),
roles: zod.array(zod.string()),
groups: zod.array(
zod.object({
id: zod.number(),
name: zod.string(),
descriptionAr: zod.string().nullish(),
descriptionEn: zod.string().nullish(),
}),
),
createdAt: zod.coerce.date(),
});
@@ -1374,6 +1445,10 @@ export const UpdateUserBody = zod.object({
displayNameEn: zod.string().nullish(),
isActive: zod.boolean().optional(),
preferredLanguage: zod.string().optional(),
groupIds: zod
.array(zod.number())
.optional()
.describe("If provided, replaces the user's group memberships"),
});
export const UpdateUserResponse = zod.object({
@@ -1395,6 +1470,14 @@ export const UpdateUserResponse = zod.object({
avatarUrl: zod.string().nullish(),
isActive: zod.boolean(),
roles: zod.array(zod.string()),
groups: zod.array(
zod.object({
id: zod.number(),
name: zod.string(),
descriptionAr: zod.string().nullish(),
descriptionEn: zod.string().nullish(),
}),
),
createdAt: zod.coerce.date(),
});
@@ -1446,6 +1529,14 @@ export const AddUserRoleResponse = zod.object({
avatarUrl: zod.string().nullish(),
isActive: zod.boolean(),
roles: zod.array(zod.string()),
groups: zod.array(
zod.object({
id: zod.number(),
name: zod.string(),
descriptionAr: zod.string().nullish(),
descriptionEn: zod.string().nullish(),
}),
),
createdAt: zod.coerce.date(),
});
@@ -1476,9 +1567,104 @@ export const RemoveUserRoleResponse = zod.object({
avatarUrl: zod.string().nullish(),
isActive: zod.boolean(),
roles: zod.array(zod.string()),
groups: zod.array(
zod.object({
id: zod.number(),
name: zod.string(),
descriptionAr: zod.string().nullish(),
descriptionEn: zod.string().nullish(),
}),
),
createdAt: zod.coerce.date(),
});
/**
* @summary List groups (admin)
*/
export const ListGroupsQueryParams = zod.object({
q: zod.coerce.string().optional(),
});
export const ListGroupsResponseItem = zod.object({
id: zod.number(),
name: zod.string(),
descriptionAr: zod.string().nullish(),
descriptionEn: zod.string().nullish(),
isSystem: zod.boolean(),
memberCount: zod.number(),
appCount: zod.number(),
roleCount: zod.number(),
createdAt: zod.coerce.date(),
});
export const ListGroupsResponse = zod.array(ListGroupsResponseItem);
/**
* @summary Create group (admin)
*/
export const CreateGroupBody = zod.object({
name: zod.string().min(1),
descriptionAr: zod.string().nullish(),
descriptionEn: zod.string().nullish(),
appIds: zod.array(zod.number()).optional(),
roleIds: zod.array(zod.number()).optional(),
userIds: zod.array(zod.number()).optional(),
});
/**
* @summary Get group with members and apps (admin)
*/
export const GetGroupParams = zod.object({
id: zod.coerce.number(),
});
export const GetGroupResponse = zod.object({
id: zod.number(),
name: zod.string(),
descriptionAr: zod.string().nullish(),
descriptionEn: zod.string().nullish(),
isSystem: zod.boolean(),
appIds: zod.array(zod.number()),
roleIds: zod.array(zod.number()),
userIds: zod.array(zod.number()),
createdAt: zod.coerce.date(),
});
/**
* @summary Update group (admin)
*/
export const UpdateGroupParams = zod.object({
id: zod.coerce.number(),
});
export const UpdateGroupBody = zod.object({
name: zod.string().min(1).optional(),
descriptionAr: zod.string().nullish(),
descriptionEn: zod.string().nullish(),
appIds: zod.array(zod.number()).optional(),
roleIds: zod.array(zod.number()).optional(),
userIds: zod.array(zod.number()).optional(),
});
export const UpdateGroupResponse = zod.object({
id: zod.number(),
name: zod.string(),
descriptionAr: zod.string().nullish(),
descriptionEn: zod.string().nullish(),
isSystem: zod.boolean(),
appIds: zod.array(zod.number()),
roleIds: zod.array(zod.number()),
userIds: zod.array(zod.number()),
createdAt: zod.coerce.date(),
});
/**
* @summary Delete group (admin)
*/
export const DeleteGroupParams = zod.object({
id: zod.coerce.number(),
});
/**
* @summary Get home screen stats
*/