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:
riyadhafraa
2026-04-22 08:47:35 +00:00
parent bf0768948e
commit 4595e792e4
10 changed files with 136 additions and 48 deletions
+1 -1
View File
@@ -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 * Effective roles for a user = direct user_roles roles attached to any of
* the user's groups via group_roles. Returns distinct role names. * 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 const rows = await db
.select({ roleId: rolesTable.id }) .select({ roleId: rolesTable.id })
.from(rolesTable) .from(rolesTable)
+11 -8
View File
@@ -12,7 +12,7 @@ import {
userGroupsTable, userGroupsTable,
groupAppsTable, groupAppsTable,
} from "@workspace/db"; } from "@workspace/db";
import { requireAuth, requireAdmin } from "../middlewares/auth"; import { requireAuth, requireAdmin, getEffectiveRoleIds } from "../middlewares/auth";
import { import {
CreateAppBody, CreateAppBody,
UpdateAppBody, UpdateAppBody,
@@ -25,13 +25,16 @@ import {
const router: IRouter = Router(); const router: IRouter = Router();
async function getVisibleAppsForUser(userId: number): Promise<typeof appsTable.$inferSelect[]> { async function getVisibleAppsForUser(userId: number): Promise<typeof appsTable.$inferSelect[]> {
const userRoleRows = await db const effectiveRoleIds = await getEffectiveRoleIds(userId);
.select({ roleName: rolesTable.name, roleId: userRolesTable.roleId })
.from(userRolesTable)
.innerJoin(rolesTable, eq(userRolesTable.roleId, rolesTable.id))
.where(eq(userRolesTable.userId, 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). // Pull the user's preferred order, then order by COALESCE(user_order, app.sort_order, name).
const orderedRows = await db const orderedRows = await db
@@ -54,7 +57,7 @@ async function getVisibleAppsForUser(userId: number): Promise<typeof appsTable.$
if (isAdmin) return apps; if (isAdmin) return apps;
const userRoleIds = userRoleRows.map((r) => r.roleId); const userRoleIds = effectiveRoleIds;
const userPermissionIds = userRoleIds.length > 0 const userPermissionIds = userRoleIds.length > 0
? (await db ? (await db
.select({ permissionId: rolePermissionsTable.permissionId }) .select({ permissionId: rolePermissionsTable.permissionId })
+43 -10
View File
@@ -11,6 +11,7 @@ import {
import { requireAuth, requireAdmin, getUserRoles } from "../middlewares/auth"; import { requireAuth, requireAdmin, getUserRoles } from "../middlewares/auth";
import { import {
RegisterBody, RegisterBody,
CreateUserBody,
GetUserParams, GetUserParams,
UpdateUserParams, UpdateUserParams,
UpdateUserBody, UpdateUserBody,
@@ -81,7 +82,7 @@ function buildUserProfile(
} }
router.post("/users", requireAdmin, async (req, res): Promise<void> => { router.post("/users", requireAdmin, async (req, res): Promise<void> => {
const parsed = RegisterBody.safeParse(req.body); const parsed = CreateUserBody.safeParse(req.body);
if (!parsed.success) { if (!parsed.success) {
res.status(400).json({ error: parsed.error.message }); res.status(400).json({ error: parsed.error.message });
return; return;
@@ -97,9 +98,27 @@ router.post("/users", requireAdmin, async (req, res): Promise<void> => {
return; 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 passwordHash = await bcrypt.hash(parsed.data.password, 12);
const [user] = await db const user = await db.transaction(async (tx) => {
const [created] = await tx
.insert(usersTable) .insert(usersTable)
.values({ .values({
username: parsed.data.username, username: parsed.data.username,
@@ -111,23 +130,32 @@ router.post("/users", requireAdmin, async (req, res): Promise<void> => {
}) })
.returning(); .returning();
if (!user) { if (!created) throw new Error("Failed to create user");
res.status(500).json({ error: "Failed to create user" });
return;
}
// Auto-assign Everyone group if it exists // Auto-assign Everyone group if it exists
const [everyone] = await db const [everyone] = await tx
.select({ id: groupsTable.id }) .select({ id: groupsTable.id })
.from(groupsTable) .from(groupsTable)
.where(eq(groupsTable.name, "Everyone")); .where(eq(groupsTable.name, "Everyone"));
if (everyone) {
await db const groupIdsToAssign = new Set<number>(requestedGroupIds);
if (everyone) groupIdsToAssign.add(everyone.id);
if (groupIdsToAssign.size > 0) {
await tx
.insert(userGroupsTable) .insert(userGroupsTable)
.values({ userId: user.id, groupId: everyone.id }) .values(
Array.from(groupIdsToAssign).map((groupId) => ({
userId: created.id,
groupId,
})),
)
.onConflictDoNothing(); .onConflictDoNothing();
} }
return created;
});
const groups = await getGroupsForUser(user.id); const groups = await getGroupsForUser(user.id);
res.status(201).json(buildUserProfile(user, [], groups)); res.status(201).json(buildUserProfile(user, [], groups));
}); });
@@ -408,6 +436,11 @@ router.delete("/users/:id", requireAdmin, async (req, res): Promise<void> => {
return; return;
} }
if (req.session.userId === params.data.id) {
res.status(400).json({ error: "You cannot delete your own account" });
return;
}
const [user] = await db const [user] = await db
.delete(usersTable) .delete(usersTable)
.where(eq(usersTable.id, params.data.id)) .where(eq(usersTable.id, params.data.id))
+4
View File
@@ -366,12 +366,16 @@
"col": { "col": {
"username": "اسم المستخدم", "username": "اسم المستخدم",
"email": "البريد", "email": "البريد",
"displayNameAr": "الاسم (عربي)",
"displayNameEn": "الاسم (إنجليزي)",
"language": "اللغة",
"createdAt": "تاريخ الإنشاء", "createdAt": "تاريخ الإنشاء",
"groups": "المجموعات", "groups": "المجموعات",
"actions": "إجراءات" "actions": "إجراءات"
} }
}, },
"groups": { "groups": {
"searchPlaceholder": "ابحث في المجموعات...",
"addGroup": "إضافة مجموعة", "addGroup": "إضافة مجموعة",
"editGroup": "تعديل المجموعة", "editGroup": "تعديل المجموعة",
"name": "الاسم", "name": "الاسم",
+4
View File
@@ -363,12 +363,16 @@
"col": { "col": {
"username": "Username", "username": "Username",
"email": "Email", "email": "Email",
"displayNameAr": "Name (Arabic)",
"displayNameEn": "Name (English)",
"language": "Language",
"createdAt": "Created", "createdAt": "Created",
"groups": "Groups", "groups": "Groups",
"actions": "Actions" "actions": "Actions"
} }
}, },
"groups": { "groups": {
"searchPlaceholder": "Search groups...",
"addGroup": "Add Group", "addGroup": "Add Group",
"editGroup": "Edit Group", "editGroup": "Edit Group",
"name": "Name", "name": "Name",
@@ -28,6 +28,19 @@ export interface RegisterBody {
preferredLanguage?: string; preferredLanguage?: string;
} }
export interface CreateUserBody {
username: string;
email: string;
password: string;
/** @nullable */
displayNameAr?: string | null;
/** @nullable */
displayNameEn?: string | null;
preferredLanguage?: string;
/** Initial group memberships in addition to the auto-assigned Everyone group. */
groupIds?: number[];
}
export interface LoginBody { export interface LoginBody {
username: string; username: string;
password: string; password: string;
+9 -8
View File
@@ -32,6 +32,7 @@ import type {
CreateGroupBody, CreateGroupBody,
CreateServiceBody, CreateServiceBody,
CreateServiceOrderBody, CreateServiceOrderBody,
CreateUserBody,
ErrorResponse, ErrorResponse,
ForgotPasswordBody, ForgotPasswordBody,
ForgotPasswordResponse, ForgotPasswordResponse,
@@ -4086,14 +4087,14 @@ export const getCreateUserUrl = () => {
}; };
export const createUser = async ( export const createUser = async (
registerBody: RegisterBody, createUserBody: CreateUserBody,
options?: RequestInit, options?: RequestInit,
): Promise<UserProfile> => { ): Promise<UserProfile> => {
return customFetch<UserProfile>(getCreateUserUrl(), { return customFetch<UserProfile>(getCreateUserUrl(), {
...options, ...options,
method: "POST", method: "POST",
headers: { "Content-Type": "application/json", ...options?.headers }, headers: { "Content-Type": "application/json", ...options?.headers },
body: JSON.stringify(registerBody), body: JSON.stringify(createUserBody),
}); });
}; };
@@ -4104,14 +4105,14 @@ export const getCreateUserMutationOptions = <
mutation?: UseMutationOptions< mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createUser>>, Awaited<ReturnType<typeof createUser>>,
TError, TError,
{ data: BodyType<RegisterBody> }, { data: BodyType<CreateUserBody> },
TContext TContext
>; >;
request?: SecondParameter<typeof customFetch>; request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions< }): UseMutationOptions<
Awaited<ReturnType<typeof createUser>>, Awaited<ReturnType<typeof createUser>>,
TError, TError,
{ data: BodyType<RegisterBody> }, { data: BodyType<CreateUserBody> },
TContext TContext
> => { > => {
const mutationKey = ["createUser"]; const mutationKey = ["createUser"];
@@ -4125,7 +4126,7 @@ export const getCreateUserMutationOptions = <
const mutationFn: MutationFunction< const mutationFn: MutationFunction<
Awaited<ReturnType<typeof createUser>>, Awaited<ReturnType<typeof createUser>>,
{ data: BodyType<RegisterBody> } { data: BodyType<CreateUserBody> }
> = (props) => { > = (props) => {
const { data } = props ?? {}; const { data } = props ?? {};
@@ -4138,7 +4139,7 @@ export const getCreateUserMutationOptions = <
export type CreateUserMutationResult = NonNullable< export type CreateUserMutationResult = NonNullable<
Awaited<ReturnType<typeof createUser>> Awaited<ReturnType<typeof createUser>>
>; >;
export type CreateUserMutationBody = BodyType<RegisterBody>; export type CreateUserMutationBody = BodyType<CreateUserBody>;
export type CreateUserMutationError = ErrorType<void>; export type CreateUserMutationError = ErrorType<void>;
/** /**
@@ -4151,14 +4152,14 @@ export const useCreateUser = <
mutation?: UseMutationOptions< mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createUser>>, Awaited<ReturnType<typeof createUser>>,
TError, TError,
{ data: BodyType<RegisterBody> }, { data: BodyType<CreateUserBody> },
TContext TContext
>; >;
request?: SecondParameter<typeof customFetch>; request?: SecondParameter<typeof customFetch>;
}): UseMutationResult< }): UseMutationResult<
Awaited<ReturnType<typeof createUser>>, Awaited<ReturnType<typeof createUser>>,
TError, TError,
{ data: BodyType<RegisterBody> }, { data: BodyType<CreateUserBody> },
TContext TContext
> => { > => {
return useMutation(getCreateUserMutationOptions(options)); return useMutation(getCreateUserMutationOptions(options));
+27 -1
View File
@@ -1077,7 +1077,7 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/RegisterBody" $ref: "#/components/schemas/CreateUserBody"
responses: responses:
"201": "201":
description: Created user description: Created user
@@ -1584,6 +1584,32 @@ components:
- email - email
- password - password
CreateUserBody:
type: object
properties:
username:
type: string
email:
type: string
password:
type: string
displayNameAr:
type: ["string", "null"]
displayNameEn:
type: ["string", "null"]
preferredLanguage:
type: string
default: "ar"
groupIds:
type: array
description: Initial group memberships in addition to the auto-assigned Everyone group.
items:
type: integer
required:
- username
- email
- password
LoginBody: LoginBody:
type: object type: object
properties: properties:
+6
View File
@@ -1394,6 +1394,12 @@ export const CreateUserBody = zod.object({
preferredLanguage: zod preferredLanguage: zod
.string() .string()
.default(createUserBodyPreferredLanguageDefault), .default(createUserBodyPreferredLanguageDefault),
groupIds: zod
.array(zod.number())
.optional()
.describe(
"Initial group memberships in addition to the auto-assigned Everyone group.",
),
}); });
/** /**
+1 -3
View File
@@ -14,7 +14,7 @@ import {
groupAppsTable, groupAppsTable,
groupRolesTable, groupRolesTable,
} from "@workspace/db"; } from "@workspace/db";
import { eq, inArray } from "drizzle-orm"; import { eq } from "drizzle-orm";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
async function main() { async function main() {
@@ -459,8 +459,6 @@ async function main() {
.onConflictDoNothing(); .onConflictDoNothing();
} }
} }
// Reference inArray to keep import live for future use
void inArray;
console.log("Groups created and existing users mapped"); console.log("Groups created and existing users mapped");
console.log("\n✅ Seeding complete!"); console.log("\n✅ Seeding complete!");