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:
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user