import { Router } from "express"; import bcrypt from "bcryptjs"; import rateLimit from "express-rate-limit"; import { db } from "@workspace/db"; import { usersTable, refreshTokensTable } from "@workspace/db"; import { eq, and, gt } from "drizzle-orm"; import { generateAccessToken, generateRefreshToken, requireAuth, AuthRequest, } from "../middlewares/auth.js"; const router = Router(); const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 10, message: { error: "too_many_requests", message: "محاولات كثيرة جداً. حاول مجدداً بعد 15 دقيقة." }, standardHeaders: true, legacyHeaders: false, skip: () => process.env.NODE_ENV === "test", }); const registerLimiter = rateLimit({ windowMs: 60 * 60 * 1000, max: 5, message: { error: "too_many_requests", message: "محاولات كثيرة جداً. حاول مجدداً لاحقاً." }, standardHeaders: true, legacyHeaders: false, }); function validatePassword(password: string): string | null { if (!password || password.length < 8) return "كلمة المرور يجب أن تكون 8 أحرف على الأقل"; if (password.length > 128) return "كلمة المرور طويلة جداً"; return null; } // POST /api/auth/register — submit join request router.post("/register", registerLimiter, async (req, res) => { try { const { email, password, displayName } = req.body; if (!email || !password) { res.status(400).json({ error: "validation", message: "البريد الإلكتروني وكلمة المرور مطلوبان" }); return; } const pwError = validatePassword(password); if (pwError) { res.status(400).json({ error: "validation", message: pwError }); return; } const existing = await db.query.usersTable.findFirst({ where: eq(usersTable.email, email.toLowerCase()) }); if (existing) { res.status(409).json({ error: "conflict", message: "البريد الإلكتروني مستخدم بالفعل" }); return; } const passwordHash = await bcrypt.hash(password, 12); await db.insert(usersTable).values({ email: email.toLowerCase(), passwordHash, displayName: displayName || null, role: "user", status: "pending", }); res.status(201).json({ message: "تم إرسال طلب الانضمام. في انتظار موافقة المسؤول." }); } catch (err) { req.log?.error({ err }, "register error"); res.status(500).json({ error: "server_error", message: "فشل تسجيل الطلب" }); } }); // POST /api/auth/login router.post("/login", loginLimiter, async (req, res) => { try { const { email, password } = req.body; if (!email || !password) { res.status(400).json({ error: "validation", message: "البريد الإلكتروني وكلمة المرور مطلوبان" }); return; } const user = await db.query.usersTable.findFirst({ where: eq(usersTable.email, email.toLowerCase()) }); if (!user || !user.passwordHash) { res.status(401).json({ error: "unauthorized", message: "بيانات الدخول غير صحيحة" }); return; } const valid = await bcrypt.compare(password, user.passwordHash); if (!valid) { res.status(401).json({ error: "unauthorized", message: "بيانات الدخول غير صحيحة" }); return; } if (user.status === "pending") { res.status(401).json({ error: "pending", message: "الحساب في انتظار موافقة المسؤول" }); return; } if (user.status === "suspended") { res.status(401).json({ error: "suspended", message: "تم إيقاف هذا الحساب" }); return; } await db.update(usersTable).set({ lastLoginAt: new Date() }).where(eq(usersTable.id, user.id)); const accessToken = generateAccessToken(user.id, user.role, user.status); const refreshToken = generateRefreshToken(user.id); const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); await db.insert(refreshTokensTable).values({ userId: user.id, token: refreshToken, expiresAt }); res.cookie("refresh_token", refreshToken, { httpOnly: true, secure: process.env.NODE_ENV === "production", maxAge: 30 * 24 * 60 * 60 * 1000, sameSite: "lax", }); res.json({ accessToken, user: { id: user.id, email: user.email, displayName: user.displayName, role: user.role, status: user.status, createdAt: user.createdAt }, }); } catch (err) { req.log?.error({ err }, "login error"); res.status(500).json({ error: "server_error", message: "فشل تسجيل الدخول" }); } }); // POST /api/auth/logout router.post("/logout", async (req, res) => { const refreshToken = req.cookies?.refresh_token; if (refreshToken) { await db.delete(refreshTokensTable).where(eq(refreshTokensTable.token, refreshToken)); } res.clearCookie("refresh_token"); res.json({ message: "تم تسجيل الخروج" }); }); // POST /api/auth/refresh router.post("/refresh", async (req, res) => { try { const refreshToken = req.cookies?.refresh_token; if (!refreshToken) { res.status(401).json({ error: "unauthorized", message: "لا يوجد رمز تحديث" }); return; } const storedToken = await db.query.refreshTokensTable.findFirst({ where: and(eq(refreshTokensTable.token, refreshToken), gt(refreshTokensTable.expiresAt, new Date())), }); if (!storedToken) { res.status(401).json({ error: "unauthorized", message: "رمز التحديث غير صالح" }); return; } const user = await db.query.usersTable.findFirst({ where: eq(usersTable.id, storedToken.userId) }); if (!user || user.status !== "active") { res.status(401).json({ error: "unauthorized", message: "الحساب غير نشط" }); return; } const accessToken = generateAccessToken(user.id, user.role, user.status); res.json({ accessToken, user: { id: user.id, email: user.email, displayName: user.displayName, role: user.role, status: user.status, createdAt: user.createdAt }, }); } catch (err) { req.log?.error({ err }, "refresh error"); res.status(500).json({ error: "server_error", message: "فشل تجديد الجلسة" }); } }); // GET /api/auth/me router.get("/me", requireAuth, async (req: AuthRequest, res) => { try { const user = await db.query.usersTable.findFirst({ where: eq(usersTable.id, req.userId!) }); if (!user) { res.status(404).json({ error: "not_found", message: "المستخدم غير موجود" }); return; } res.json({ id: user.id, email: user.email, displayName: user.displayName, role: user.role, status: user.status, createdAt: user.createdAt }); } catch (err) { req.log?.error({ err }, "me error"); res.status(500).json({ error: "server_error", message: "فشل جلب بيانات المستخدم" }); } }); // POST /api/auth/forgot-password router.post("/forgot-password", async (req, res) => { try { const { email } = req.body; if (!email) { res.status(400).json({ error: "validation", message: "البريد الإلكتروني مطلوب" }); return; } const user = await db.query.usersTable.findFirst({ where: eq(usersTable.email, email.toLowerCase()) }); if (user) { const token = crypto.randomUUID(); const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); await db.update(usersTable).set({ resetToken: token, resetTokenExpiresAt: expiresAt }).where(eq(usersTable.id, user.id)); // TODO: Send email. Token available in server log for dev. req.log?.info({ token, email }, "Password reset token generated (dev mode — no email sent)"); } res.json({ message: "إذا كان البريد الإلكتروني مسجلاً، سيتم إرسال رابط إعادة التعيين." }); } catch (err) { req.log?.error({ err }, "forgot-password error"); res.status(500).json({ error: "server_error", message: "فشلت العملية" }); } }); // POST /api/auth/reset-password router.post("/reset-password", async (req, res) => { try { const { token, password } = req.body; if (!token || !password) { res.status(400).json({ error: "validation", message: "الرمز وكلمة المرور مطلوبان" }); return; } const pwError = validatePassword(password); if (pwError) { res.status(400).json({ error: "validation", message: pwError }); return; } const user = await db.query.usersTable.findFirst({ where: and(eq(usersTable.resetToken, token), gt(usersTable.resetTokenExpiresAt!, new Date())), }); if (!user) { res.status(400).json({ error: "invalid_token", message: "الرمز غير صالح أو منتهي الصلاحية" }); return; } const passwordHash = await bcrypt.hash(password, 12); await db.update(usersTable).set({ passwordHash, resetToken: null, resetTokenExpiresAt: null }).where(eq(usersTable.id, user.id)); await db.delete(refreshTokensTable).where(eq(refreshTokensTable.userId, user.id)); res.json({ message: "تم إعادة تعيين كلمة المرور بنجاح" }); } catch (err) { req.log?.error({ err }, "reset-password error"); res.status(500).json({ error: "server_error", message: "فشلت العملية" }); } }); // PATCH /api/auth/profile — update own display name router.patch("/profile", requireAuth, async (req: AuthRequest, res) => { try { const { displayName } = req.body; const [updated] = await db.update(usersTable) .set({ displayName: displayName || null, updatedAt: new Date() }) .where(eq(usersTable.id, req.userId!)) .returning(); res.json({ id: updated.id, email: updated.email, displayName: updated.displayName, role: updated.role, status: updated.status }); } catch (err) { req.log?.error({ err }, "profile update error"); res.status(500).json({ error: "server_error", message: "فشل تحديث الملف الشخصي" }); } }); // POST /api/auth/change-password — change own password router.post("/change-password", requireAuth, async (req: AuthRequest, res) => { try { const { currentPassword, newPassword } = req.body; if (!currentPassword || !newPassword) { res.status(400).json({ error: "validation", message: "كلمة المرور الحالية والجديدة مطلوبتان" }); return; } const pwError = validatePassword(newPassword); if (pwError) { res.status(400).json({ error: "validation", message: pwError }); return; } const user = await db.query.usersTable.findFirst({ where: eq(usersTable.id, req.userId!) }); if (!user || !user.passwordHash) { res.status(404).json({ error: "not_found" }); return; } const valid = await bcrypt.compare(currentPassword, user.passwordHash); if (!valid) { res.status(401).json({ error: "unauthorized", message: "كلمة المرور الحالية غير صحيحة" }); return; } const passwordHash = await bcrypt.hash(newPassword, 12); await db.update(usersTable).set({ passwordHash, updatedAt: new Date() }).where(eq(usersTable.id, user.id)); res.json({ message: "تم تغيير كلمة المرور بنجاح" }); } catch (err) { req.log?.error({ err }, "change-password error"); res.status(500).json({ error: "server_error", message: "فشل تغيير كلمة المرور" }); } }); export default router;