import { Router } from "express"; import { db } from "@workspace/db"; import { usersTable } from "@workspace/db"; import { eq } from "drizzle-orm"; import { CreateUserBody, UpdateUserBody, UpdateUserParams } from "@workspace/api-zod"; import { logAudit } from "../lib/audit-logger.js"; import { requireAdmin, requireManagerOrAdmin, requireAuth, AuthRequest } from "../middleware/auth.js"; import { canAccessUser, User, isAdmin, isHeadOfTeam, isTeamLead, canAccessAllTeams, ROLES } from "../lib/auth-utils.js"; import { z } from "zod"; import bcrypt from "bcryptjs"; const router = Router(); router.get("/users", requireManagerOrAdmin, async (req: AuthRequest, res) => { try { const requestingUser = req.user as User; let { team } = req.query as { team?: string }; let rows; if (canAccessAllTeams(requestingUser)) { // Admins and Head of Team see all users or filtered by team rows = team ? await db.select().from(usersTable).where(eq(usersTable.team, team)).orderBy(usersTable.name) : await db.select().from(usersTable).orderBy(usersTable.name); } else if (isTeamLead(requestingUser)) { // Team Leads only see their own team rows = await db.select().from(usersTable).where(eq(usersTable.team, requestingUser.team)).orderBy(usersTable.name); } else { // Members can only see themselves rows = await db.select().from(usersTable).where(eq(usersTable.id, requestingUser.id)); } res.json(rows.map(u => ({ id: u.id, name: u.name, email: u.email, team: u.team, role: u.role, avatarColor: u.avatarColor, createdAt: u.createdAt.toISOString(), }))); } catch (err) { req.log.error({ err }, "Failed to list users"); res.status(500).json({ error: "Internal server error" }); } }); import { supabase } from "./auth.js"; const createUserSchema = z.object({ email: z.string().email("صيغة البريد الإلكتروني غير صحيحة"), name: z.string().trim().min(1, "الاسم مطلوب"), role: z.enum(["admin", "head_of_team", "team_lead", "member"], { errorMap: () => ({ message: "دور غير صالح" }) }), password: z.string() .min(8, "كلمة المرور يجب أن تكون 8 أحرف على الأقل") .regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, "كلمة المرور يجب أن تحتوي على حرف كبير، حرف صغير، ورقم"), avatarColor: z.string().optional(), team: z.string().optional() }).strict(); router.post("/users", requireManagerOrAdmin, async (req: AuthRequest, res) => { try { const requestingUser = req.user as User; const parseResult = createUserSchema.safeParse(req.body); if (!parseResult.success) { res.status(400).json({ error: parseResult.error.errors[0].message }); return; } const { email, name, role, password, avatarColor, team } = parseResult.data; if (!isAdmin(requestingUser)) { if (role === "admin") { res.status(403).json({ error: "لا يمكنك إضافة مدير نظام" }); return; } if (isTeamLead(requestingUser) && team !== requestingUser.team) { res.status(403).json({ error: "يمكنك إضافة أعضاء لفريقك فقط" }); return; } } // Check if user exists const existing = await db.select().from(usersTable).where(eq(usersTable.email, email.toLowerCase().trim())); if (existing.length > 0) { res.status(409).json({ error: "البريد الإلكتروني مستخدم مسبقاً" }); return; } // Create in Supabase Auth first const { data: authData, error: authErr } = await supabase.auth.admin.createUser({ email: email.toLowerCase().trim(), password, email_confirm: true, user_metadata: { name }, }); let supabaseUid = authData?.user?.id; if (authErr || !authData?.user) { if (process.env.SUPABASE_SERVICE_ROLE_KEY === "your_supabase_service_role_key_here" || process.env.NODE_ENV !== "production") { req.log.warn("Supabase Service Role Key is placeholder or in dev mode. Generating local UUID for Supabase Auth simulation."); supabaseUid = crypto.randomUUID(); } else { res.status(400).json({ error: authErr?.message ?? "Failed to create user in Supabase Auth" }); return; } } const passwordHash = await bcrypt.hash(password, 10); const [user] = await db.insert(usersTable).values({ userId: supabaseUid, name, email: email.toLowerCase().trim(), role, passwordHash, team: team ?? "Unassigned", avatarColor: avatarColor ?? "#6366f1", }).returning(); logAudit({ userId: req.userUid ?? supabaseUid, action: "role_change", entityType: "user", entityId: user.id, details: { newUserId: user.id, name: user.name, email: user.email, role: user.role, team: user.team, avatarColor: user.avatarColor, supabaseUid, note: "Initial role assignment on user creation", }, req, }); res.status(201).json({ id: user.id, name: user.name, email: user.email, team: user.team, role: user.role, avatarColor: user.avatarColor, createdAt: user.createdAt.toISOString() }); } catch (err) { req.log.error({ err: err instanceof Error ? err.message : String(err) }, "Failed to create user"); res.status(500).json({ error: "Internal server error" }); } }); const getUserParamsSchema = z.object({ id: z.coerce.number().int().positive("معرف المستخدم غير صالح") }); router.get("/users/:id", requireAuth, async (req: AuthRequest, res) => { try { const requestingUser = req.user as User; const parseResult = getUserParamsSchema.safeParse(req.params); if (!parseResult.success) { res.status(400).json({ error: parseResult.error.errors[0].message }); return; } const { id } = parseResult.data; const [targetUser] = await db.select().from(usersTable).where(eq(usersTable.id, id)); if (!targetUser) { res.status(404).json({ error: "User not found" }); return; } let allowed = false; if (isAdmin(requestingUser)) { allowed = true; } else if ((isHeadOfTeam(requestingUser) || isTeamLead(requestingUser)) && requestingUser.team === targetUser.team) { allowed = true; } else if (requestingUser.id === targetUser.id) { allowed = true; } if (!allowed) { res.status(403).json({ error: "Forbidden: you do not have permission to view this profile" }); return; } res.json({ id: targetUser.id, name: targetUser.name, email: targetUser.email, team: targetUser.team, role: targetUser.role, avatarColor: targetUser.avatarColor, createdAt: targetUser.createdAt.toISOString(), }); } catch (err) { req.log.error({ err: err instanceof Error ? err.message : String(err) }, "Failed to get user"); res.status(500).json({ error: "Internal server error" }); } }); const updateUserSchema = z.object({ email: z.string().email("صيغة البريد الإلكتروني غير صحيحة").optional(), name: z.string().trim().min(1, "الاسم مطلوب").optional(), role: z.enum(["admin", "head_of_team", "team_lead", "member"], { errorMap: () => ({ message: "دور غير صالح" }) }).optional(), password: z.string() .min(8, "كلمة المرور يجب أن تكون 8 أحرف على الأقل") .regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, "كلمة المرور يجب أن تحتوي على حرف كبير، حرف صغير، ورقم") .optional(), avatarColor: z.string().optional(), team: z.string().optional() }).strict(); router.patch("/users/:id", requireAuth, async (req: AuthRequest, res) => { try { const requestingUser = req.user as User; const id = Number(req.params.id); if (isNaN(id)) { res.status(400).json({ error: "Invalid ID" }); return; } const parseResult = updateUserSchema.safeParse(req.body); if (!parseResult.success) { res.status(400).json({ error: parseResult.error.errors[0].message }); return; } const { email, name, role, password, avatarColor, team } = parseResult.data; const [existingUser] = await db.select().from(usersTable).where(eq(usersTable.id, id)); if (!existingUser) { res.status(404).json({ error: "User not found" }); return; } // RBAC let allowed = false; if (isAdmin(requestingUser)) { allowed = true; } else if ((isHeadOfTeam(requestingUser) || isTeamLead(requestingUser)) && existingUser.team === requestingUser.team) { allowed = true; } if (!allowed) { res.status(403).json({ error: "Forbidden: insufficient permissions to update this user" }); return; } // Admin role assignment restriction if (role === "admin" && !isAdmin(requestingUser)) { res.status(403).json({ error: "Forbidden: only admins can assign admin role" }); return; } // Team update restriction if (team !== undefined && team !== existingUser.team && !isAdmin(requestingUser)) { res.status(403).json({ error: "Forbidden: only admins can change user team" }); return; } let passwordHash = undefined; if (password) { passwordHash = await bcrypt.hash(password, 10); } const updatePayload: any = {}; if (email) updatePayload.email = email.toLowerCase().trim(); if (name) updatePayload.name = name; if (role) updatePayload.role = role; if (avatarColor) updatePayload.avatarColor = avatarColor; if (team) updatePayload.team = team; if (passwordHash) updatePayload.passwordHash = passwordHash; if (Object.keys(updatePayload).length === 0) { res.json({ id: existingUser.id, name: existingUser.name, email: existingUser.email, team: existingUser.team, role: existingUser.role, avatarColor: existingUser.avatarColor, createdAt: existingUser.createdAt.toISOString(), }); return; } const [updatedUser] = await db.update(usersTable).set(updatePayload).where(eq(usersTable.id, id)).returning(); const permChanged = (role && role !== existingUser.role) || (team && team !== existingUser.team); if (permChanged) { const sensitiveChanges: any = {}; if (role && role !== existingUser.role) { sensitiveChanges.role = { old: existingUser.role, new: updatedUser.role }; } if (team && team !== existingUser.team) { sensitiveChanges.team = { old: existingUser.team, new: updatedUser.team }; } logAudit({ userId: requestingUser.id, action: "role_change", entityType: "user", entityId: updatedUser.id, details: { email: updatedUser.email, changes: sensitiveChanges, }, req, }); } res.json({ id: updatedUser.id, name: updatedUser.name, email: updatedUser.email, team: updatedUser.team, role: updatedUser.role, avatarColor: updatedUser.avatarColor, createdAt: updatedUser.createdAt.toISOString(), }); } catch (err) { req.log.error({ err: err instanceof Error ? err.message : String(err) }, "Failed to update user"); res.status(500).json({ error: "Internal server error" }); } }); export default router;