Fix 5 validator blockers in groups/users admin work
1. apps.ts getVisibleAppsForUser: use getEffectiveRoleIds() so admin
gate honors group_roles inheritance (was direct user_roles only —
security bypass for group-only admins). Exported helper from
middlewares/auth.ts.
2. POST /users: switch from RegisterBody to new CreateUserBody schema
that accepts optional groupIds[]. Validates ids exist; user creation
+ auto-Everyone + requested groups happen in single transaction.
OpenAPI spec extended; api-zod and api-client-react regenerated.
3. DELETE /users/🆔 400 if req.session.userId === target (no
self-delete).
4. scripts/src/seed.ts: removed `void inArray;` AI-slop and dropped
unused inArray import.
5. Locales (ar.json + en.json): added admin.users.col.displayNameAr,
displayNameEn, language and admin.groups.searchPlaceholder.
Typecheck clean. groups-crud + service-orders + users tests pass.
Pre-existing admin-app-opens-pagination flake unrelated.
Architect re-review: PASS.
This commit is contained in:
@@ -15,7 +15,7 @@ import { eq, and, inArray, or } from "drizzle-orm";
|
||||
* Effective roles for a user = direct user_roles ∪ roles attached to any of
|
||||
* the user's groups via group_roles. Returns distinct role names.
|
||||
*/
|
||||
async function getEffectiveRoleIds(userId: number): Promise<number[]> {
|
||||
export async function getEffectiveRoleIds(userId: number): Promise<number[]> {
|
||||
const rows = await db
|
||||
.select({ roleId: rolesTable.id })
|
||||
.from(rolesTable)
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
userGroupsTable,
|
||||
groupAppsTable,
|
||||
} from "@workspace/db";
|
||||
import { requireAuth, requireAdmin } from "../middlewares/auth";
|
||||
import { requireAuth, requireAdmin, getEffectiveRoleIds } from "../middlewares/auth";
|
||||
import {
|
||||
CreateAppBody,
|
||||
UpdateAppBody,
|
||||
@@ -25,13 +25,16 @@ import {
|
||||
const router: IRouter = Router();
|
||||
|
||||
async function getVisibleAppsForUser(userId: number): Promise<typeof appsTable.$inferSelect[]> {
|
||||
const userRoleRows = await db
|
||||
.select({ roleName: rolesTable.name, roleId: userRolesTable.roleId })
|
||||
.from(userRolesTable)
|
||||
.innerJoin(rolesTable, eq(userRolesTable.roleId, rolesTable.id))
|
||||
.where(eq(userRolesTable.userId, userId));
|
||||
const effectiveRoleIds = await getEffectiveRoleIds(userId);
|
||||
|
||||
const isAdmin = userRoleRows.some((r) => r.roleName === "admin");
|
||||
const adminRoleRows = effectiveRoleIds.length > 0
|
||||
? await db
|
||||
.select({ id: rolesTable.id })
|
||||
.from(rolesTable)
|
||||
.where(sql`${rolesTable.id} = ANY(${effectiveRoleIds}) and ${rolesTable.name} = 'admin'`)
|
||||
: [];
|
||||
|
||||
const isAdmin = adminRoleRows.length > 0;
|
||||
|
||||
// Pull the user's preferred order, then order by COALESCE(user_order, app.sort_order, name).
|
||||
const orderedRows = await db
|
||||
@@ -54,7 +57,7 @@ async function getVisibleAppsForUser(userId: number): Promise<typeof appsTable.$
|
||||
|
||||
if (isAdmin) return apps;
|
||||
|
||||
const userRoleIds = userRoleRows.map((r) => r.roleId);
|
||||
const userRoleIds = effectiveRoleIds;
|
||||
const userPermissionIds = userRoleIds.length > 0
|
||||
? (await db
|
||||
.select({ permissionId: rolePermissionsTable.permissionId })
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { requireAuth, requireAdmin, getUserRoles } from "../middlewares/auth";
|
||||
import {
|
||||
RegisterBody,
|
||||
CreateUserBody,
|
||||
GetUserParams,
|
||||
UpdateUserParams,
|
||||
UpdateUserBody,
|
||||
@@ -81,7 +82,7 @@ function buildUserProfile(
|
||||
}
|
||||
|
||||
router.post("/users", requireAdmin, async (req, res): Promise<void> => {
|
||||
const parsed = RegisterBody.safeParse(req.body);
|
||||
const parsed = CreateUserBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.message });
|
||||
return;
|
||||
@@ -97,36 +98,63 @@ router.post("/users", requireAdmin, async (req, res): Promise<void> => {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestedGroupIds = Array.isArray((parsed.data as { groupIds?: unknown }).groupIds)
|
||||
? ((parsed.data as { groupIds?: number[] }).groupIds ?? []).filter(
|
||||
(id): id is number => Number.isInteger(id),
|
||||
)
|
||||
: [];
|
||||
|
||||
if (requestedGroupIds.length > 0) {
|
||||
const found = await db
|
||||
.select({ id: groupsTable.id })
|
||||
.from(groupsTable)
|
||||
.where(inArray(groupsTable.id, requestedGroupIds));
|
||||
if (found.length !== new Set(requestedGroupIds).size) {
|
||||
res.status(400).json({ error: "One or more groupIds are invalid" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(parsed.data.password, 12);
|
||||
|
||||
const [user] = await db
|
||||
.insert(usersTable)
|
||||
.values({
|
||||
username: parsed.data.username,
|
||||
email: parsed.data.email,
|
||||
passwordHash,
|
||||
displayNameAr: parsed.data.displayNameAr ?? null,
|
||||
displayNameEn: parsed.data.displayNameEn ?? null,
|
||||
preferredLanguage: parsed.data.preferredLanguage,
|
||||
})
|
||||
.returning();
|
||||
const user = await db.transaction(async (tx) => {
|
||||
const [created] = await tx
|
||||
.insert(usersTable)
|
||||
.values({
|
||||
username: parsed.data.username,
|
||||
email: parsed.data.email,
|
||||
passwordHash,
|
||||
displayNameAr: parsed.data.displayNameAr ?? null,
|
||||
displayNameEn: parsed.data.displayNameEn ?? null,
|
||||
preferredLanguage: parsed.data.preferredLanguage,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!user) {
|
||||
res.status(500).json({ error: "Failed to create user" });
|
||||
return;
|
||||
}
|
||||
if (!created) throw new Error("Failed to create user");
|
||||
|
||||
// Auto-assign Everyone group if it exists
|
||||
const [everyone] = await db
|
||||
.select({ id: groupsTable.id })
|
||||
.from(groupsTable)
|
||||
.where(eq(groupsTable.name, "Everyone"));
|
||||
if (everyone) {
|
||||
await db
|
||||
.insert(userGroupsTable)
|
||||
.values({ userId: user.id, groupId: everyone.id })
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
// Auto-assign Everyone group if it exists
|
||||
const [everyone] = await tx
|
||||
.select({ id: groupsTable.id })
|
||||
.from(groupsTable)
|
||||
.where(eq(groupsTable.name, "Everyone"));
|
||||
|
||||
const groupIdsToAssign = new Set<number>(requestedGroupIds);
|
||||
if (everyone) groupIdsToAssign.add(everyone.id);
|
||||
|
||||
if (groupIdsToAssign.size > 0) {
|
||||
await tx
|
||||
.insert(userGroupsTable)
|
||||
.values(
|
||||
Array.from(groupIdsToAssign).map((groupId) => ({
|
||||
userId: created.id,
|
||||
groupId,
|
||||
})),
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
return created;
|
||||
});
|
||||
|
||||
const groups = await getGroupsForUser(user.id);
|
||||
res.status(201).json(buildUserProfile(user, [], groups));
|
||||
@@ -408,6 +436,11 @@ router.delete("/users/:id", requireAdmin, async (req, res): Promise<void> => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.session.userId === params.data.id) {
|
||||
res.status(400).json({ error: "You cannot delete your own account" });
|
||||
return;
|
||||
}
|
||||
|
||||
const [user] = await db
|
||||
.delete(usersTable)
|
||||
.where(eq(usersTable.id, params.data.id))
|
||||
|
||||
@@ -366,12 +366,16 @@
|
||||
"col": {
|
||||
"username": "اسم المستخدم",
|
||||
"email": "البريد",
|
||||
"displayNameAr": "الاسم (عربي)",
|
||||
"displayNameEn": "الاسم (إنجليزي)",
|
||||
"language": "اللغة",
|
||||
"createdAt": "تاريخ الإنشاء",
|
||||
"groups": "المجموعات",
|
||||
"actions": "إجراءات"
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
"searchPlaceholder": "ابحث في المجموعات...",
|
||||
"addGroup": "إضافة مجموعة",
|
||||
"editGroup": "تعديل المجموعة",
|
||||
"name": "الاسم",
|
||||
|
||||
@@ -363,12 +363,16 @@
|
||||
"col": {
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
"displayNameAr": "Name (Arabic)",
|
||||
"displayNameEn": "Name (English)",
|
||||
"language": "Language",
|
||||
"createdAt": "Created",
|
||||
"groups": "Groups",
|
||||
"actions": "Actions"
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
"searchPlaceholder": "Search groups...",
|
||||
"addGroup": "Add Group",
|
||||
"editGroup": "Edit Group",
|
||||
"name": "Name",
|
||||
|
||||
Reference in New Issue
Block a user