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
|
* 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)
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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": "الاسم",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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!");
|
||||||
|
|||||||
Reference in New Issue
Block a user