feat: แกไขสวน backend ใหเขากบ frontend
This commit is contained in:
		| @@ -1,27 +1,30 @@ | ||||
| // src/routes/admin.js | ||||
| import { Router } from 'express'; | ||||
| import sequelize from '../db/index.js'; | ||||
| import { requireAuth } from '../middleware/auth.js'; | ||||
| import { requirePermission } from '../middleware/perm.js'; | ||||
|  | ||||
| const router = Router(); | ||||
| // src/routes/admin.js | ||||
| import { Router } from 'express'; | ||||
| import os from 'node:os'; | ||||
| import sql from '../db/index.js'; | ||||
| import { requirePerm } from '../middleware/requirePerm.js'; | ||||
| import PERM from '../config/permissions.js'; | ||||
| import { Router } from "express"; | ||||
| import os from "node:os"; | ||||
| import { dbReady, sequelize, Role, Permission } from "../db/sequelize.js"; | ||||
| import { requirePerm } from "../middleware/requirePerm.js"; | ||||
| import PERM from "../config/permissions.js"; | ||||
|  | ||||
| const r = Router(); | ||||
|  | ||||
| // GET /api/admin/sysinfo  → ต้องมี admin.read | ||||
| r.get('/sysinfo', | ||||
|   requirePerm(PERM.admin.read, { scope: 'global' }), | ||||
|   async (req, res) => { | ||||
| // แนะนำ: ensure DB connection once (กันเผลอเรียกก่อน DB พร้อม) | ||||
| await dbReady().catch((e) => { | ||||
|   console.error("[admin] DB not ready:", e?.message); | ||||
| }); | ||||
|  | ||||
| /** | ||||
|  * GET /api/admin/sysinfo | ||||
|  * perm: admin.read (global) | ||||
|  */ | ||||
| r.get( | ||||
|   "/sysinfo", | ||||
|   requirePerm(PERM.admin.read, { scope: "global" }), | ||||
|   async (_req, res) => { | ||||
|     try { | ||||
|       const [[{ now }]] = await sql.query('SELECT NOW() AS now'); | ||||
|       // ping DB เบา ๆ | ||||
|       await sequelize.query("SELECT 1"); | ||||
|       res.json({ | ||||
|         now, | ||||
|         now: new Date().toISOString(), | ||||
|         node: process.version, | ||||
|         platform: os.platform(), | ||||
|         arch: os.arch(), | ||||
| @@ -29,80 +32,97 @@ r.get('/sysinfo', | ||||
|         uptime_sec: os.uptime(), | ||||
|         loadavg: os.loadavg(), | ||||
|         memory: { total: os.totalmem(), free: os.freemem() }, | ||||
|         env: { NODE_ENV: process.env.NODE_ENV, APP_VERSION: process.env.APP_VERSION }, | ||||
|         env: { | ||||
|           NODE_ENV: process.env.NODE_ENV, | ||||
|           APP_VERSION: process.env.APP_VERSION, | ||||
|         }, | ||||
|       }); | ||||
|     } catch (e) { | ||||
|       res.status(500).json({ error: 'SYSINFO_FAIL', message: e?.message }); | ||||
|       res.status(500).json({ error: "SYSINFO_FAIL", message: e?.message }); | ||||
|     } | ||||
|   } | ||||
| ); | ||||
|  | ||||
| // POST /api/admin/maintenance/reindex  → ต้องมี admin.maintain | ||||
| r.post('/maintenance/reindex', | ||||
|   requirePerm(PERM.admin.maintain, { scope: 'global' }), | ||||
|   async (_req, res) => { | ||||
|     // ตัวอย่าง: ANALYZE/OPTIMIZE ตารางสำคัญ (ปรับตามจริง) | ||||
|     try { | ||||
|       await sql.query('ANALYZE TABLE correspondences, rfas, drawings'); | ||||
|       res.json({ ok: 1 }); | ||||
|     } catch (e) { | ||||
|       res.status(500).json({ error: 'MAINT_FAIL', message: e?.message }); | ||||
|     } | ||||
|   } | ||||
| ); | ||||
|  | ||||
| export default r; | ||||
|  | ||||
| /** | ||||
|  * GET /api/admin/perm-matrix | ||||
|  * query: | ||||
|  *   format=md|json  (default: md) | ||||
|  * | ||||
|  * ต้องมีสิทธิ์ ADMIN หรืออย่างน้อย CDWG_ADMIN/ALL (เปลี่ยนเป็นอะไรก็ได้ตามนโยบายคุณ) | ||||
|  * POST /api/admin/maintenance/reindex | ||||
|  * perm: admin.maintain (global) | ||||
|  * หมายเหตุ: ปรับรายชื่อตารางตามโปรเจ็คจริงของคุณ | ||||
|  */ | ||||
| router.get('/perm-matrix', | ||||
|   requireAuth, | ||||
|   // ใช้ ANY จากชุดสิทธิ์ด้านล่าง (คุณปรับให้เป็น ['ALL'] อย่างเดียวก็ได้) | ||||
|   requirePermission(['ALL', 'CDWG_ADMIN'], { mode: 'any' }), | ||||
| r.post( | ||||
|   "/maintenance/reindex", | ||||
|   requirePerm(PERM.admin.maintain, { scope: "global" }), | ||||
|   async (_req, res) => { | ||||
|     try { | ||||
|       // ตัวอย่าง ใช้ RAW ก็ได้เมื่อเหมาะสม | ||||
|       await sequelize.query("ANALYZE TABLE correspondences, rfas, drawings"); | ||||
|       res.json({ ok: 1 }); | ||||
|     } catch (e) { | ||||
|       res.status(500).json({ error: "MAINT_FAIL", message: e?.message }); | ||||
|     } | ||||
|   } | ||||
| ); | ||||
|  | ||||
| /** | ||||
|  * GET /api/admin/perm-matrix?format=md|json | ||||
|  * perm: admin.read (global) | ||||
|  * ดึง Role -> Permissions ด้วย association ของ Sequelize | ||||
|  */ | ||||
| r.get( | ||||
|   "/perm-matrix", | ||||
|   requirePerm(PERM.admin.read, { scope: "global" }), | ||||
|   async (req, res, next) => { | ||||
|     try { | ||||
|       const format = (req.query.format || 'md').toLowerCase(); | ||||
|       const format = String(req.query.format || "md").toLowerCase(); | ||||
|  | ||||
|       // ดึง Role → Permissions (global) | ||||
|       const [rows] = await sequelize.query(` | ||||
|         SELECT | ||||
|           r.role_id, | ||||
|           r.role_code, | ||||
|           r.role_name, | ||||
|           GROUP_CONCAT(DISTINCT p.perm_code ORDER BY p.perm_code SEPARATOR ', ') AS perm_codes | ||||
|         FROM roles r | ||||
|         LEFT JOIN role_permissions rp ON rp.role_id = r.role_id | ||||
|         LEFT JOIN permissions p       ON p.perm_id = rp.perm_id | ||||
|         GROUP BY r.role_id, r.role_code, r.role_name | ||||
|         ORDER BY r.role_code | ||||
|       `); | ||||
|       const roles = await Role.findAll({ | ||||
|         attributes: ["role_id", "role_code", "role_name"], | ||||
|         include: [ | ||||
|           { | ||||
|             model: Permission, | ||||
|             attributes: ["perm_code"], | ||||
|             through: { attributes: [] }, // ไม่ต้องข้อมูลตาราง join | ||||
|             required: false, | ||||
|           }, | ||||
|         ], | ||||
|         order: [["role_code", "ASC"]], | ||||
|         logging: false, | ||||
|       }); | ||||
|  | ||||
|       if (format === 'json') { | ||||
|         return res.json({ roles: rows }); | ||||
|       if (format === "json") { | ||||
|         const data = roles.map((r) => ({ | ||||
|           role_id: r.role_id, | ||||
|           role_code: r.role_code, | ||||
|           role_name: r.role_name, | ||||
|           perm_codes: (r.Permissions || []).map((p) => p.perm_code).sort(), | ||||
|         })); | ||||
|         return res.json({ roles: data }); | ||||
|       } | ||||
|  | ||||
|       // สร้าง Markdown | ||||
|       // สร้าง Markdown table | ||||
|       const lines = []; | ||||
|       lines.push(`# Permission Matrix (Role → Permissions)`); | ||||
|       lines.push(`_Generated at: ${new Date().toISOString()}_\n`); | ||||
|       lines.push(`| # | Role Code | Role Name | Permissions |`); | ||||
|       lines.push(`|---:|:---------|:----------|:------------|`); | ||||
|       rows.forEach((r, idx) => { | ||||
|         lines.push(`| ${idx + 1} | \`${r.role_code}\` | ${r.role_name || ''} | ${r.perm_codes || ''} |`); | ||||
|  | ||||
|       roles.forEach((r, idx) => { | ||||
|         const perms = (r.Permissions || []) | ||||
|           .map((p) => p.perm_code) | ||||
|           .sort() | ||||
|           .join(", "); | ||||
|         lines.push( | ||||
|           `| ${idx + 1} | \`${r.role_code}\` | ${ | ||||
|             r.role_name || "" | ||||
|           } | ${perms} |` | ||||
|         ); | ||||
|       }); | ||||
|  | ||||
|       const md = lines.join('\n'); | ||||
|       res.setHeader('Content-Type', 'text/markdown; charset=utf-8'); | ||||
|       return res.send(md); | ||||
|       res.setHeader("Content-Type", "text/markdown; charset=utf-8"); | ||||
|       return res.send(lines.join("\n")); | ||||
|     } catch (e) { | ||||
|       next(e); | ||||
|     } | ||||
|   } | ||||
| ); | ||||
|  | ||||
| export default router; | ||||
| export default r; | ||||
|   | ||||
| @@ -1,114 +1,127 @@ | ||||
| // src/routes/auth.js (ESM) | ||||
| import { Router } from 'express'; | ||||
| import jwt from 'jsonwebtoken'; | ||||
| import bcrypt from 'bcryptjs'; | ||||
| import sql from '../db/index.js'; | ||||
| // src/routes/auth.js | ||||
| import { Router } from "express"; | ||||
| import jwt from "jsonwebtoken"; | ||||
| import sql from "../db/index.js"; | ||||
| import crypto from "node:crypto"; // ถ้าต้องการ timingSafeEqual | ||||
| import bcrypt from "bcryptjs"; // ถ้า password_hash เป็น bcrypt (แนะนำ) | ||||
|  | ||||
| const r = Router(); | ||||
|  | ||||
| const JWT_SECRET = process.env.JWT_SECRET || 'dev-access-secret'; | ||||
| const REFRESH_SECRET = process.env.REFRESH_SECRET || 'dev-refresh-secret'; | ||||
| const ACCESS_TTL = process.env.ACCESS_TTL || '30m';   // 30 นาที | ||||
| const REFRESH_TTL = process.env.REFRESH_TTL || '30d'; // 30 วัน | ||||
| // ตั้งค่า JWT (อย่าใช้ .env ในโปรดักชันของคุณ → ใส่ผ่าน docker-compose) | ||||
| const JWT_ACCESS_SECRET = process.env.JWT_ACCESS_SECRET || "dev-access-secret"; | ||||
| const JWT_REFRESH_SECRET = | ||||
|   process.env.JWT_REFRESH_SECRET || "dev-refresh-secret"; | ||||
| const ACCESS_TTL_MS = 30 * 60 * 1000; // 30 นาที | ||||
| const REFRESH_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 วัน | ||||
|  | ||||
| function cookieOpts(maxAge) { | ||||
|   return { | ||||
|     httpOnly: true, | ||||
|     secure: true, // ใช้งานจริงหลัง HTTPS | ||||
|     sameSite: "lax", | ||||
|     path: "/", | ||||
|     maxAge, | ||||
|     // domain: ".np-dms.work", // ถ้าต้องการใช้ข้าม subdomain ให้เปิด | ||||
|   }; | ||||
| } | ||||
|  | ||||
| function signAccessToken(user) { | ||||
|   return jwt.sign( | ||||
|     { user_id: user.user_id, username: user.username }, | ||||
|     JWT_SECRET, | ||||
|     { expiresIn: ACCESS_TTL, issuer: 'dms-backend' } | ||||
|     JWT_ACCESS_SECRET, | ||||
|     { expiresIn: "30m", issuer: "dms-backend" } | ||||
|   ); | ||||
| } | ||||
|  | ||||
| function signRefreshToken(user) { | ||||
|   return jwt.sign( | ||||
|     { user_id: user.user_id, username: user.username, t: 'refresh' }, | ||||
|     REFRESH_SECRET, | ||||
|     { expiresIn: REFRESH_TTL, issuer: 'dms-backend' } | ||||
|   ); | ||||
|   return jwt.sign({ user_id: user.user_id }, JWT_REFRESH_SECRET, { | ||||
|     expiresIn: "30d", | ||||
|     issuer: "dms-backend", | ||||
|   }); | ||||
| } | ||||
|  | ||||
| async function findUserByUsername(username) { | ||||
|   const [[u]] = await sql.query( | ||||
|     'SELECT user_id, username, password_hash, email, first_name, last_name FROM users WHERE username=?', | ||||
|   const [rows] = await sql.query( | ||||
|     "SELECT user_id, username, email, password_hash FROM users WHERE username=? LIMIT 1", | ||||
|     [username] | ||||
|   ); | ||||
|   return u || null; | ||||
|   return rows?.[0] || null; | ||||
| } | ||||
|  | ||||
| // POST /api/auth/login | ||||
| r.post('/login', async (req, res) => { | ||||
| async function verifyPassword(plain, hash) { | ||||
|   // ถ้าใช้ bcrypt: | ||||
|   try { | ||||
|     return await bcrypt.compare(plain, hash); | ||||
|   } catch { | ||||
|     return false; | ||||
|   } | ||||
|   // ถ้าระบบคุณใช้ hash แบบอื่น ให้สลับมาใช้วิธีที่ตรงกับของจริง | ||||
| } | ||||
|  | ||||
| r.post("/login", async (req, res) => { | ||||
|   const { username, password } = req.body || {}; | ||||
|   if (!username || !password) { | ||||
|     return res.status(400).json({ error: 'username and password required' }); | ||||
|     return res.status(400).json({ error: "USERNAME_PASSWORD_REQUIRED" }); | ||||
|   } | ||||
|  | ||||
|   const user = await findUserByUsername(username); | ||||
|   if (!user) return res.status(401).json({ error: 'INVALID_CREDENTIALS' }); | ||||
|   if (!user) return res.status(401).json({ error: "INVALID_CREDENTIALS" }); | ||||
|  | ||||
|   const ok = await bcrypt.compare(password, user.password_hash || ''); | ||||
|   if (!ok) return res.status(401).json({ error: 'INVALID_CREDENTIALS' }); | ||||
|   const ok = await verifyPassword(password, user.password_hash); | ||||
|   if (!ok) return res.status(401).json({ error: "INVALID_CREDENTIALS" }); | ||||
|  | ||||
|   const access_token = signAccessToken(user); | ||||
|   const refresh_token = signRefreshToken(user); | ||||
|   res.json({ | ||||
|     token: access_token, | ||||
|     refresh_token, | ||||
|   const access = signAccessToken(user); | ||||
|   const refresh = signRefreshToken(user); | ||||
|  | ||||
|   res.cookie("access_token", access, cookieOpts(ACCESS_TTL_MS)); | ||||
|   res.cookie("refresh_token", refresh, cookieOpts(REFRESH_TTL_MS)); | ||||
|  | ||||
|   return res.json({ | ||||
|     ok: true, | ||||
|     user: { | ||||
|       user_id: user.user_id, | ||||
|       username: user.username, | ||||
|       email: user.email, | ||||
|       first_name: user.first_name, | ||||
|       last_name: user.last_name, | ||||
|     }, | ||||
|   }); | ||||
| }); | ||||
|  | ||||
| // POST /api/auth/refresh | ||||
| r.post('/refresh', async (req, res) => { | ||||
|   const { refresh_token } = req.body || {}; | ||||
|   if (!refresh_token) return res.status(400).json({ error: 'refresh_token required' }); | ||||
| r.post("/refresh", async (req, res) => { | ||||
|   const refresh = req.cookies?.refresh_token || req.body?.refresh_token; | ||||
|   if (!refresh) | ||||
|     return res.status(400).json({ error: "REFRESH_TOKEN_REQUIRED" }); | ||||
|  | ||||
|   try { | ||||
|     const payload = jwt.verify(refresh_token, REFRESH_SECRET, { issuer: 'dms-backend' }); | ||||
|     if (payload.t !== 'refresh') throw new Error('bad token'); | ||||
|     const payload = jwt.verify(refresh, JWT_REFRESH_SECRET, { | ||||
|       issuer: "dms-backend", | ||||
|     }); | ||||
|     // TODO: (ถ้ามี) ตรวจ blacklist/rotation store ของ refresh token | ||||
|  | ||||
|     // ยืนยันผู้ใช้ยังอยู่ในระบบ | ||||
|     const [[user]] = await sql.query( | ||||
|       'SELECT user_id, username FROM users WHERE user_id=?', | ||||
|     // คืน user จากฐานข้อมูลจริงตาม payload.user_id | ||||
|     const [rows] = await sql.query( | ||||
|       "SELECT user_id, username, email FROM users WHERE user_id=? LIMIT 1", | ||||
|       [payload.user_id] | ||||
|     ); | ||||
|     if (!user) return res.status(401).json({ error: 'USER_NOT_FOUND' }); | ||||
|     const user = rows?.[0]; | ||||
|     if (!user) return res.status(401).json({ error: "USER_NOT_FOUND" }); | ||||
|  | ||||
|     const token = signAccessToken(user); | ||||
|     const new_refresh = signRefreshToken(user); // rotation | ||||
|     res.json({ token, refresh_token: new_refresh }); | ||||
|     // rotation: ออก access+refresh ใหม่ | ||||
|     const access = signAccessToken(user); | ||||
|     const newRef = signRefreshToken(user); | ||||
|  | ||||
|     res.cookie("access_token", access, cookieOpts(ACCESS_TTL_MS)); | ||||
|     res.cookie("refresh_token", newRef, cookieOpts(REFRESH_TTL_MS)); | ||||
|     return res.json({ ok: true }); | ||||
|   } catch (e) { | ||||
|     return res.status(401).json({ error: 'INVALID_REFRESH', message: e?.message }); | ||||
|     return res.status(401).json({ error: "INVALID_REFRESH_TOKEN" }); | ||||
|   } | ||||
| }); | ||||
|  | ||||
| // POST /api/auth/logout (stateless) | ||||
| r.post('/logout', (req, res) => { | ||||
|   // หากต้องการ blacklist/whitelist refresh token ให้เพิ่มตารางและบันทึกที่นี่ | ||||
|   res.json({ ok: 1 }); | ||||
| }); | ||||
|  | ||||
| // POST /api/auth/change-password | ||||
| r.post('/change-password', async (req, res) => { | ||||
|   const { username, old_password, new_password } = req.body || {}; | ||||
|   if (!username || !old_password || !new_password) { | ||||
|     return res.status(400).json({ error: 'username, old_password, new_password required' }); | ||||
|   } | ||||
|   const user = await findUserByUsername(username); | ||||
|   if (!user) return res.status(404).json({ error: 'USER_NOT_FOUND' }); | ||||
|  | ||||
|   const ok = await bcrypt.compare(old_password, user.password_hash || ''); | ||||
|   if (!ok) return res.status(401).json({ error: 'INVALID_CREDENTIALS' }); | ||||
|  | ||||
|   const salt = await bcrypt.genSalt(10); | ||||
|   const hash = await bcrypt.hash(new_password, salt); | ||||
|   await sql.query('UPDATE users SET password_hash=? WHERE user_id=?', [hash, user.user_id]); | ||||
|   res.json({ ok: 1 }); | ||||
| r.post("/logout", (req, res) => { | ||||
|   res.clearCookie("access_token", { path: "/" }); | ||||
|   res.clearCookie("refresh_token", { path: "/" }); | ||||
|   return res.json({ ok: true }); | ||||
| }); | ||||
|  | ||||
| export default r; | ||||
| // หมายเหตุ: คุณอาจเพิ่ม /register, /forgot-password, /reset-password ตามต้องการ | ||||
| // แต่ในโปรเจกต์นี้จะให้แอดมินสร้างบัญชีผู้ใช้ผ่าน /api/users แทน | ||||
|   | ||||
| @@ -1,19 +1,43 @@ | ||||
| import { Router } from 'express'; | ||||
| import { requireAuth, enrichRoles } from '../middleware/auth.js'; | ||||
| // src/routes/auth_extras.js | ||||
| import jwt from "jsonwebtoken"; | ||||
|  | ||||
| const r = Router(); | ||||
| const JWT_ACCESS_SECRET = process.env.JWT_ACCESS_SECRET || "dev-access-secret"; | ||||
|  | ||||
| r.get('/auth/me', requireAuth, enrichRoles, async (req, res) => { | ||||
|   res.json({ | ||||
|     user_id: req.user?.user_id, | ||||
|     username: req.user?.username, | ||||
|     roles: req.user?.roles || [] | ||||
|   }); | ||||
| }); | ||||
| /** | ||||
|  * ตรวจสอบ access_token จาก httpOnly cookie | ||||
|  * ใช้เป็น middleware กับเส้นทางที่ต้องการป้องกันฝั่ง API (ซ้ำกับ authJwt เดิมได้ แต่ตัวนี้อ่านคุกกี้ตรง ๆ) | ||||
|  */ | ||||
| export function requireAuth(req, res, next) { | ||||
|   const token = req.cookies?.access_token; | ||||
|   if (!token) return res.status(401).json({ error: "Unauthenticated" }); | ||||
|  | ||||
| // Placeholder: client can simply drop tokens; provided for symmetry/logging hook | ||||
| r.post('/auth/logout', requireAuth, async (_req, res) => { | ||||
|   res.json({ ok: true }); | ||||
| }); | ||||
|   try { | ||||
|     const payload = jwt.verify(token, JWT_ACCESS_SECRET, { | ||||
|       issuer: "dms-backend", | ||||
|     }); | ||||
|     req.user = { | ||||
|       user_id: payload.user_id, | ||||
|       username: payload.username, | ||||
|     }; | ||||
|     return next(); | ||||
|   } catch (e) { | ||||
|     return res.status(401).json({ error: "INVALID_TOKEN" }); | ||||
|   } | ||||
| } | ||||
|  | ||||
| export default r; | ||||
| /** | ||||
|  * เฉพาะกรณีในอนาคต: ตรวจบทบาท/สิทธิ์ง่าย ๆ | ||||
|  * ใช้หลัง requireAuth เช่น app.get('/api/admin/xxx', requireAuth, requireRole('Admin'), handler) | ||||
|  */ | ||||
| export function requireRole(roleName) { | ||||
|   return function (req, res, next) { | ||||
|     // สมมติว่ามี req.principal.roles จาก middleware อื่น (เช่น loadPrincipalMw) | ||||
|     const roles = req.principal?.roles || req.user?.roles || []; | ||||
|     if (!Array.isArray(roles) || !roles.includes(roleName)) { | ||||
|       return res.status(403).json({ error: "FORBIDDEN" }); | ||||
|     } | ||||
|     next(); | ||||
|   }; | ||||
| } | ||||
| // หมายเหตุ: ในโปรเจกต์นี้ เราใช้ requirePerm จาก src/middleware/requirePerm.js แทน | ||||
| // เพราะมีความยืดหยุ่นกว่า (ตรวจสิทธิ์เป็นรายรายการ และมี scope ด้วย) | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 admin
					admin