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,9 +98,27 @@ 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
|
||||
const user = await db.transaction(async (tx) => {
|
||||
const [created] = await tx
|
||||
.insert(usersTable)
|
||||
.values({
|
||||
username: parsed.data.username,
|
||||
@@ -111,23 +130,32 @@ router.post("/users", requireAdmin, async (req, res): Promise<void> => {
|
||||
})
|
||||
.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
|
||||
const [everyone] = await tx
|
||||
.select({ id: groupsTable.id })
|
||||
.from(groupsTable)
|
||||
.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)
|
||||
.values({ userId: user.id, groupId: everyone.id })
|
||||
.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",
|
||||
|
||||
@@ -28,6 +28,19 @@ export interface RegisterBody {
|
||||
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 {
|
||||
username: string;
|
||||
password: string;
|
||||
|
||||
@@ -32,6 +32,7 @@ import type {
|
||||
CreateGroupBody,
|
||||
CreateServiceBody,
|
||||
CreateServiceOrderBody,
|
||||
CreateUserBody,
|
||||
ErrorResponse,
|
||||
ForgotPasswordBody,
|
||||
ForgotPasswordResponse,
|
||||
@@ -4086,14 +4087,14 @@ export const getCreateUserUrl = () => {
|
||||
};
|
||||
|
||||
export const createUser = async (
|
||||
registerBody: RegisterBody,
|
||||
createUserBody: CreateUserBody,
|
||||
options?: RequestInit,
|
||||
): Promise<UserProfile> => {
|
||||
return customFetch<UserProfile>(getCreateUserUrl(), {
|
||||
...options,
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...options?.headers },
|
||||
body: JSON.stringify(registerBody),
|
||||
body: JSON.stringify(createUserBody),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -4104,14 +4105,14 @@ export const getCreateUserMutationOptions = <
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createUser>>,
|
||||
TError,
|
||||
{ data: BodyType<RegisterBody> },
|
||||
{ data: BodyType<CreateUserBody> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createUser>>,
|
||||
TError,
|
||||
{ data: BodyType<RegisterBody> },
|
||||
{ data: BodyType<CreateUserBody> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ["createUser"];
|
||||
@@ -4125,7 +4126,7 @@ export const getCreateUserMutationOptions = <
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof createUser>>,
|
||||
{ data: BodyType<RegisterBody> }
|
||||
{ data: BodyType<CreateUserBody> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
@@ -4138,7 +4139,7 @@ export const getCreateUserMutationOptions = <
|
||||
export type CreateUserMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createUser>>
|
||||
>;
|
||||
export type CreateUserMutationBody = BodyType<RegisterBody>;
|
||||
export type CreateUserMutationBody = BodyType<CreateUserBody>;
|
||||
export type CreateUserMutationError = ErrorType<void>;
|
||||
|
||||
/**
|
||||
@@ -4151,14 +4152,14 @@ export const useCreateUser = <
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createUser>>,
|
||||
TError,
|
||||
{ data: BodyType<RegisterBody> },
|
||||
{ data: BodyType<CreateUserBody> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createUser>>,
|
||||
TError,
|
||||
{ data: BodyType<RegisterBody> },
|
||||
{ data: BodyType<CreateUserBody> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getCreateUserMutationOptions(options));
|
||||
|
||||
@@ -1077,7 +1077,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/RegisterBody"
|
||||
$ref: "#/components/schemas/CreateUserBody"
|
||||
responses:
|
||||
"201":
|
||||
description: Created user
|
||||
@@ -1584,6 +1584,32 @@ components:
|
||||
- email
|
||||
- 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:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -1394,6 +1394,12 @@ export const CreateUserBody = zod.object({
|
||||
preferredLanguage: zod
|
||||
.string()
|
||||
.default(createUserBodyPreferredLanguageDefault),
|
||||
groupIds: zod
|
||||
.array(zod.number())
|
||||
.optional()
|
||||
.describe(
|
||||
"Initial group memberships in addition to the auto-assigned Everyone group.",
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
+1
-3
@@ -14,7 +14,7 @@ import {
|
||||
groupAppsTable,
|
||||
groupRolesTable,
|
||||
} from "@workspace/db";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { eq } from "drizzle-orm";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
async function main() {
|
||||
@@ -459,8 +459,6 @@ async function main() {
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
}
|
||||
// Reference inArray to keep import live for future use
|
||||
void inArray;
|
||||
console.log("Groups created and existing users mapped");
|
||||
|
||||
console.log("\n✅ Seeding complete!");
|
||||
|
||||
Reference in New Issue
Block a user