diff --git a/artifacts/api-server/src/middlewares/auth.ts b/artifacts/api-server/src/middlewares/auth.ts index 22f08b1e..1b169ce2 100644 --- a/artifacts/api-server/src/middlewares/auth.ts +++ b/artifacts/api-server/src/middlewares/auth.ts @@ -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 { +export async function getEffectiveRoleIds(userId: number): Promise { const rows = await db .select({ roleId: rolesTable.id }) .from(rolesTable) diff --git a/artifacts/api-server/src/routes/apps.ts b/artifacts/api-server/src/routes/apps.ts index a31adb88..91ab2b67 100644 --- a/artifacts/api-server/src/routes/apps.ts +++ b/artifacts/api-server/src/routes/apps.ts @@ -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 { - 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 r.roleId); + const userRoleIds = effectiveRoleIds; const userPermissionIds = userRoleIds.length > 0 ? (await db .select({ permissionId: rolePermissionsTable.permissionId }) diff --git a/artifacts/api-server/src/routes/users.ts b/artifacts/api-server/src/routes/users.ts index ac1b3e4c..48ab016b 100644 --- a/artifacts/api-server/src/routes/users.ts +++ b/artifacts/api-server/src/routes/users.ts @@ -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 => { - 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 => { 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(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 => { 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)) diff --git a/artifacts/teaboy-os/src/locales/ar.json b/artifacts/teaboy-os/src/locales/ar.json index 7819fd16..52f475b6 100644 --- a/artifacts/teaboy-os/src/locales/ar.json +++ b/artifacts/teaboy-os/src/locales/ar.json @@ -366,12 +366,16 @@ "col": { "username": "اسم المستخدم", "email": "البريد", + "displayNameAr": "الاسم (عربي)", + "displayNameEn": "الاسم (إنجليزي)", + "language": "اللغة", "createdAt": "تاريخ الإنشاء", "groups": "المجموعات", "actions": "إجراءات" } }, "groups": { + "searchPlaceholder": "ابحث في المجموعات...", "addGroup": "إضافة مجموعة", "editGroup": "تعديل المجموعة", "name": "الاسم", diff --git a/artifacts/teaboy-os/src/locales/en.json b/artifacts/teaboy-os/src/locales/en.json index 43ec01bd..453f6653 100644 --- a/artifacts/teaboy-os/src/locales/en.json +++ b/artifacts/teaboy-os/src/locales/en.json @@ -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", diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index 29bfeeeb..98630b86 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -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; diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index 6dc8cb22..452be85a 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -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 => { return customFetch(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>, TError, - { data: BodyType }, + { data: BodyType }, TContext >; request?: SecondParameter; }): UseMutationOptions< Awaited>, TError, - { data: BodyType }, + { data: BodyType }, TContext > => { const mutationKey = ["createUser"]; @@ -4125,7 +4126,7 @@ export const getCreateUserMutationOptions = < const mutationFn: MutationFunction< Awaited>, - { data: BodyType } + { data: BodyType } > = (props) => { const { data } = props ?? {}; @@ -4138,7 +4139,7 @@ export const getCreateUserMutationOptions = < export type CreateUserMutationResult = NonNullable< Awaited> >; -export type CreateUserMutationBody = BodyType; +export type CreateUserMutationBody = BodyType; export type CreateUserMutationError = ErrorType; /** @@ -4151,14 +4152,14 @@ export const useCreateUser = < mutation?: UseMutationOptions< Awaited>, TError, - { data: BodyType }, + { data: BodyType }, TContext >; request?: SecondParameter; }): UseMutationResult< Awaited>, TError, - { data: BodyType }, + { data: BodyType }, TContext > => { return useMutation(getCreateUserMutationOptions(options)); diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index ec6d82ef..b924ac01 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -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: diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index cd918d4e..2c89aa7f 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -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.", + ), }); /** diff --git a/scripts/src/seed.ts b/scripts/src/seed.ts index 7019c7a3..66ad0704 100644 --- a/scripts/src/seed.ts +++ b/scripts/src/seed.ts @@ -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!");