Spaces:
Sleeping
Sleeping
| 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; | |