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