Compare commits

...

11 Commits

Author SHA1 Message Date
223c9a6c6e WIP: save before rebase 2025-10-05 11:21:51 +07:00
0abf5618f7 fronted build js-cookiep lock 2025-10-05 09:42:10 +07:00
71fc7eee13 backend: Mod 2025-10-04 23:55:15 +07:00
admin
d2a7a3e478 feat(dashboard): backend Sequelize.js 2025-10-04 17:26:36 +07:00
admin
72c2573648 feat(dashboard): backend rbac_admin.js 2025-10-04 17:11:30 +07:00
admin
c98baa94fc feat(dashboard): backend user.js 2025-10-04 17:08:58 +07:00
admin
c414899a4f feat(dashboard): backend and frontend 2025-10-04 16:46:39 +07:00
admin
1ef1f8148f feat(dashboard): backend/src/index.js 2025-10-04 16:17:21 +07:00
admin
772239e708 feat(dashboard): เพมสวนจดการ user 2025-10-04 16:07:22 +07:00
admin
7f41c35cb8 stable: auth+dashboard OK 2025-10-04 14:56:44 +07:00
admin
d3844aec71 251004 backend restore /routes/auth.js 2025-10-04 11:24:01 +07:00
104 changed files with 10283 additions and 8509 deletions

11
.gitignore vendored
View File

@@ -1,17 +1,13 @@
# ยกเว้นโฟลเดอร์
.devcontainer/
.qsync/
@Recently-Snapshot/
Documents/
mariadb/data/
n8n*/
npm/
phpmyadmin/
pgadmin/
npm/
n8n/
n8n-cache/
n8n-migrate/git
n8n-postgres/
pgadmin/
.tmp*/
# =====================================================
# IDE/Editor settings
# =====================================================
@@ -94,4 +90,5 @@ docker-compose.override.*.yml
/backend/.cache/
/frontend/.cache/
.tmp/
.tmp*.*/
.cache/

View File

@@ -1,4 +1,4 @@
[/dms]
max_log = 496206
number = 3
max_log = 498246
number = 4
finish = 1

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -113,3 +113,24 @@
- ไม่ใช้ .env เด็ดขาด Container Station ไม่รองรับ และ docker-compose.yml ได้ทดสอบ รันบน Container station มาแล้ว
- Code ของ backend ทั้งหมด
- การทดสอบระบบ backend ทุกส่วน ให้พร้อม สำหรับ frontend
# กรณี 2: มี Git อยู่แล้ว (มี main อยู่)
2.1 อัปเดต main ให้ตรงล่าสุดก่อนแตกบร้านช์
cd /share/Container/dms
git checkout main
git pull --ff-only # ถ้าเชื่อม remote อยู่
git tag -f stable-$(date +%F) # tag จุดเสถียรปัจจุบัน
2.2 แตก branch งาน Dashboard
git checkout -b feature/dashboard-update-$(date +%y%m%d)
git checkout -b feature/dashboard-update-251004
2.3 ทำงาน/คอมมิตตามปกติ
# แก้ไฟล์ frontend/app/dashboard/\* และที่เกี่ยวข้อง
git add frontend/app/dashboard
git commit -m "feat(dashboard): เพิ่มส่วนจัดการ user"
git push -u origin feature/dashboard-update-251004

View File

@@ -0,0 +1,39 @@
// FILE: backend/src/db/index.js (ESM)
import mysql from "mysql2/promise";
const {
DB_HOST = "mariadb",
DB_PORT = "3306",
DB_USER = "center",
DB_PASSWORD = "Center#2025",
DB_NAME = "dms",
DB_CONN_LIMIT = "10",
} = process.env;
const pool = mysql.createPool({
host: DB_HOST,
port: Number(DB_PORT),
user: DB_USER,
password: DB_PASSWORD,
database: DB_NAME,
connectionLimit: Number(DB_CONN_LIMIT),
waitForConnections: true,
namedPlaceholders: true,
dateStrings: true, // คงวันที่เป็น string
timezone: "Z", // ใช้ UTC
});
/**
* เรียก Stored Procedure แบบง่าย
* @param {string} procName ชื่อโปรซีเยอร์ เช่น "sp_rfa_create_with_items"
* @param {Array<any>} params ลำดับพารามิเตอร์
* @returns {Promise<any>} rows จาก CALL
*/
export async function callProc(procName, params = []) {
const placeholders = params.map(() => "?").join(",");
const sql = `CALL ${procName}(${placeholders})`;
const [rows] = await pool.query(sql, params);
return rows;
}
export default pool; // ใช้ sql.query(...) ได้ตามที่ routes เรียกอยู่

View File

@@ -0,0 +1,71 @@
// FILE: backend/src/db/sequelize.js
// “lazy-load” ตาม env ปลอดภัยกว่า และยังคง dbReady() ให้เรียกทดสอบได้
// ใช้ได้เมื่อจำเป็น (เช่น งาน admin tool เฉพาะกิจ)
// ตั้ง ENABLE_SEQUELIZE=1 เพื่อเปิดใช้ Model loader; ไม่งั้นจะเป็นโหมดเบา ๆ
import { Sequelize } from "sequelize";
import { config } from "../config.js";
export const sequelize = new Sequelize(
config.DB.NAME,
config.DB.USER,
config.DB.PASS,
{
host: config.DB.HOST,
port: config.DB.PORT,
dialect: "mariadb",
logging: false,
dialectOptions: { timezone: "Z" },
define: { freezeTableName: true, underscored: false, timestamps: false },
pool: { max: 10, min: 0, idle: 10000 },
}
);
export let User = null;
export let Role = null;
export let Permission = null;
export let UserRole = null;
export let RolePermission = null;
if (process.env.ENABLE_SEQUELIZE === "1") {
// โหลดโมเดลแบบ on-demand เพื่อลดความเสี่ยง runtime หากไฟล์โมเดลไม่มี
const mdlUser = await import("./models/User.js").catch(() => null);
const mdlRole = await import("./models/Role.js").catch(() => null);
const mdlPerm = await import("./models/Permission.js").catch(() => null);
const mdlUR = await import("./models/UserRole.js").catch(() => null);
const mdlRP = await import("./models/RolePermission.js").catch(() => null);
if (mdlUser?.default) User = mdlUser.default(sequelize);
if (mdlRole?.default) Role = mdlRole.default(sequelize);
if (mdlPerm?.default) Permission = mdlPerm.default(sequelize);
if (mdlUR?.default) UserRole = mdlUR.default(sequelize);
if (mdlRP?.default) RolePermission = mdlRP.default(sequelize);
if (User && Role && Permission && UserRole && RolePermission) {
User.belongsToMany(Role, {
through: UserRole,
foreignKey: "user_id",
otherKey: "role_id",
});
Role.belongsToMany(User, {
through: UserRole,
foreignKey: "role_id",
otherKey: "user_id",
});
Role.belongsToMany(Permission, {
through: RolePermission,
foreignKey: "role_id",
otherKey: "permission_id",
});
Permission.belongsToMany(Role, {
through: RolePermission,
foreignKey: "permission_id",
otherKey: "role_id",
});
}
}
export async function dbReady() {
// โหมดเบา ๆ: แค่ทดสอบเชื่อมต่อ
await sequelize.authenticate();
}

128
backend/src/db/sequelize.js Normal file → Executable file
View File

@@ -5,11 +5,7 @@
import { Sequelize } from "sequelize";
import { config } from "../config.js";
export const sequelize = new Sequelize(
config.DB.NAME,
config.DB.USER,
config.DB.PASS,
{
export const sequelize = new Sequelize(config.DB.NAME, config.DB.USER, config.DB.PASS, {
host: config.DB.HOST,
port: config.DB.PORT,
dialect: "mariadb",
@@ -17,55 +13,91 @@ export const sequelize = new Sequelize(
dialectOptions: { timezone: "Z" },
define: { freezeTableName: true, underscored: false, timestamps: false },
pool: { max: 10, min: 0, idle: 10000 },
}
);
});
export let User = null;
export let Role = null;
export let Permission = null;
export let UserRole = null;
export let RolePermission = null;
// --- 1. ประกาศตัวแปรสำหรับ Export Model ทั้งหมด ---
export let User, Role, Permission, Organization, Project, UserRole, RolePermission,
UserProjectRole, Correspondence, CorrespondenceVersion, Document, CorrDocumentMap,
Drawing, DrawingRevision, FileObject, RFA, RFARevision, RfaDrawingMap,
Transmittal, TransmittalItem, Volume, ContractDwg, SubCategory;
if (process.env.ENABLE_SEQUELIZE === "1") {
// โหลดโมเดลแบบ on-demand เพื่อลดความเสี่ยง runtime หากไฟล์โมเดลไม่มี
const mdlUser = await import("./models/User.js").catch(() => null);
const mdlRole = await import("./models/Role.js").catch(() => null);
const mdlPerm = await import("./models/Permission.js").catch(() => null);
const mdlUR = await import("./models/UserRole.js").catch(() => null);
const mdlRP = await import("./models/RolePermission.js").catch(() => null);
// --- 2. สร้าง Object ของ Models ทั้งหมดที่จะโหลด ---
const modelsToLoad = {
User: await import("./models/User.js").catch(() => null),
Role: await import("./models/Role.js").catch(() => null),
Permission: await import("./models/Permission.js").catch(() => null),
Organization: await import("./models/Organization.js").catch(() => null),
Project: await import("./models/Project.js").catch(() => null),
UserRole: await import("./models/UserRole.js").catch(() => null),
RolePermission: await import("./models/RolePermission.js").catch(() => null),
UserProjectRole: await import("./models/UserProjectRole.js").catch(() => null),
Correspondence: await import("./models/Correspondence.js").catch(() => null),
CorrespondenceVersion: await import("./models/CorrespondenceVersion.js").catch(() => null),
Document: await import("./models/Document.js").catch(() => null),
CorrDocumentMap: await import("./models/CorrDocumentMap.js").catch(() => null),
Drawing: await import("./models/Drawing.js").catch(() => null),
DrawingRevision: await import("./models/DrawingRevision.js").catch(() => null),
FileObject: await import("./models/FileObject.js").catch(() => null),
RFA: await import("./models/RFA.js").catch(() => null),
RFARevision: await import("./models/RFARevision.js").catch(() => null),
RfaDrawingMap: await import("./models/RfaDrawingMap.js").catch(() => null),
Transmittal: await import("./models/Transmittal.js").catch(() => null),
TransmittalItem: await import("./models/TransmittalItem.js").catch(() => null),
Volume: await import("./models/Volume.js").catch(() => null),
ContractDwg: await import("./models/ContractDwg.js").catch(() => null),
SubCategory: await import("./models/SubCategory.js").catch(() => null),
};
if (mdlUser?.default) User = mdlUser.default(sequelize);
if (mdlRole?.default) Role = mdlRole.default(sequelize);
if (mdlPerm?.default) Permission = mdlPerm.default(sequelize);
if (mdlUR?.default) UserRole = mdlUR.default(sequelize);
if (mdlRP?.default) RolePermission = mdlRP.default(sequelize);
// --- 3. Initialize Model ทั้งหมด ---
User = modelsToLoad.User?.default ? modelsToLoad.User.default(sequelize) : null;
Role = modelsToLoad.Role?.default ? modelsToLoad.Role.default(sequelize) : null;
Permission = modelsToLoad.Permission?.default ? modelsToLoad.Permission.default(sequelize) : null;
Organization = modelsToLoad.Organization?.default ? modelsToLoad.Organization.default(sequelize) : null;
Project = modelsToLoad.Project?.default ? modelsToLoad.Project.default(sequelize) : null;
UserRole = modelsToLoad.UserRole?.default ? modelsToLoad.UserRole.default(sequelize) : null;
RolePermission = modelsToLoad.RolePermission?.default ? modelsToLoad.RolePermission.default(sequelize) : null;
UserProjectRole = modelsToLoad.UserProjectRole?.default ? modelsToLoad.UserProjectRole.default(sequelize) : null;
Correspondence = modelsToLoad.Correspondence?.default ? modelsToLoad.Correspondence.default(sequelize) : null;
CorrespondenceVersion = modelsToLoad.CorrespondenceVersion?.default ? modelsToLoad.CorrespondenceVersion.default(sequelize) : null;
Document = modelsToLoad.Document?.default ? modelsToLoad.Document.default(sequelize) : null;
CorrDocumentMap = modelsToLoad.CorrDocumentMap?.default ? modelsToLoad.CorrDocumentMap.default(sequelize) : null;
Drawing = modelsToLoad.Drawing?.default ? modelsToLoad.Drawing.default(sequelize) : null;
DrawingRevision = modelsToLoad.DrawingRevision?.default ? modelsToLoad.DrawingRevision.default(sequelize) : null;
FileObject = modelsToLoad.FileObject?.default ? modelsToLoad.FileObject.default(sequelize) : null;
RFA = modelsToLoad.RFA?.default ? modelsToLoad.RFA.default(sequelize) : null;
RFARevision = modelsToLoad.RFARevision?.default ? modelsToLoad.RFARevision.default(sequelize) : null;
RfaDrawingMap = modelsToLoad.RfaDrawingMap?.default ? modelsToLoad.RfaDrawingMap.default(sequelize) : null;
Transmittal = modelsToLoad.Transmittal?.default ? modelsToLoad.Transmittal.default(sequelize) : null;
TransmittalItem = modelsToLoad.TransmittalItem?.default ? modelsToLoad.TransmittalItem.default(sequelize) : null;
Volume = modelsToLoad.Volume?.default ? modelsToLoad.Volume.default(sequelize) : null;
ContractDwg = modelsToLoad.ContractDwg?.default ? modelsToLoad.ContractDwg.default(sequelize) : null;
SubCategory = modelsToLoad.SubCategory?.default ? modelsToLoad.SubCategory.default(sequelize) : null;
if (User && Role && Permission && UserRole && RolePermission) {
User.belongsToMany(Role, {
through: UserRole,
foreignKey: "user_id",
otherKey: "role_id",
});
Role.belongsToMany(User, {
through: UserRole,
foreignKey: "role_id",
otherKey: "user_id",
});
Role.belongsToMany(Permission, {
through: RolePermission,
foreignKey: "role_id",
otherKey: "permission_id",
});
Permission.belongsToMany(Role, {
through: RolePermission,
foreignKey: "permission_id",
otherKey: "role_id",
});
}
// --- 4. สร้างความสัมพันธ์ (Associations) ---
const loadedModels = { User, Role, Permission, Organization, Project, UserRole, RolePermission,
UserProjectRole, Correspondence, CorrespondenceVersion, Document, CorrDocumentMap,
Drawing, DrawingRevision, FileObject, RFA, RFARevision, RfaDrawingMap,
Transmittal, TransmittalItem, Volume, ContractDwg, SubCategory };
for (const modelName in loadedModels) {
if (loadedModels[modelName] && loadedModels[modelName].associate) {
loadedModels[modelName].associate(loadedModels);
}
}
}
export async function dbReady() {
// โหมดเบา ๆ: แค่ทดสอบเชื่อมต่อ
await sequelize.authenticate();
}
if (process.env.ENABLE_SEQUELIZE !== "1") {
console.log("Sequelize is disabled.");
return Promise.resolve();
}
try {
await sequelize.authenticate();
console.log("Sequelize connection has been established successfully.");
} catch (error) {
console.error("Unable to connect to the database via Sequelize:", error);
return Promise.reject(error);
}
}

View File

@@ -10,6 +10,9 @@ import { authJwt } from "./middleware/authJwt.js";
import { loadPrincipalMw } from "./middleware/loadPrincipal.js";
// ROUTES
import usersRoutes from "./routes/users.js";
import rbacAdminRoutes from "./routes/rbac_admin.js";
import dashboardRoutes from "./routes/dashboard.js";
import authRoutes from "./routes/auth.js";
import lookupRoutes from "./routes/lookup.js";
import organizationsRoutes from "./routes/organizations.js";
@@ -23,7 +26,6 @@ import contractDwgRoutes from "./routes/contract_dwg.js";
import categoriesRoutes from "./routes/categories.js";
import volumesRoutes from "./routes/volumes.js";
import uploadsRoutes from "./routes/uploads.js";
import usersRoutes from "./routes/users.js";
import permissionsRoutes from "./routes/permissions.js";
const PORT = Number(process.env.PORT || 3001);
@@ -148,6 +150,8 @@ app.use("/api/volumes", volumesRoutes);
app.use("/api/uploads", uploadsRoutes);
app.use("/api/users", usersRoutes);
app.use("/api/permissions", permissionsRoutes);
app.use("/api/rbac", rbacAdminRoutes);
app.use("/api/dashboard", dashboardRoutes);
// 404 / error
app.use((req, res) =>

View File

@@ -0,0 +1,61 @@
// FILE: backend/src/middleware/auth.js
import jwt from "jsonwebtoken";
import { config } from "../config.js";
import { User, Role, UserRole } from "../db/sequelize.js";
export function signAccessToken(payload) {
return jwt.sign(payload, config.JWT.SECRET, {
expiresIn: config.JWT.EXPIRES_IN,
});
}
export function signRefreshToken(payload) {
return jwt.sign(payload, config.JWT.REFRESH_SECRET, {
expiresIn: config.JWT.REFRESH_EXPIRES_IN,
});
}
export function extractToken(req) {
// ให้คุกกี้มาก่อน แล้วค่อย Bearer (รองรับทั้งสองทาง)
const cookieTok = req.cookies?.access_token || null;
if (cookieTok) return cookieTok;
const hdr = req.headers.authorization || "";
return hdr.startsWith("Bearer ") ? hdr.slice(7) : null;
}
export function requireAuth(req, res, next) {
if (req.path === "/health") return next(); // อนุญาต health เสมอ
const token = extractToken(req);
if (!token) return res.status(401).json({ error: "Missing token" });
try {
req.user = jwt.verify(token, config.JWT.SECRET);
next();
} catch {
return res.status(401).json({ error: "Invalid/Expired token" });
}
}
// ใช้กับเส้นทางที่ login แล้วจะ enrich ต่อได้ แต่ไม่บังคับ
export function optionalAuth(req, _res, next) {
const token = extractToken(req);
if (!token) return next();
try {
req.user = jwt.verify(token, config.JWT.SECRET);
} catch {}
next();
}
export async function enrichRoles(req, _res, next) {
if (!req.user?.user_id) return next();
const rows = await UserRole.findAll({
where: { user_id: req.user.user_id },
include: [{ model: Role }],
}).catch(() => []);
req.user.roles = rows.map((r) => r.role?.role_name).filter(Boolean);
next();
}
export function hasPerm(req, perm) {
const set = new Set(req?.user?.permissions || []);
return set.has(perm);
}

0
backend/src/middleware/auth.js Normal file → Executable file
View File

39
backend/src/middleware/index.js Executable file
View File

@@ -0,0 +1,39 @@
// File: backend/src/middleware/index.js
import * as authJwt from "./authJwt.js";
import * as abac from "./abac.js";
import * as auth from "./auth.js";
import * as errorHandler from "./errorHandler.js";
import * as loadPrincipal from "./loadPrincipal.js";
import * as permGuard from "./permGuard.js";
import * as permissions from "./permissions.js";
import * as rbac from "./rbac.js";
import * as requirePerm from "./requirePerm.js";
// Export ทุกอย่างออกมาเป็น named exports
// เพื่อให้สามารถ import แบบ `import { authJwt, permGuard } from '../middleware';` ได้
export {
authJwt,
abac,
auth,
errorHandler,
loadPrincipal,
permGuard,
permissions,
rbac,
requirePerm,
};
// (Optional) สร้าง default export สำหรับกรณีที่ต้องการ import ทั้งหมดใน object เดียว
const middleware = {
authJwt,
abac,
auth,
errorHandler,
loadPrincipal,
permGuard,
permissions,
rbac,
requirePerm,
};
export default middleware;

0
backend/src/middleware/loadPrincipal.js Normal file → Executable file
View File

View File

@@ -1,279 +0,0 @@
// FILE: backend/src/routes/auth.js (ESM) — Bearer only, refresh via header/body, forgot/reset password
import { Router } from "express";
import jwt from "jsonwebtoken";
import sql from "../db/index.js";
import { cookieOpts } from "../utils/cookie.js";
import bcrypt from "bcryptjs";
import { requireAuth } from "../middleware/auth.js";
import crypto from "node:crypto";
const r = Router();
/* =========================
* CONFIG & HELPERS
* ========================= */
// ใช้ค่าเดียวกับ middleware authJwt()
const JWT_SECRET = process.env.JWT_SECRET || "dev-secret";
const REFRESH_SECRET = process.env.REFRESH_SECRET || "dev-refresh-secret";
const ACCESS_TTL = process.env.ACCESS_TTL || "30m";
const REFRESH_TTL = process.env.REFRESH_TTL || "30d";
// อายุของ reset token (นาที)
const RESET_TTL_MIN = Number(process.env.RESET_TTL_MIN || 30);
function signAccessToken(user) {
return jwt.sign(
{ user_id: user.user_id, username: user.username },
JWT_SECRET,
{ expiresIn: ACCESS_TTL, 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" }
);
}
function getBearer(req) {
const h = req.headers.authorization || "";
if (!h.startsWith("Bearer ")) return null;
const token = h.slice(7).trim();
return token || null;
}
async function findUserByUsername(username) {
const [rows] = await sql.query(
`SELECT user_id, username, email, first_name, last_name, password_hash
FROM users WHERE username=? LIMIT 1`,
[username]
);
return rows?.[0] || null;
}
async function findUserByEmail(email) {
const [rows] = await sql.query(
`SELECT user_id, username, email, first_name, last_name, password_hash
FROM users WHERE email=? LIMIT 1`,
[email]
);
return rows?.[0] || null;
}
/* =========================
* POST /api/auth/login
* - รับ username/password
* - ตรวจ bcrypt แล้วออก token+refresh_token (JSON)
* ========================= */
r.post("/login", async (req, res) => {
const { username, password } = req.body || {};
if (!username || !password) {
return res.status(400).json({ error: "USERNAME_PASSWORD_REQUIRED" });
}
const user = await findUserByUsername(username);
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 token = signAccessToken(user);
const refresh_token = signRefreshToken(user);
// set httpOnly cookies (ยังคงส่ง token ใน body กลับเช่นเดิม)
res.cookie(
"access_token",
token,
cookieOpts(parseInt(process.env.ACCESS_TTL_MS || "900000", 10))
);
res.cookie(
"refresh_token",
refresh_token,
cookieOpts(parseInt(process.env.REFRESH_TTL_MS || "604800000", 10))
);
return res.json({
token,
refresh_token,
user: {
user_id: user.user_id,
username: user.username,
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
},
});
});
/* =========================
* GET /api/auth/me (cookie or bearer)
* ========================= */
r.get("/me", requireAuth, async (req, res) => {
return res.json({
ok: true,
user: { user_id: req.user.user_id, username: req.user.username },
});
});
+(
/* =========================
* POST /api/auth/refresh
* - รองรับ refresh token จาก:
* 1) Authorization: Bearer <refresh_token>
* 2) req.body.refresh_token
* - ออก token ใหม่ + refresh ใหม่ (rotation)
* ========================= */
r.post("/refresh", async (req, res) => {
const fromHeader = getBearer(req);
const fromBody = (req.body || {}).refresh_token;
const refreshToken = fromHeader || fromBody;
if (!refreshToken) {
return res.status(400).json({ error: "REFRESH_TOKEN_REQUIRED" });
}
try {
const payload = jwt.verify(refreshToken, REFRESH_SECRET, {
issuer: "dms-backend",
});
if (payload.t !== "refresh") throw new Error("bad token type");
const [[user]] = await sql.query(
`SELECT user_id, username, email, first_name, last_name
FROM users WHERE user_id=? LIMIT 1`,
[payload.user_id]
);
if (!user) return res.status(401).json({ error: "USER_NOT_FOUND" });
// rotation
const token = signAccessToken(user);
const new_refresh = signRefreshToken(user);
// rotate cookies
res.cookie(
"access_token",
token,
cookieOpts(parseInt(process.env.ACCESS_TTL_MS || "900000", 10))
);
res.cookie(
"refresh_token",
new_refresh,
cookieOpts(parseInt(process.env.REFRESH_TTL_MS || "604800000", 10))
);
return res.json({
token,
refresh_token: new_refresh,
user: {
user_id: user.user_id,
username: user.username,
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
},
});
} catch {
return res.status(401).json({ error: "INVALID_REFRESH_TOKEN" });
}
})
);
/* =========================
* POST /api/auth/forgot-password
* - รับ username หรือ email อย่างใดอย่างหนึ่ง
* - สร้าง reset token แบบสุ่ม, เก็บ hash ใน DB พร้อมหมดอายุ
* - ส่งเสมอ {ok:true} เพื่อลด user enumeration
* - การ “ส่งอีเมล/ลิงก์รีเซ็ต” ให้ทำนอกระบบนี้ (เช่น n8n)
* ========================= */
r.post("/forgot-password", async (req, res) => {
const { username, email } = req.body || {};
// หา user จาก username หรือ email (ถ้ามีทั้งสอง จะให้ username มาก่อน)
let user = null;
if (username) user = await findUserByUsername(username);
if (!user && email) user = await findUserByEmail(email);
// สร้างโทเคน “เหมือนจริง” เสมอ (แต่ถ้าไม่เจอ user ก็ไม่บอก)
if (user) {
const raw = crypto.randomBytes(32).toString("hex"); // โทเคนดิบ (ส่งทางอีเมล)
const hash = crypto.createHash("sha256").update(raw).digest("hex"); // เก็บใน DB
const expires = new Date(Date.now() + RESET_TTL_MIN * 60 * 1000);
// ทำ invalid เก่า ๆ ของ user นี้ (optional)
await sql.query(
`UPDATE password_resets SET used_at=NOW()
WHERE user_id=? AND used_at IS NULL AND expires_at < NOW()`,
[user.user_id]
);
// บันทึก token ใหม่
await sql.query(
`INSERT INTO password_resets (user_id, token_hash, expires_at)
VALUES (?,?,?)`,
[user.user_id, hash, expires]
);
// TODO: ส่ง “raw token” ไปช่องทางปลอดภัย (เช่น n8n ส่งอีเมล)
// ตัวอย่างลิงก์ที่ frontend จะใช้:
// https://<frontend-domain>/reset-password?token=<raw>
// คุณสามารถต่อ webhook ไป n8n ได้ที่นี่ถ้าต้องการ
}
// ไม่บอกว่าเจอหรือไม่เจอ user
return res.json({ ok: true });
});
/* =========================
* POST /api/auth/reset-password
* - รับ token (จากลิงก์ในอีเมล) + new_password
* - ตรวจ token_hash กับ DB, หมดอายุหรือถูกใช้ไปแล้วหรือยัง
* - เปลี่ยนรหัสผ่าน/ปิดใช้ token
* ========================= */
r.post("/reset-password", async (req, res) => {
const { token, new_password } = req.body || {};
if (!token || !new_password) {
return res.status(400).json({ error: "TOKEN_AND_NEW_PASSWORD_REQUIRED" });
}
const token_hash = crypto.createHash("sha256").update(token).digest("hex");
const [[row]] = await sql.query(
`SELECT id, user_id, expires_at, used_at
FROM password_resets
WHERE token_hash=? LIMIT 1`,
[token_hash]
);
if (!row) return res.status(400).json({ error: "INVALID_TOKEN" });
if (row.used_at) return res.status(400).json({ error: "TOKEN_ALREADY_USED" });
if (new Date(row.expires_at).getTime() < Date.now()) {
return res.status(400).json({ error: "TOKEN_EXPIRED" });
}
// เปลี่ยนรหัสผ่าน
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,
row.user_id,
]);
// ปิดใช้ token นี้
await sql.query(`UPDATE password_resets SET used_at=NOW() WHERE id=?`, [
row.id,
]);
return res.json({ ok: true });
});
/* =========================
* POST /api/auth/logout — stateless
* - frontend ลบ token เอง
* ========================= */
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 แทน

View File

@@ -0,0 +1,137 @@
// backend/src/routes/auth.js
import { Router } from "express";
import bcrypt from "bcryptjs";
import jwt from "jsonwebtoken";
import { config } from "../config.js";
import { User } from "../db/sequelize.js";
import {
signAccessToken,
signRefreshToken,
requireAuth,
} from "../middleware/auth.js";
const r = Router();
// cookie options — ทำในไฟล์นี้เลย (ไม่เพิ่ม utils ใหม่)
function cookieOpts(maxAgeMs) {
const isProd = process.env.NODE_ENV === "production";
const opts = {
httpOnly: true,
secure: true, // หลัง Nginx/HTTPS
sameSite: "none", // ส่งข้าม subdomain ได้
path: "/",
maxAge: maxAgeMs,
};
if (config.COOKIE_DOMAIN) opts.domain = config.COOKIE_DOMAIN; // เช่น .np-dms.work
if (!isProd && process.env.ALLOW_INSECURE_COOKIE === "1") {
opts.secure = false;
opts.sameSite = "lax";
}
return opts;
}
// helper TTL จาก config เดิม
const ACCESS_TTL_MS = (() => {
// รับทั้งรูปแบบ "15m" (เช่น EXPIRES_IN) หรือ milliseconds
// ถ้าเป็นเลขอยู่แล้ว (ms) ก็ใช้เลย
if (/^\d+$/.test(String(config.JWT.EXPIRES_IN)))
return Number(config.JWT.EXPIRES_IN);
// แปลงรูปแบบเช่น "15m" เป็น ms แบบง่าย ๆ
const s = String(config.JWT.EXPIRES_IN || "15m");
const n = parseInt(s, 10);
if (s.endsWith("h")) return n * 60 * 60 * 1000;
if (s.endsWith("m")) return n * 60 * 1000;
if (s.endsWith("s")) return n * 1000;
return 15 * 60 * 1000;
})();
const REFRESH_TTL_MS = (() => {
if (/^\d+$/.test(String(config.JWT.REFRESH_EXPIRES_IN)))
return Number(config.JWT.REFRESH_EXPIRES_IN);
const s = String(config.JWT.REFRESH_EXPIRES_IN || "7d");
const n = parseInt(s, 10);
if (s.endsWith("d")) return n * 24 * 60 * 60 * 1000;
if (s.endsWith("h")) return n * 60 * 60 * 1000;
if (s.endsWith("m")) return n * 60 * 1000;
if (s.endsWith("s")) return n * 1000;
return 7 * 24 * 60 * 60 * 1000;
})();
// == POST /api/auth/login ==
r.post("/login", async (req, res) => {
const { username, password } = req.body || {};
if (!username || !password)
return res.status(400).json({ error: "USERNAME_PASSWORD_REQUIRED" });
const user = await User.findOne({ where: { username }, raw: true });
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" });
// NOTE: สิทธิ์จริง ๆ ให้ดึงจากตาราง role/permission ของคุณ
const permissions = []; // ใส่เปล่าไว้ก่อน (คุณมี enrichRoles ที่อื่นอยู่แล้ว)
const payload = {
user_id: user.user_id,
username: user.username,
permissions,
};
const access = signAccessToken(payload);
const refresh = signRefreshToken({ user_id: user.user_id });
// ตั้งคุกกี้ (และยังส่ง token ใน body ได้ถ้าคุณใช้อยู่)
res.cookie("access_token", access, cookieOpts(ACCESS_TTL_MS));
res.cookie("refresh_token", refresh, cookieOpts(REFRESH_TTL_MS));
return res.json({
ok: true,
token: access,
refresh_token: refresh,
user: { user_id: user.user_id, username: user.username, email: user.email },
});
});
// == GET /api/auth/me ==
r.get("/me", requireAuth, async (req, res) => {
// enrich เพิ่มจากฐานได้ตามต้องการ; ตอนนี้เอาเบื้องต้นจาก token
return res.json({
ok: true,
user: {
user_id: req.user.user_id,
username: req.user.username,
permissions: req.user.permissions || [],
},
});
});
// == POST /api/auth/refresh ==
r.post("/refresh", async (req, res) => {
// รับจากคุกกี้ก่อน แล้วค่อย Authorization
const bearer = req.headers.authorization?.startsWith("Bearer ")
? req.headers.authorization.slice(7)
: null;
const rt = req.cookies?.refresh_token || bearer;
if (!rt) return res.status(401).json({ error: "Unauthenticated" });
try {
// verify refresh โดยตรงด้วย config.JWT.REFRESH_SECRET (คงสไตล์เดิม)
const p = jwt.verify(rt, config.JWT.REFRESH_SECRET, { clockTolerance: 10 });
// โหลดสิทธิ์ล่าสุดจากฐานได้ ถ้าต้องการ; ตอนนี้ใส่ [] ไว้ก่อน
const permissions = [];
const access = signAccessToken({ user_id: p.user_id, permissions });
res.cookie("access_token", access, cookieOpts(ACCESS_TTL_MS));
return res.json({ ok: true, token: access });
} catch {
return res.status(401).json({ error: "Unauthenticated" });
}
});
// == POST /api/auth/logout ==
r.post("/logout", (_req, res) => {
res.clearCookie("access_token", { path: "/" });
res.clearCookie("refresh_token", { path: "/" });
return res.json({ ok: true });
});
export default r;

View File

@@ -1,133 +1,272 @@
// backend/src/routes/auth.js
// FILE: backend/src/routes/auth.js (ESM) — Bearer only, refresh via header/body, forgot/reset password
import { Router } from "express";
import bcrypt from "bcryptjs";
import jwt from "jsonwebtoken";
import { config } from "../config.js";
import { User } from "../db/sequelize.js";
import {
signAccessToken,
signRefreshToken,
requireAuth,
} from "../middleware/auth.js";
import sql from "../db/index.js";
import { cookieOpts } from "../utils/cookie.js";
import bcrypt from "bcryptjs";
import { requireAuth } from "../middleware/auth.js";
import crypto from "node:crypto";
const r = Router();
// cookie options — ทำในไฟล์นี้เลย (ไม่เพิ่ม utils ใหม่)
function cookieOpts(maxAgeMs) {
const isProd = process.env.NODE_ENV === "production";
const opts = {
httpOnly: true,
secure: true, // หลัง Nginx/HTTPS
sameSite: "none", // ส่งข้าม subdomain ได้
path: "/",
maxAge: maxAgeMs,
};
if (config.COOKIE_DOMAIN) opts.domain = config.COOKIE_DOMAIN; // เช่น .np-dms.work
if (!isProd && process.env.ALLOW_INSECURE_COOKIE === "1") {
opts.secure = false;
opts.sameSite = "lax";
}
return opts;
/* =========================
* CONFIG & HELPERS
* ========================= */
// ใช้ค่าเดียวกับ middleware authJwt()
const JWT_SECRET = process.env.JWT_SECRET || "dev-secret";
const REFRESH_SECRET = process.env.REFRESH_SECRET || "dev-refresh-secret";
const ACCESS_TTL = process.env.ACCESS_TTL || "30m";
const REFRESH_TTL = process.env.REFRESH_TTL || "30d";
// อายุของ reset token (นาที)
const RESET_TTL_MIN = Number(process.env.RESET_TTL_MIN || 30);
function signAccessToken(user) {
return jwt.sign(
{ user_id: user.user_id, username: user.username },
JWT_SECRET,
{ expiresIn: ACCESS_TTL, issuer: "dms-backend" }
);
}
// helper TTL จาก config เดิม
const ACCESS_TTL_MS = (() => {
// รับทั้งรูปแบบ "15m" (เช่น EXPIRES_IN) หรือ milliseconds
// ถ้าเป็นเลขอยู่แล้ว (ms) ก็ใช้เลย
if (/^\d+$/.test(String(config.JWT.EXPIRES_IN)))
return Number(config.JWT.EXPIRES_IN);
// แปลงรูปแบบเช่น "15m" เป็น ms แบบง่าย ๆ
const s = String(config.JWT.EXPIRES_IN || "15m");
const n = parseInt(s, 10);
if (s.endsWith("h")) return n * 60 * 60 * 1000;
if (s.endsWith("m")) return n * 60 * 1000;
if (s.endsWith("s")) return n * 1000;
return 15 * 60 * 1000;
})();
const REFRESH_TTL_MS = (() => {
if (/^\d+$/.test(String(config.JWT.REFRESH_EXPIRES_IN)))
return Number(config.JWT.REFRESH_EXPIRES_IN);
const s = String(config.JWT.REFRESH_EXPIRES_IN || "7d");
const n = parseInt(s, 10);
if (s.endsWith("d")) return n * 24 * 60 * 60 * 1000;
if (s.endsWith("h")) return n * 60 * 60 * 1000;
if (s.endsWith("m")) return n * 60 * 1000;
if (s.endsWith("s")) return n * 1000;
return 7 * 24 * 60 * 60 * 1000;
})();
function signRefreshToken(user) {
return jwt.sign(
{ user_id: user.user_id, username: user.username, t: "refresh" },
REFRESH_SECRET,
{ expiresIn: REFRESH_TTL, issuer: "dms-backend" }
);
}
// == POST /api/auth/login ==
function getBearer(req) {
const h = req.headers.authorization || "";
if (!h.startsWith("Bearer ")) return null;
const token = h.slice(7).trim();
return token || null;
}
async function findUserByUsername(username) {
const [rows] = await sql.query(
`SELECT user_id, username, email, first_name, last_name, password_hash
FROM users WHERE username=? LIMIT 1`,
[username]
);
return rows?.[0] || null;
}
async function findUserByEmail(email) {
const [rows] = await sql.query(
`SELECT user_id, username, email, first_name, last_name, password_hash
FROM users WHERE email=? LIMIT 1`,
[email]
);
return rows?.[0] || null;
}
/* =========================
* POST /api/auth/login
* - รับ username/password
* - ตรวจ bcrypt แล้วออก token+refresh_token (JSON)
* ========================= */
r.post("/login", async (req, res) => {
const { username, password } = req.body || {};
if (!username || !password)
if (!username || !password) {
return res.status(400).json({ error: "USERNAME_PASSWORD_REQUIRED" });
}
const user = await User.findOne({ where: { username }, raw: true });
const user = await findUserByUsername(username);
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" });
// NOTE: สิทธิ์จริง ๆ ให้ดึงจากตาราง role/permission ของคุณ
const permissions = []; // ใส่เปล่าไว้ก่อน (คุณมี enrichRoles ที่อื่นอยู่แล้ว)
const payload = {
user_id: user.user_id,
username: user.username,
permissions,
};
const token = signAccessToken(user);
const refresh_token = signRefreshToken(user);
const access = signAccessToken(payload);
const refresh = signRefreshToken({ user_id: user.user_id });
// ตั้งคุกกี้ (และยังส่ง token ใน body ได้ถ้าคุณใช้อยู่)
res.cookie("access_token", access, cookieOpts(ACCESS_TTL_MS));
res.cookie("refresh_token", refresh, cookieOpts(REFRESH_TTL_MS));
// set httpOnly cookies (ยังคงส่ง token ใน body กลับเช่นเดิม)
res.cookie(
"access_token",
token,
cookieOpts(parseInt(process.env.ACCESS_TTL_MS || "900000", 10))
);
res.cookie(
"refresh_token",
refresh_token,
cookieOpts(parseInt(process.env.REFRESH_TTL_MS || "604800000", 10))
);
return res.json({
ok: true,
token: access,
refresh_token: refresh,
user: { user_id: user.user_id, username: user.username, email: user.email },
});
});
// == GET /api/auth/me ==
r.get("/me", requireAuth, async (req, res) => {
// enrich เพิ่มจากฐานได้ตามต้องการ; ตอนนี้เอาเบื้องต้นจาก token
return res.json({
ok: true,
token,
refresh_token,
user: {
user_id: req.user.user_id,
username: req.user.username,
permissions: req.user.permissions || [],
user_id: user.user_id,
username: user.username,
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
},
});
});
/* =========================
* GET /api/auth/me (cookie or bearer)
* ========================= */
r.get("/me", requireAuth, async (req, res) => {
return res.json({
ok: true,
user: { user_id: req.user.user_id, username: req.user.username },
});
});
+(
/* =========================
* POST /api/auth/refresh
* - รองรับ refresh token จาก:
* 1) Authorization: Bearer <refresh_token>
* 2) req.body.refresh_token
* - ออก token ใหม่ + refresh ใหม่ (rotation)
* ========================= */
r.post("/refresh", async (req, res) => {
const fromHeader = getBearer(req);
const fromBody = (req.body || {}).refresh_token;
const refreshToken = fromHeader || fromBody;
if (!refreshToken) {
return res.status(400).json({ error: "REFRESH_TOKEN_REQUIRED" });
}
// == POST /api/auth/refresh ==
r.post("/refresh", async (req, res) => {
// รับจากคุกกี้ก่อน แล้วค่อย Authorization
const bearer = req.headers.authorization?.startsWith("Bearer ")
? req.headers.authorization.slice(7)
: null;
const rt = req.cookies?.refresh_token || bearer;
if (!rt) return res.status(401).json({ error: "Unauthenticated" });
try {
const payload = jwt.verify(refreshToken, REFRESH_SECRET, {
issuer: "dms-backend",
});
if (payload.t !== "refresh") throw new Error("bad token type");
try {
// verify refresh โดยตรงด้วย config.JWT.REFRESH_SECRET (คงสไตล์เดิม)
const p = jwt.verify(rt, config.JWT.REFRESH_SECRET, { clockTolerance: 10 });
// โหลดสิทธิ์ล่าสุดจากฐานได้ ถ้าต้องการ; ตอนนี้ใส่ [] ไว้ก่อน
const permissions = [];
const access = signAccessToken({ user_id: p.user_id, permissions });
res.cookie("access_token", access, cookieOpts(ACCESS_TTL_MS));
return res.json({ ok: true, token: access });
} catch {
return res.status(401).json({ error: "Unauthenticated" });
const [[user]] = await sql.query(
`SELECT user_id, username, email, first_name, last_name
FROM users WHERE user_id=? LIMIT 1`,
[payload.user_id]
);
if (!user) return res.status(401).json({ error: "USER_NOT_FOUND" });
// rotation
const token = signAccessToken(user);
const new_refresh = signRefreshToken(user);
// rotate cookies
res.cookie(
"access_token",
token,
cookieOpts(parseInt(process.env.ACCESS_TTL_MS || "900000", 10))
);
res.cookie(
"refresh_token",
new_refresh,
cookieOpts(parseInt(process.env.REFRESH_TTL_MS || "604800000", 10))
);
return res.json({
token,
refresh_token: new_refresh,
user: {
user_id: user.user_id,
username: user.username,
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
},
});
} catch {
return res.status(401).json({ error: "INVALID_REFRESH_TOKEN" });
}
})
);
/* =========================
* POST /api/auth/forgot-password
* - รับ username หรือ email อย่างใดอย่างหนึ่ง
* - สร้าง reset token แบบสุ่ม, เก็บ hash ใน DB พร้อมหมดอายุ
* - ส่งเสมอ {ok:true} เพื่อลด user enumeration
* - การ “ส่งอีเมล/ลิงก์รีเซ็ต” ให้ทำนอกระบบนี้ (เช่น n8n)
* ========================= */
r.post("/forgot-password", async (req, res) => {
const { username, email } = req.body || {};
// หา user จาก username หรือ email (ถ้ามีทั้งสอง จะให้ username มาก่อน)
let user = null;
if (username) user = await findUserByUsername(username);
if (!user && email) user = await findUserByEmail(email);
// สร้างโทเคน “เหมือนจริง” เสมอ (แต่ถ้าไม่เจอ user ก็ไม่บอก)
if (user) {
const raw = crypto.randomBytes(32).toString("hex"); // โทเคนดิบ (ส่งทางอีเมล)
const hash = crypto.createHash("sha256").update(raw).digest("hex"); // เก็บใน DB
const expires = new Date(Date.now() + RESET_TTL_MIN * 60 * 1000);
// ทำ invalid เก่า ๆ ของ user นี้ (optional)
await sql.query(
`UPDATE password_resets SET used_at=NOW()
WHERE user_id=? AND used_at IS NULL AND expires_at < NOW()`,
[user.user_id]
);
// บันทึก token ใหม่
await sql.query(
`INSERT INTO password_resets (user_id, token_hash, expires_at)
VALUES (?,?,?)`,
[user.user_id, hash, expires]
);
// TODO: ส่ง “raw token” ไปช่องทางปลอดภัย (เช่น n8n ส่งอีเมล)
// ตัวอย่างลิงก์ที่ frontend จะใช้:
// https://<frontend-domain>/reset-password?token=<raw>
// คุณสามารถต่อ webhook ไป n8n ได้ที่นี่ถ้าต้องการ
}
// ไม่บอกว่าเจอหรือไม่เจอ user
return res.json({ ok: true });
});
// == POST /api/auth/logout ==
/* =========================
* POST /api/auth/reset-password
* - รับ token (จากลิงก์ในอีเมล) + new_password
* - ตรวจ token_hash กับ DB, หมดอายุหรือถูกใช้ไปแล้วหรือยัง
* - เปลี่ยนรหัสผ่าน/ปิดใช้ token
* ========================= */
r.post("/reset-password", async (req, res) => {
const { token, new_password } = req.body || {};
if (!token || !new_password) {
return res.status(400).json({ error: "TOKEN_AND_NEW_PASSWORD_REQUIRED" });
}
const token_hash = crypto.createHash("sha256").update(token).digest("hex");
const [[row]] = await sql.query(
`SELECT id, user_id, expires_at, used_at
FROM password_resets
WHERE token_hash=? LIMIT 1`,
[token_hash]
);
if (!row) return res.status(400).json({ error: "INVALID_TOKEN" });
if (row.used_at) return res.status(400).json({ error: "TOKEN_ALREADY_USED" });
if (new Date(row.expires_at).getTime() < Date.now()) {
return res.status(400).json({ error: "TOKEN_EXPIRED" });
}
// เปลี่ยนรหัสผ่าน
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,
row.user_id,
]);
// ปิดใช้ token นี้
await sql.query(`UPDATE password_resets SET used_at=NOW() WHERE id=?`, [
row.id,
]);
return res.json({ ok: true });
});
/* =========================
* POST /api/auth/logout — stateless
* - frontend ลบ token เอง
* ========================= */
r.post("/logout", (_req, res) => {
res.clearCookie("access_token", { path: "/" });
res.clearCookie("refresh_token", { path: "/" });
@@ -135,3 +274,6 @@ r.post("/logout", (_req, res) => {
});
export default r;
// หมายเหตุ: คุณอาจเพิ่ม /register, /forgot-password, /reset-password ตามต้องการ
// แต่ในโปรเจกต์นี้จะให้แอดมินสร้างบัญชีผู้ใช้ผ่าน /api/users แทน

View File

@@ -0,0 +1,56 @@
// backend/src/routes/dashboard.js
import { Router } from "express";
import { Op } from "sequelize";
import { Correspondence, Document, RFA, User } from "../db/index.js"; // import models
import { authJwt } from "../middleware/index.js";
const router = Router();
// Middleware: ตรวจสอบสิทธิ์สำหรับทุก route ในไฟล์นี้
router.use(authJwt.verifyToken);
// === API สำหรับ User Management Widget ===
router.get("/users/summary", async (req, res, next) => {
try {
const totalUsers = await User.count();
const activeUsers = await User.count({ where: { is_active: true } });
// ดึง user ที่สร้างล่าสุด 5 คน
const recentUsers = await User.findAll({
limit: 5,
order: [["createdAt", "DESC"]],
attributes: ["id", "username", "email", "createdAt"],
});
res.json({
total: totalUsers,
active: activeUsers,
inactive: totalUsers - activeUsers,
recent: recentUsers,
});
} catch (error) {
next(error);
}
});
// === API อื่นๆ สำหรับ Dashboard ที่เราคุยกันไว้ก่อนหน้า ===
router.get("/stats", async (req, res, next) => {
try {
const sevenDaysAgo = new Date(new Date().setDate(new Date().getDate() - 7));
const totalDocuments = await Document.count();
const newThisWeek = await Document.count({
where: { createdAt: { [Op.gte]: sevenDaysAgo } },
});
const pendingRfas = await RFA.count({ where: { status: "pending" } }); // สมมติตาม status
res.json({
totalDocuments,
newThisWeek,
pendingRfas,
});
} catch (error) {
next(error);
}
});
export default router;

63
backend/src/routes/dashboard.js Executable file
View File

@@ -0,0 +1,63 @@
// backend/src/routes/dashboard.js
import { Router } from 'express';
import { Op } from 'sequelize';
// 1. Import Middleware ที่ถูกต้อง
import { authJwt } from '../middleware/authJwt.js';
import { loadPrincipalMw } from '../middleware/loadPrincipal.js';
// 2. Import Sequelize Models จาก `sequelize.js` ไม่ใช่ `index.js`
import { Correspondence, Document, RFA, User } from '../db/sequelize.js';
const router = Router();
// 3. ใช้ Middleware Chain ที่ถูกต้อง 100%
router.use(authJwt(), loadPrincipalMw());
// === API สำหรับ User Management Widget ===
router.get('/users/summary', async (req, res, next) => {
try {
// ตรวจสอบว่า Model ถูกโหลดแล้วหรือยัง (จำเป็นสำหรับโหมด lazy-load)
if (!User) {
return res.status(503).json({ message: 'Database models not available. Is ENABLE_SEQUELIZE=1 set?' });
}
const totalUsers = await User.count();
const activeUsers = await User.count({ where: { is_active: true } });
res.json({
total: totalUsers,
active: activeUsers,
inactive: totalUsers - activeUsers,
});
} catch (error) {
next(error);
}
});
// === API อื่นๆ สำหรับ Dashboard ที่เราคุยกันไว้ก่อนหน้า ===
router.get('/stats', async (req, res, next) => {
try {
if (!Document || !RFA) {
return res.status(503).json({ message: 'Database models not available. Is ENABLE_SEQUELIZE=1 set?' });
}
const sevenDaysAgo = new Date(new Date().setDate(new Date().getDate() - 7));
const totalDocuments = await Document.count();
const newThisWeek = await Document.count({ where: { createdAt: { [Op.gte]: sevenDaysAgo } } });
const pendingRfas = await RFA.count({ where: { status: 'pending' } }); // สมมติตาม status
res.json({
totalDocuments,
newThisWeek,
pendingRfas
});
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,126 @@
// FILE: backend/src/routes/rbac_admin.js
// RBAC admin — ใช้ settings.manage ทั้งหมด
import { Router } from "express";
import sql from "../db/index.js";
import { requirePerm } from "../middleware/requirePerm.js";
const r = Router();
// ROLES
r.get("/roles", requirePerm("settings.manage"), async (_req, res) => {
const [rows] = await sql.query(
"SELECT role_id, role_code, role_name, description FROM roles ORDER BY role_code"
);
res.json(rows);
});
// PERMISSIONS
r.get("/permissions", requirePerm("settings.manage"), async (_req, res) => {
const [rows] = await sql.query(
"SELECT permission_id, perm_code AS permission_code, scope_level, description FROM permissions ORDER BY perm_code"
);
res.json(rows);
});
// role -> permissions
r.get(
"/roles/:role_id/permissions",
requirePerm("settings.manage"),
async (req, res) => {
const role_id = Number(req.params.role_id);
const [rows] = await sql.query(
`SELECT p.permission_id, p.perm_code AS permission_code, p.description
FROM role_permissions rp
JOIN permissions p ON p.permission_id = rp.permission_id
WHERE rp.role_id=? ORDER BY p.perm_code`,
[role_id]
);
res.json(rows);
}
);
r.post(
"/roles/:role_id/permissions",
requirePerm("settings.manage"),
async (req, res) => {
const role_id = Number(req.params.role_id);
const { permission_id } = req.body || {};
await sql.query(
"INSERT IGNORE INTO role_permissions (role_id, permission_id) VALUES (?,?)",
[role_id, Number(permission_id)]
);
res.json({ ok: 1 });
}
);
r.delete(
"/roles/:role_id/permissions/:permission_id",
requirePerm("settings.manage"),
async (req, res) => {
const role_id = Number(req.params.role_id);
const permission_id = Number(req.params.permission_id);
await sql.query(
"DELETE FROM role_permissions WHERE role_id=? AND permission_id=?",
[role_id, permission_id]
);
res.json({ ok: 1 });
}
);
// user -> roles (global/org/project scope columns มีในตาราง user_roles ตามสคีมา)
r.get(
"/users/:user_id/roles",
requirePerm("settings.manage"),
async (req, res) => {
const user_id = Number(req.params.user_id);
const [rows] = await sql.query(
`SELECT ur.user_id, ur.role_id, r.role_code, r.role_name, ur.org_id, ur.project_id
FROM user_roles ur JOIN roles r ON r.role_id = ur.role_id
WHERE ur.user_id=? ORDER BY r.role_code`,
[user_id]
);
res.json(rows);
}
);
r.post(
"/users/:user_id/roles",
requirePerm("settings.manage"),
async (req, res) => {
const user_id = Number(req.params.user_id);
const { role_id, org_id = null, project_id = null } = req.body || {};
await sql.query(
"INSERT INTO user_roles (user_id, role_id, org_id, project_id) VALUES (?,?,?,?)",
[
user_id,
Number(role_id),
org_id ? Number(org_id) : null,
project_id ? Number(project_id) : null,
]
);
res.json({ ok: 1 });
}
);
r.delete(
"/users/:user_id/roles",
requirePerm("settings.manage"),
async (req, res) => {
const user_id = Number(req.params.user_id);
const { role_id, org_id = null, project_id = null } = req.body || {};
// สร้างเงื่อนไขแบบ dynamic สำหรับ NULL-safe compare
const whereOrg = org_id === null ? "ur.org_id IS NULL" : "ur.org_id = ?";
const wherePrj =
project_id === null ? "ur.project_id IS NULL" : "ur.project_id = ?";
const params = [user_id, Number(role_id)];
if (org_id !== null) params.push(Number(org_id));
if (project_id !== null) params.push(Number(project_id));
await sql.query(
`DELETE FROM user_roles ur WHERE ur.user_id=? AND ur.role_id=? AND ${whereOrg} AND ${wherePrj}`,
params
);
res.json({ ok: 1 });
}
);
export default r;

188
backend/src/routes/rbac_admin.js Normal file → Executable file
View File

@@ -1,126 +1,88 @@
// FILE: backend/src/routes/rbac_admin.js
// RBAC admin — ใช้ settings.manage ทั้งหมด
import { Router } from "express";
import sql from "../db/index.js";
import { Role, Permission, UserProjectRole, Project } from "../db/sequelize.js";
import { authJwt } from "../middleware/authJwt.js";
import { loadPrincipalMw } from "../middleware/loadPrincipal.js"; // แก้ไข: import ให้ถูกต้อง
import { requirePerm } from "../middleware/requirePerm.js";
const r = Router();
const router = Router();
// ROLES
r.get("/roles", requirePerm("settings.manage"), async (_req, res) => {
const [rows] = await sql.query(
"SELECT role_id, role_code, role_name, description FROM roles ORDER BY role_code"
);
res.json(rows);
// Middleware Chain ที่ถูกต้อง 100% ตามสถาปัตยกรรมของคุณ
router.use(authJwt(), loadPrincipalMw());
// == ROLES Management ==
router.get("/roles", requirePerm("roles.manage"), async (req, res, next) => {
try {
const roles = await Role.findAll({
include: [{ model: Permission, attributes: ["id", "name"], through: { attributes: [] } }],
order: [["name", "ASC"]],
});
res.json(roles);
} catch (error) { next(error); }
});
// PERMISSIONS
r.get("/permissions", requirePerm("settings.manage"), async (_req, res) => {
const [rows] = await sql.query(
"SELECT permission_id, perm_code AS permission_code, scope_level, description FROM permissions ORDER BY perm_code"
);
res.json(rows);
router.post("/roles", requirePerm("roles.manage"), async (req, res, next) => {
const { name, description } = req.body;
if (!name) return res.status(400).json({ message: "Role name is required." });
try {
const newRole = await Role.create({ name, description });
res.status(201).json(newRole);
} catch (error) {
if (error.name === "SequelizeUniqueConstraintError") {
return res.status(409).json({ message: `Role '${name}' already exists.` });
}
next(error);
}
});
// role -> permissions
r.get(
"/roles/:role_id/permissions",
requirePerm("settings.manage"),
async (req, res) => {
const role_id = Number(req.params.role_id);
const [rows] = await sql.query(
`SELECT p.permission_id, p.perm_code AS permission_code, p.description
FROM role_permissions rp
JOIN permissions p ON p.permission_id = rp.permission_id
WHERE rp.role_id=? ORDER BY p.perm_code`,
[role_id]
);
res.json(rows);
}
);
router.put("/roles/:id/permissions", requirePerm("roles.manage"), async (req, res, next) => {
const { permissionIds } = req.body;
if (!Array.isArray(permissionIds)) return res.status(400).json({ message: "permissionIds must be an array." });
try {
const role = await Role.findByPk(req.params.id);
if (!role) return res.status(404).json({ message: "Role not found." });
await role.setPermissions(permissionIds);
const updatedRole = await Role.findByPk(req.params.id, {
include: [{ model: Permission, attributes: ['id', 'name'], through: { attributes: [] } }]
});
res.json(updatedRole);
} catch (error) { next(error); }
});
r.post(
"/roles/:role_id/permissions",
requirePerm("settings.manage"),
async (req, res) => {
const role_id = Number(req.params.role_id);
const { permission_id } = req.body || {};
await sql.query(
"INSERT IGNORE INTO role_permissions (role_id, permission_id) VALUES (?,?)",
[role_id, Number(permission_id)]
);
res.json({ ok: 1 });
}
);
// == USER-PROJECT-ROLES Management ==
router.get("/user-project-roles", requirePerm("users.manage"), async (req, res, next) => {
const { userId } = req.query;
if (!userId) return res.status(400).json({ message: "userId query parameter is required." });
try {
const assignments = await UserProjectRole.findAll({
where: { user_id: userId },
include: [ { model: Project, attributes: ["id", "name"] }, { model: Role, attributes: ["id", "name"] } ],
});
res.json(assignments);
} catch (error) { next(error); }
});
r.delete(
"/roles/:role_id/permissions/:permission_id",
requirePerm("settings.manage"),
async (req, res) => {
const role_id = Number(req.params.role_id);
const permission_id = Number(req.params.permission_id);
await sql.query(
"DELETE FROM role_permissions WHERE role_id=? AND permission_id=?",
[role_id, permission_id]
);
res.json({ ok: 1 });
}
);
router.post("/user-project-roles", requirePerm("users.manage"), async (req, res, next) => {
const { userId, projectId, roleId } = req.body;
if (!userId || !projectId || !roleId) return res.status(400).json({ message: "userId, projectId, and roleId are required." });
try {
const [assignment, created] = await UserProjectRole.findOrCreate({
where: { user_id: userId, project_id: projectId, role_id: roleId },
defaults: { user_id: userId, project_id: projectId, role_id: roleId },
});
if (!created) return res.status(409).json({ message: "This assignment already exists." });
res.status(201).json(assignment);
} catch (error) { next(error); }
});
// user -> roles (global/org/project scope columns มีในตาราง user_roles ตามสคีมา)
r.get(
"/users/:user_id/roles",
requirePerm("settings.manage"),
async (req, res) => {
const user_id = Number(req.params.user_id);
const [rows] = await sql.query(
`SELECT ur.user_id, ur.role_id, r.role_code, r.role_name, ur.org_id, ur.project_id
FROM user_roles ur JOIN roles r ON r.role_id = ur.role_id
WHERE ur.user_id=? ORDER BY r.role_code`,
[user_id]
);
res.json(rows);
}
);
router.delete("/user-project-roles", requirePerm("users.manage"), async (req, res, next) => {
const { userId, projectId, roleId } = req.body;
if (!userId || !projectId || !roleId) return res.status(400).json({ message: "userId, projectId, and roleId are required." });
try {
const deletedCount = await UserProjectRole.destroy({ where: { user_id: userId, project_id: projectId, role_id: roleId } });
if (deletedCount === 0) return res.status(404).json({ message: 'Assignment not found.' });
res.status(204).send();
} catch (error) { next(error); }
});
r.post(
"/users/:user_id/roles",
requirePerm("settings.manage"),
async (req, res) => {
const user_id = Number(req.params.user_id);
const { role_id, org_id = null, project_id = null } = req.body || {};
await sql.query(
"INSERT INTO user_roles (user_id, role_id, org_id, project_id) VALUES (?,?,?,?)",
[
user_id,
Number(role_id),
org_id ? Number(org_id) : null,
project_id ? Number(project_id) : null,
]
);
res.json({ ok: 1 });
}
);
r.delete(
"/users/:user_id/roles",
requirePerm("settings.manage"),
async (req, res) => {
const user_id = Number(req.params.user_id);
const { role_id, org_id = null, project_id = null } = req.body || {};
// สร้างเงื่อนไขแบบ dynamic สำหรับ NULL-safe compare
const whereOrg = org_id === null ? "ur.org_id IS NULL" : "ur.org_id = ?";
const wherePrj =
project_id === null ? "ur.project_id IS NULL" : "ur.project_id = ?";
const params = [user_id, Number(role_id)];
if (org_id !== null) params.push(Number(org_id));
if (project_id !== null) params.push(Number(project_id));
await sql.query(
`DELETE FROM user_roles ur WHERE ur.user_id=? AND ur.role_id=? AND ${whereOrg} AND ${wherePrj}`,
params
);
res.json({ ok: 1 });
}
);
export default r;
export default router;

View File

@@ -0,0 +1,55 @@
// FILE: backend/src/routes/users.js
import { Router } from "express";
import sql from "../db/index.js";
import { requirePerm } from "../middleware/requirePerm.js";
const r = Router();
// ME (ทุกคน)
r.get("/me", async (req, res) => {
const p = req.principal;
const [[u]] = await sql.query(
`SELECT user_id, username, email, first_name, last_name, org_id FROM users WHERE user_id=?`,
[p.user_id]
);
if (!u) return res.status(404).json({ error: "User not found" });
const [roles] = await sql.query(
`SELECT r.role_code, r.role_name, ur.org_id, ur.project_id
FROM user_roles ur JOIN roles r ON r.role_id = ur.role_id
WHERE ur.user_id=?`,
[p.user_id]
);
res.json({
...u,
roles,
role_codes: roles.map((r) => r.role_code),
permissions: [...(p.permissions || [])],
project_ids: p.project_ids,
org_ids: p.org_ids,
is_superadmin: p.is_superadmin,
});
});
// USERS LIST (ORG scope) — admin.access
r.get(
"/",
requirePerm("admin.access", { orgParam: "org_id" }),
async (req, res) => {
const P = req.principal;
let rows = [];
if (P.is_superadmin) {
[rows] = await sql.query(
"SELECT user_id, username, email, org_id FROM users ORDER BY user_id DESC LIMIT 500"
);
} else if (P.org_ids?.length) {
const inSql = P.org_ids.map(() => "?").join(",");
[rows] = await sql.query(
`SELECT user_id, username, email, org_id FROM users WHERE org_id IN (${inSql}) ORDER BY user_id DESC LIMIT 500`,
P.org_ids
);
}
res.json(rows);
}
);
export default r;

142
backend/src/routes/users.js Normal file → Executable file
View File

@@ -1,55 +1,99 @@
// FILE: backend/src/routes/users.js
import { Router } from "express";
import sql from "../db/index.js";
import { requirePerm } from "../middleware/requirePerm.js";
// File: backend/src/routes/users.js
import { Router } from 'express';
import { User, Role } from '../db/sequelize.js';
import { authJwt } from "../middleware/authJwt.js";
import { loadPrincipalMw } from "../middleware/loadPrincipal.js"; // แก้ไข: import ให้ถูกต้อง
import { requirePerm } from '../middleware/requirePerm.js';
import { hashPassword } from '../utils/passwords.js';
const r = Router();
const router = Router();
// ME (ทุกคน)
r.get("/me", async (req, res) => {
const p = req.principal;
const [[u]] = await sql.query(
`SELECT user_id, username, email, first_name, last_name, org_id FROM users WHERE user_id=?`,
[p.user_id]
);
if (!u) return res.status(404).json({ error: "User not found" });
const [roles] = await sql.query(
`SELECT r.role_code, r.role_name, ur.org_id, ur.project_id
FROM user_roles ur JOIN roles r ON r.role_id = ur.role_id
WHERE ur.user_id=?`,
[p.user_id]
);
res.json({
...u,
roles,
role_codes: roles.map((r) => r.role_code),
permissions: [...(p.permissions || [])],
project_ids: p.project_ids,
org_ids: p.org_ids,
is_superadmin: p.is_superadmin,
});
// Middleware Chain ที่ถูกต้อง 100%
router.use(authJwt(), loadPrincipalMw());
// GET /api/users
router.get('/', requirePerm('users.view'), async (req, res, next) => {
try {
const users = await User.findAll({
attributes: { exclude: ['password_hash'] },
include: [{ model: Role, attributes: ['id', 'name'], through: { attributes: [] } }],
order: [['username', 'ASC']]
});
res.json(users);
} catch (error) { next(error); }
});
// USERS LIST (ORG scope) — admin.access
r.get(
"/",
requirePerm("admin.access", { orgParam: "org_id" }),
async (req, res) => {
const P = req.principal;
let rows = [];
if (P.is_superadmin) {
[rows] = await sql.query(
"SELECT user_id, username, email, org_id FROM users ORDER BY user_id DESC LIMIT 500"
);
} else if (P.org_ids?.length) {
const inSql = P.org_ids.map(() => "?").join(",");
[rows] = await sql.query(
`SELECT user_id, username, email, org_id FROM users WHERE org_id IN (${inSql}) ORDER BY user_id DESC LIMIT 500`,
P.org_ids
);
// POST /api/users
router.post('/', requirePerm('users.manage'), async (req, res, next) => {
const { username, email, password, first_name, last_name, is_active, roles } = req.body;
if (!username || !email || !password) {
return res.status(400).json({ message: 'Username, email, and password are required' });
}
res.json(rows);
}
);
try {
const password_hash = await hashPassword(password);
const newUser = await User.create({
username, email, password_hash, first_name, last_name, is_active: is_active !== false,
created_by: req.principal.user_id,
updated_by: req.principal.user_id,
org_id: req.principal.org_ids[0] || null,
});
export default r;
if (roles && roles.length > 0) {
await newUser.setRoles(roles);
}
const userWithRoles = await User.findByPk(newUser.id, {
attributes: { exclude: ['password_hash'] },
include: [{ model: Role, attributes: ['id', 'name'], through: { attributes: [] } }]
});
res.status(201).json(userWithRoles);
} catch (error) {
if (error.name === 'SequelizeUniqueConstraintError') {
return res.status(409).json({ message: 'Username or email already exists.' });
}
next(error);
}
});
// PUT /api/users/:id
router.put('/:id', requirePerm('users.manage'), async (req, res, next) => {
const { id } = req.params;
const { email, first_name, last_name, is_active, roles } = req.body;
try {
const user = await User.findByPk(id);
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
user.email = email ?? user.email;
user.first_name = first_name ?? user.first_name;
user.last_name = last_name ?? user.last_name;
user.is_active = is_active ?? user.is_active;
user.updated_by = req.principal.user_id;
await user.save();
if (roles) {
await user.setRoles(roles);
}
const updatedUser = await User.findByPk(id, {
attributes: { exclude: ['password_hash'] },
include: [{ model: Role, attributes: ['id', 'name'], through: { attributes: [] } }]
});
res.json(updatedUser);
} catch (error) { next(error); }
});
// DELETE /api/users/:id
router.delete('/:id', requirePerm('users.manage'), async (req, res, next) => {
try {
const user = await User.findByPk(req.params.id);
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
user.is_active = false;
user.updated_by = req.principal.user_id;
await user.save();
res.status(204).send();
} catch (error) { next(error); }
});
export default router;

View File

@@ -1,4 +1,4 @@
# DMS Container v0_6_0
# DMS Container v0_7_0 251004
# version: "3.8"
x-restart: &restart_policy
restart: unless-stopped
@@ -82,10 +82,15 @@ services:
DB_PASSWORD: "Center#2025"
DB_NAME: "dms"
JWT_SECRET: "9a6d8705a6695ab9bae4ca1cd46c72a6379aa72404b96e2c5b59af881bb55c639dd583afdce5a885c68e188da55ce6dbc1fb4aa9cd4055ceb51507e56204e4ca"
JWT_ACCESS_SECRET: "9a6d8705a6695ab9bae4ca1cd46c72a6379aa72404b96e2c5b59af881bb55c639dd583afdce5a885c68e188da55ce6dbc1fb4aa9cd4055ceb51507e56204e4ca"
JWT_REFRESH_SECRET: "743e798bb10d6aba168bf68fc3cf8eff103c18bd34f1957a3906dc87987c0df139ab72498f2fe20d6c4c580f044ccba7d7bfa4393ee6035b73ba038f28d7480c"
ACCESS_TTL_MS: "900000"
REFRESH_TTL_MS: "604800000"
JWT_EXPIRES_IN: "12h"
PASSWORD_SALT_ROUNDS: "10"
FRONTEND_ORIGIN: "https://lcbp3.np-dms.work"
CORS_ORIGINS: "https://lcbp3.np-dms.work,http://localhost:3000,http://127.0.0.1:3000"
COOKIE_DOMAIN: ".np-dms.work"
RATE_LIMIT_WINDOW_MS: "900000"
RATE_LIMIT_MAX: "200"
BACKEND_LOG_DIR: "/app/logs"

View File

@@ -0,0 +1,38 @@
// File: frontend/app/(protected)/admin/_components/confirm-delete-dialog.jsx
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
export function ConfirmDeleteDialog({
isOpen,
setIsOpen,
title,
description,
onConfirm,
isLoading,
}) {
return (
<AlertDialog open={isOpen} onOpenChange={setIsOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isLoading}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm} disabled={isLoading} className="bg-red-600 hover:bg-red-700">
{isLoading ? 'Processing...' : 'Confirm'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,146 @@
// File: frontend/app/(protected)/admin/_components/role-form-dialog.jsx
'use client';
import { useState, useEffect } from 'react';
import api from '@/lib/api';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { ScrollArea } from '@/components/ui/scroll-area';
export function RoleFormDialog({ role, allPermissions, isOpen, setIsOpen, onSuccess }) {
const [formData, setFormData] = useState({ name: '', description: '' });
const [selectedPermissions, setSelectedPermissions] = useState(new Set());
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const isEditMode = !!role;
useEffect(() => {
if (isOpen) {
if (isEditMode) {
setFormData({ name: role.name, description: role.description || '' });
setSelectedPermissions(new Set(role.Permissions?.map(p => p.id) || []));
} else {
setFormData({ name: '', description: '' });
setSelectedPermissions(new Set());
}
setError('');
}
}, [role, isOpen]);
const handleInputChange = (e) => {
const { id, value } = e.target;
setFormData((prev) => ({ ...prev, [id]: value }));
};
const handlePermissionChange = (permissionId) => {
setSelectedPermissions(prev => {
const newSet = new Set(prev);
if (newSet.has(permissionId)) {
newSet.delete(permissionId);
} else {
newSet.add(permissionId);
}
return newSet;
});
};
const handleSubmit = async (e) => {
e.preventDefault();
setIsLoading(true);
setError('');
try {
if (isEditMode) {
// ในโหมดแก้ไข เราจะอัปเดตสิทธิ์เสมอ
await api.put(`/rbac/roles/${role.id}/permissions`, {
permissionIds: Array.from(selectedPermissions)
});
// (Optional) อาจจะเพิ่มการแก้ไขชื่อ/description ของ role ที่นี่ด้วยก็ได้
// await api.put(`/rbac/roles/${role.id}`, { name: formData.name, description: formData.description });
} else {
// ในโหมดสร้างใหม่
const newRoleRes = await api.post('/rbac/roles', formData);
// ถ้าสร้าง Role สำเร็จ และมีการเลือก Permission ไว้ ให้ทำการผูกสิทธิ์ทันที
if (newRoleRes.data && selectedPermissions.size > 0) {
await api.put(`/rbac/roles/${newRoleRes.data.id}/permissions`, {
permissionIds: Array.from(selectedPermissions)
});
}
}
onSuccess();
setIsOpen(false);
} catch (err) {
setError(err.response?.data?.message || 'An unexpected error occurred.');
} finally {
setIsLoading(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="sm:max-w-md">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>{isEditMode ? `Edit Permissions for ${role.name}` : 'Create New Role'}</DialogTitle>
<DialogDescription>
Select the permissions for this role.
</DialogDescription>
</DialogHeader>
<div className="py-4 space-y-4">
{!isEditMode && (
<>
<div className="space-y-1">
<Label htmlFor="name">Role Name</Label>
<Input id="name" value={formData.name} onChange={handleInputChange} required />
</div>
<div className="space-y-1">
<Label htmlFor="description">Description</Label>
<Input id="description" value={formData.description} onChange={handleInputChange} />
</div>
</>
)}
<div>
<Label>Permissions</Label>
<ScrollArea className="h-60 w-full rounded-md border p-4 mt-1">
<div className="space-y-2">
{allPermissions.map(perm => (
<div key={perm.id} className="flex items-center space-x-2">
<Checkbox
id={`perm-${perm.id}`}
checked={selectedPermissions.has(perm.id)}
onCheckedChange={() => handlePermissionChange(perm.id)}
/>
<label htmlFor={`perm-${perm.id}`} className="text-sm font-medium leading-none">
{perm.name}
</label>
</div>
))}
</div>
</ScrollArea>
</div>
</div>
{error && <p className="text-sm text-red-500 text-center pb-2">{error}</p>}
<DialogFooter>
<Button type="submit" disabled={isLoading}>
{isLoading ? 'Saving...' : 'Save Changes'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,267 @@
// File: frontend/app/(protected)/admin/users/_components/user-form-dialog.jsx
'use client';
import { useState, useEffect } from 'react';
import api from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Trash2 } from 'lucide-react';
import { ScrollArea } from '@/components/ui/scroll-area';
export function UserFormDialog({ user, isOpen, setIsOpen, onSuccess }) {
const [formData, setFormData] = useState({});
const [allRoles, setAllRoles] = useState([]);
const [selectedSystemRoles, setSelectedSystemRoles] = useState(new Set());
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [allProjects, setAllProjects] = useState([]);
const [projectRoles, setProjectRoles] = useState([]);
const [selectedProjectId, setSelectedProjectId] = useState('');
const [selectedRoleId, setSelectedRoleId] = useState('');
const isEditMode = !!user;
useEffect(() => {
const fetchPrerequisites = async () => {
try {
const [rolesRes, projectsRes] = await Promise.all([
api.get('/rbac/roles'),
api.get('/projects'),
]);
setAllRoles(rolesRes.data);
setAllProjects(projectsRes.data);
} catch (err) {
console.error('Failed to fetch prerequisites', err);
}
};
if (isOpen) {
fetchPrerequisites();
}
}, [isOpen]);
useEffect(() => {
const fetchUserData = async () => {
if (isEditMode) {
setFormData({
username: user.username,
email: user.email,
first_name: user.first_name || '',
last_name: user.last_name || '',
is_active: user.is_active,
});
setSelectedSystemRoles(new Set(user.Roles?.map(role => role.id) || []));
try {
const res = await api.get(`/rbac/user-project-roles?userId=${user.id}`);
setProjectRoles(res.data);
} catch (err) {
console.error("Failed to fetch user's project roles", err);
setProjectRoles([]);
}
} else {
setFormData({ username: '', email: '', password: '', first_name: '', last_name: '', is_active: true });
setSelectedSystemRoles(new Set());
setProjectRoles([]);
}
setError('');
setSelectedProjectId('');
setSelectedRoleId('');
};
if (isOpen) {
fetchUserData();
}
}, [user, isOpen]);
const handleInputChange = (e) => {
const { id, value } = e.target;
setFormData((prev) => ({ ...prev, [id]: value }));
};
const handleSystemRoleChange = (roleId) => {
setSelectedSystemRoles(prev => {
const newSet = new Set(prev);
if (newSet.has(roleId)) newSet.delete(roleId);
else newSet.add(roleId);
return newSet;
});
};
const handleAddProjectRole = async () => {
if (!selectedProjectId || !selectedRoleId) {
setError("Please select both a project and a role.");
return;
}
setIsLoading(true);
setError('');
try {
await api.post('/rbac/user-project-roles', {
userId: user.id,
projectId: selectedProjectId,
roleId: selectedRoleId
});
const res = await api.get(`/rbac/user-project-roles?userId=${user.id}`);
setProjectRoles(res.data);
setSelectedProjectId('');
setSelectedRoleId('');
} catch(err) {
setError(err.response?.data?.message || 'Failed to add project role.');
} finally {
setIsLoading(false);
}
};
const handleRemoveProjectRole = async (assignment) => {
setIsLoading(true);
setError('');
try {
await api.delete('/rbac/user-project-roles', {
data: {
userId: user.id,
projectId: assignment.project_id,
roleId: assignment.role_id
}
});
setProjectRoles(prev => prev.filter(p => p.id !== assignment.id));
} catch(err) {
setError(err.response?.data?.message || 'Failed to remove project role.');
} finally {
setIsLoading(false);
}
};
const handleSaveUserDetails = async () => {
setIsLoading(true);
setError('');
const payload = { ...formData, roles: Array.from(selectedSystemRoles) };
try {
if (isEditMode) {
await api.put(`/users/${user.id}`, payload);
} else {
await api.post('/users', payload);
}
onSuccess();
setIsOpen(false);
} catch (err) {
setError(err.response?.data?.message || 'An unexpected error occurred.');
} finally {
setIsLoading(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="sm:max-w-3xl">
<DialogHeader>
<DialogTitle>{isEditMode ? `Edit User: ${user.username}` : 'Create New User'}</DialogTitle>
</DialogHeader>
<ScrollArea className="max-h-[70vh]">
<div className="grid grid-cols-1 p-4 md:grid-cols-2 gap-x-6 gap-y-4">
{/* Section 1: User Details & System Roles */}
<div className="pr-4 space-y-4 border-r-0 md:border-r">
<h3 className="pb-2 font-semibold border-b">User Details & System Roles</h3>
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input id="username" value={formData.username || ''} onChange={handleInputChange} required disabled={isEditMode} />
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" value={formData.email || ''} onChange={handleInputChange} required />
</div>
{!isEditMode && (
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input id="password" type="password" value={formData.password || ''} onChange={handleInputChange} required />
</div>
)}
<div className="flex gap-4">
<div className="flex-1 space-y-2">
<Label htmlFor="first_name">First Name</Label>
<Input id="first_name" value={formData.first_name || ''} onChange={handleInputChange} />
</div>
<div className="flex-1 space-y-2">
<Label htmlFor="last_name">Last Name</Label>
<Input id="last_name" value={formData.last_name || ''} onChange={handleInputChange} />
</div>
</div>
<div className="space-y-2">
<Label>System Roles</Label>
<div className="p-2 space-y-2 overflow-y-auto border rounded-md max-h-32">
{allRoles.map(role => (
<div key={role.id} className="flex items-center space-x-2">
<Checkbox id={`role-${role.id}`} checked={selectedSystemRoles.has(role.id)} onCheckedChange={() => handleSystemRoleChange(role.id)} />
<label htmlFor={`role-${role.id}`} className="text-sm font-medium">{role.name}</label>
</div>
))}
</div>
</div>
<div className="flex items-center pt-2 space-x-2">
<Switch id="is_active" checked={formData.is_active || false} onCheckedChange={(checked) => setFormData(prev => ({...prev, is_active: checked}))} />
<Label htmlFor="is_active">User is Active</Label>
</div>
</div>
{/* Section 2: Project Role Assignments */}
<div className="space-y-4">
<h3 className="pb-2 font-semibold border-b">Project Role Assignments</h3>
{isEditMode ? (
<>
<div className="p-4 space-y-3 border rounded-lg bg-muted/50">
<p className="text-sm font-medium">Assign New Project Role</p>
<div className="grid grid-cols-2 gap-2">
<Select onValueChange={setSelectedProjectId} value={selectedProjectId}>
<SelectTrigger><SelectValue placeholder="Select Project" /></SelectTrigger>
<SelectContent>{allProjects.map(p => <SelectItem key={p.id} value={String(p.id)}>{p.name}</SelectItem>)}</SelectContent>
</Select>
<Select onValueChange={setSelectedRoleId} value={selectedRoleId}>
<SelectTrigger><SelectValue placeholder="Select Role" /></SelectTrigger>
<SelectContent>{allRoles.map(r => <SelectItem key={r.id} value={String(r.id)}>{r.name}</SelectItem>)}</SelectContent>
</Select>
</div>
<Button onClick={handleAddProjectRole} disabled={isLoading || !selectedProjectId || !selectedRoleId} size="sm" className="w-full">
{isLoading ? 'Adding...' : 'Add Project Role'}
</Button>
</div>
<div className="space-y-2">
<p className="text-sm font-medium">Current Assignments</p>
<div className="pr-1 space-y-1 overflow-y-auto max-h-48">
{projectRoles.length > 0 ? projectRoles.map(pr => (
<div key={pr.id} className="flex items-center justify-between p-2 text-sm border rounded-md">
<div>
<span className="font-semibold">{pr.Project.name}</span>
<span className="text-muted-foreground"> as </span>
<span>{pr.Role.name}</span>
</div>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => handleRemoveProjectRole(pr)} disabled={isLoading}>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
)) : <p className="py-2 text-sm italic text-center text-muted-foreground">No project assignments.</p>}
</div>
</div>
</>
) : <p className="py-4 text-sm italic text-center text-muted-foreground">Save the user first to assign project roles.</p>}
</div>
</div>
</ScrollArea>
{error && <p className="pb-2 text-sm text-center text-red-500">{error}</p>}
<DialogFooter className="pt-4 border-t">
<Button onClick={() => setIsOpen(false)} variant="outline">Close</Button>
<Button onClick={handleSaveUserDetails} disabled={isLoading}>
{isLoading ? 'Saving...' : 'Save User Details'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,43 @@
// File: frontend/app/(protected)/admin/layout.jsx
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Users, ShieldCheck } from 'lucide-react';
import { cn } from '@/lib/utils'; // ตรวจสอบว่า import cn มาจากที่ถูกต้อง
export default function AdminLayout({ children }) {
const pathname = usePathname();
const navLinks = [
{ href: '/admin/users', label: 'User Management', icon: Users },
{ href: '/admin/roles', label: 'Role & Permission', icon: ShieldCheck },
];
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-3xl font-bold">Admin Settings</h1>
<p className="text-muted-foreground">Manage users, roles, and system permissions.</p>
</div>
<div className="flex border-b">
{navLinks.map(({ href, label, icon: Icon }) => (
<Link
key={href}
href={href}
className={cn(
'flex items-center gap-2 px-4 py-2 -mb-px border-b-2 text-sm font-medium transition-colors',
pathname === href
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'
)}
>
<Icon className="h-4 w-4" />
{label}
</Link>
))}
</div>
<div>{children}</div>
</div>
);
}

View File

@@ -0,0 +1,105 @@
// File: frontend/app/(protected)/admin/roles/page.jsx
'use client';
import { useState, useEffect } from 'react';
import api from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ShieldCheck, PlusCircle } from 'lucide-react';
// Import Dialog component ที่เราเพิ่งสร้าง
import { RoleFormDialog } from '../_components/role-form-dialog';
export default function RolesPage() {
const [roles, setRoles] = useState([]);
const [allPermissions, setAllPermissions] = useState([]);
const [loading, setLoading] = useState(true);
// State สำหรับควบคุม Dialog
const [isFormOpen, setIsFormOpen] = useState(false);
const [selectedRole, setSelectedRole] = useState(null);
const fetchData = async () => {
try {
setLoading(true);
const [rolesRes, permsRes] = await Promise.all([
api.get('/rbac/roles'),
api.get('/rbac/permissions'),
]);
setRoles(rolesRes.data);
setAllPermissions(permsRes.data);
} catch (error) {
console.error("Failed to fetch RBAC data", error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, []);
const handleCreate = () => {
setSelectedRole(null); // ไม่มี Role ที่เลือก = สร้างใหม่
setIsFormOpen(true);
};
const handleEdit = (role) => {
setSelectedRole(role);
setIsFormOpen(true);
};
if (loading) return <div>Loading role settings...</div>;
return (
<>
<div className="space-y-4">
<div className="flex justify-between items-center">
<h2 className="text-2xl font-semibold">Roles & Permissions</h2>
<Button onClick={handleCreate}>
<PlusCircle className="mr-2 h-4 w-4" /> Add Role
</Button>
</div>
{roles.map(role => (
<Card key={role.id}>
<CardHeader>
<div className="flex justify-between items-start">
<div>
<CardTitle className="flex items-center gap-2">
<ShieldCheck className="text-primary" />
{role.name}
</CardTitle>
<CardDescription>{role.description || 'No description'}</CardDescription>
</div>
<Button variant="outline" size="sm" onClick={() => handleEdit(role)}>
Edit Permissions
</Button>
</div>
</CardHeader>
<CardContent>
<p className="text-sm font-medium mb-2">Assigned Permissions:</p>
<div className="flex flex-wrap gap-2">
{role.Permissions.length > 0 ? (
role.Permissions.map(perm => (
<Badge key={perm.id} variant="secondary">{perm.name}</Badge>
))
) : (
<p className="text-sm text-muted-foreground">No permissions assigned.</p>
)}
</div>
</CardContent>
</Card>
))}
</div>
<RoleFormDialog
isOpen={isFormOpen}
setIsOpen={setIsFormOpen}
role={selectedRole}
allPermissions={allPermissions}
onSuccess={fetchData}
/>
</>
);
}

View File

@@ -0,0 +1,161 @@
// File: frontend/app/(protected)/admin/users/page.jsx
'use client';
import { useState, useEffect } from 'react';
import { PlusCircle, MoreHorizontal } from 'lucide-react';
import api from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { Badge } from '@/components/ui/badge';
// Import components ที่เราเพิ่งสร้าง
import { UserFormDialog } from '../_components/user-form-dialog';
import { ConfirmDeleteDialog } from '../_components/confirm-delete-dialog';
export default function UsersPage() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
// State สำหรับควบคุม Dialog ทั้งหมด
const [isFormOpen, setIsFormOpen] = useState(false);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [selectedUser, setSelectedUser] = useState(null);
const [isSubmitting, setIsSubmitting] = useState(false);
// Function สำหรับดึงข้อมูลใหม่
const fetchUsers = async () => {
try {
setLoading(true);
const res = await api.get('/users');
setUsers(res.data);
} catch (error) {
console.error("Failed to fetch users", error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchUsers();
}, []);
// Handlers สำหรับเปิด Dialog
const handleCreate = () => {
setSelectedUser(null);
setIsFormOpen(true);
};
const handleEdit = (user) => {
setSelectedUser(user);
setIsFormOpen(true);
};
const handleDelete = (user) => {
setSelectedUser(user);
setIsDeleteOpen(true);
};
// Function ที่จะทำงานเมื่อยืนยันการลบ
const confirmDeactivate = async () => {
if (!selectedUser) return;
setIsSubmitting(true);
try {
await api.delete(`/users/${selectedUser.id}`);
fetchUsers(); // Refresh ข้อมูล
setIsDeleteOpen(false);
} catch (error) {
console.error("Failed to deactivate user", error);
// ควรมี Alert แจ้งเตือน
} finally {
setIsSubmitting(false);
}
};
return (
<>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>User Accounts</CardTitle>
<CardDescription>Manage all user accounts and their roles.</CardDescription>
</div>
<Button onClick={handleCreate}>
<PlusCircle className="w-4 h-4 mr-2" /> Add User
</Button>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Username</TableHead>
<TableHead>Email</TableHead>
<TableHead>Roles</TableHead>
<TableHead>Status</TableHead>
<TableHead><span className="sr-only">Actions</span></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow><TableCell colSpan={5} className="text-center">Loading...</TableCell></TableRow>
) : (
users.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.username}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{user.Roles?.map(role => <Badge key={role.id} variant="secondary">{role.name}</Badge>)}
</div>
</TableCell>
<TableCell>
<Badge variant={user.is_active ? 'default' : 'destructive'}>
{user.is_active ? 'Active' : 'Inactive'}
</Badge>
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="w-8 h-8 p-0"><MoreHorizontal className="w-4 h-4" /></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem onClick={() => handleEdit(user)}>Edit</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDelete(user)} className="text-red-500">
Deactivate
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
{/* Render Dialogs ที่นี่ (มันจะไม่แสดงผลจนกว่า state จะเป็น true) */}
<UserFormDialog
user={selectedUser}
isOpen={isFormOpen}
setIsOpen={setIsFormOpen}
onSuccess={fetchUsers}
/>
<ConfirmDeleteDialog
isOpen={isDeleteOpen}
setIsOpen={setIsDeleteOpen}
isLoading={isSubmitting}
title="Are you sure?"
description={`This will deactivate the user "${selectedUser?.username}". They will no longer be able to log in.`}
onConfirm={confirmDeactivate}
/>
</>
);
}

View File

@@ -0,0 +1,977 @@
// frontend/app//(protected)/dashboard/page.jsx
"use client";
import React from "react";
import Link from "next/link";
import { motion } from "framer-motion";
import {
LayoutDashboard,
FileText,
Files,
Send,
Layers,
Users,
Settings,
Activity,
Search,
ChevronRight,
ShieldCheck,
Workflow,
Database,
Mail,
Server,
Shield,
BookOpen,
PanelLeft,
PanelRight,
ChevronDown,
Plus,
Filter,
Eye,
EyeOff,
SlidersHorizontal,
Columns3,
X,
ExternalLink,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Progress } from "@/components/ui/progress";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuLabel,
} from "@/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Switch } from "@/components/ui/switch";
import { API_BASE } from "@/lib/api";
const sea = {
light: "#E6F7FB",
light2: "#F3FBFD",
mid: "#2A7F98",
dark: "#0D5C75",
textDark: "#0E2932",
};
const can = (user, perm) => new Set(user?.permissions || []).has(perm);
const Tag = ({ children }) => (
<Badge
className="px-3 py-1 text-xs border-0 rounded-full"
style={{ background: sea.light, color: sea.dark }}
>
{children}
</Badge>
);
const SidebarItem = ({ label, icon: Icon, active = false, badge }) => (
<button
className={`group w-full flex items-center gap-3 rounded-2xl px-4 py-3 text-left transition-all border ${
active ? "bg-white/70" : "bg-white/30 hover:bg-white/60"
}`}
style={{ borderColor: "#ffffff40", color: sea.textDark }}
>
<Icon className="w-5 h-5" />
<span className="font-medium grow">{label}</span>
{badge ? (
<span
className="text-xs rounded-full px-2 py-0.5"
style={{ background: sea.light, color: sea.dark }}
>
{badge}
</span>
) : null}
<ChevronRight className="w-4 h-4 transition-opacity opacity-0 group-hover:opacity-100" />
</button>
);
const KPI = ({ label, value, icon: Icon, onClick }) => (
<Card
onClick={onClick}
className="transition border-0 shadow-sm cursor-pointer rounded-2xl hover:shadow"
style={{ background: "white" }}
>
<CardContent className="p-5">
<div className="flex items-start justify-between">
<span className="text-sm opacity-70">{label}</span>
<div className="p-2 rounded-xl" style={{ background: sea.light }}>
<Icon className="w-5 h-5" style={{ color: sea.dark }} />
</div>
</div>
<div className="mt-3 text-3xl font-bold" style={{ color: sea.textDark }}>
{value}
</div>
<div className="mt-2">
<Progress value={Math.min(100, (value / 400) * 100)} />
</div>
</CardContent>
</Card>
);
function PreviewDrawer({ open, onClose, children }) {
return (
<div
className={`fixed top-0 right-0 h-full w-full sm:w-[420px] bg-white shadow-2xl transition-transform z-50 ${
open ? "translate-x-0" : "translate-x-full"
}`}
>
<div className="flex items-center justify-between p-4 border-b">
<div className="font-medium">รายละเอยด</div>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="w-5 h-5" />
</Button>
</div>
<div className="p-4 overflow-auto h-[calc(100%-56px)]">{children}</div>
</div>
);
}
export default function DashboardPage() {
const [user, setUser] = React.useState(null);
const [sidebarOpen, setSidebarOpen] = React.useState(true);
const [densityCompact, setDensityCompact] = React.useState(false);
const [showCols, setShowCols] = React.useState({
type: true,
id: true,
title: true,
status: true,
due: true,
owner: true,
actions: true,
});
const [previewOpen, setPreviewOpen] = React.useState(false);
const [filters, setFilters] = React.useState({
type: "All",
status: "All",
overdue: false,
});
const [activeQuery, setActiveQuery] = React.useState({});
React.useEffect(() => {
fetch(`${API_BASE}/auth/me`, { credentials: "include" })
.then((r) => (r.ok ? r.json() : null))
.then((data) => setUser(data?.user || null))
.catch(() => setUser(null));
}, []);
const quickLinks = [
{
label: "สร้าง RFA",
icon: FileText,
perm: "rfa:create",
href: "/rfas/new",
},
{
label: "อัปโหลด Drawing",
icon: Layers,
perm: "drawing:upload",
href: "/drawings/upload",
},
{
label: "สร้าง Transmittal",
icon: Send,
perm: "transmittal:create",
href: "/transmittals/new",
},
{
label: "บันทึกหนังสือสื่อสาร",
icon: Mail,
perm: "correspondence:create",
href: "/correspondences/new",
},
];
const nav = [
{ label: "แดชบอร์ด", icon: LayoutDashboard },
{ label: "Drawings", icon: Layers },
{ label: "RFAs", icon: FileText },
{ label: "Transmittals", icon: Send },
{ label: "Contracts & Volumes", icon: BookOpen },
{ label: "Correspondences", icon: Files },
{ label: "ผู้ใช้/บทบาท", icon: Users, perm: "users:manage" },
{ label: "Reports", icon: Activity },
{ label: "Workflow (n8n)", icon: Workflow, perm: "workflow:view" },
{ label: "Health", icon: Server, perm: "health:view" },
{ label: "Admin", icon: Settings, perm: "admin:view" },
];
const kpis = [
{
key: "rfa-pending",
label: "RFAs รออนุมัติ",
value: 12,
icon: FileText,
query: { type: "RFA", status: "pending" },
},
{
key: "drawings",
label: "แบบ (Drawings) ล่าสุด",
value: 326,
icon: Layers,
query: { type: "Drawing" },
},
{
key: "trans-month",
label: "Transmittals เดือนนี้",
value: 18,
icon: Send,
query: { type: "Transmittal", month: "current" },
},
{
key: "overdue",
label: "เกินกำหนด (Overdue)",
value: 5,
icon: Activity,
query: { overdue: true },
},
];
const recent = [
{
type: "RFA",
code: "RFA-LCP3-0012",
title: "ปรับปรุงรายละเอียดเสาเข็มท่าเรือ",
who: "สุรเชษฐ์ (Editor)",
when: "เมื่อวาน 16:40",
},
{
type: "Drawing",
code: "DWG-C-210A-Rev.3",
title: "แปลนโครงสร้างท่าเรือส่วนที่ 2",
who: "วรวิชญ์ (Admin)",
when: "วันนี้ 09:15",
},
{
type: "Transmittal",
code: "TR-2025-0916-04",
title: "ส่งแบบ Rebar Shop Drawing ชุด A",
who: "Supansa (Viewer)",
when: "16 ก.ย. 2025",
},
{
type: "Correspondence",
code: "CRSP-58",
title: "แจ้งเลื่อนประชุมตรวจแบบ",
who: "Kitti (Editor)",
when: "15 ก.ย. 2025",
},
];
const items = [
{
t: "RFA",
id: "RFA-LCP3-0013",
title: "ยืนยันรายละเอียดท่อระบายน้ำ",
status: "Pending",
due: "20 ก.ย. 2025",
owner: "คุณแดง",
},
{
t: "Drawing",
id: "DWG-S-115-Rev.1",
title: "Section เสาเข็มพื้นที่ส่วนที่ 1",
status: "Review",
due: "19 ก.ย. 2025",
owner: "วิทยา",
},
{
t: "Transmittal",
id: "TR-2025-0915-03",
title: "ส่งแบบโครงสร้างท่าเรือ ชุด B",
status: "Sent",
due: "—",
owner: "สุธิดา",
},
];
const visibleItems = items.filter((r) => {
if (filters.type !== "All" && r.t !== filters.type) return false;
if (filters.status !== "All" && r.status !== filters.status) return false;
if (filters.overdue && r.due === "—") return false;
return true;
});
const onKpiClick = (q) => {
setActiveQuery(q);
if (q?.type) setFilters((f) => ({ ...f, type: q.type }));
if (q?.overdue) setFilters((f) => ({ ...f, overdue: true }));
};
return (
<TooltipProvider>
<div
className="min-h-screen"
style={{
background: `linear-gradient(180deg, ${sea.light2} 0%, ${sea.light} 100%)`,
}}
>
<header
className="sticky top-0 z-40 border-b backdrop-blur-md"
style={{
borderColor: "#ffffff66",
background: "rgba(230,247,251,0.7)",
}}
>
<div className="flex items-center gap-3 px-4 py-2 mx-auto max-w-7xl">
<button
className="flex items-center justify-center shadow-sm h-9 w-9 rounded-2xl"
style={{ background: sea.dark }}
onClick={() => setSidebarOpen((v) => !v)}
aria-label={sidebarOpen ? "ซ่อนแถบด้านข้าง" : "แสดงแถบด้านข้าง"}
>
{sidebarOpen ? (
<PanelLeft className="w-5 h-5 text-white" />
) : (
<PanelRight className="w-5 h-5 text-white" />
)}
</button>
<div>
<div className="text-xs opacity-70">
Document Management System
</div>
<div className="font-semibold" style={{ color: sea.textDark }}>
โครงการพฒนาทาเรอแหลมฉบ ระยะท 3 วนท 14
</div>
</div>
<Tag>Phase 3</Tag>
<Tag>Port Infrastructure</Tag>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="flex items-center gap-2 ml-auto rounded-2xl btn-sea">
System <ChevronDown className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-56">
<DropdownMenuLabel>ระบบ</DropdownMenuLabel>
{can(user, "admin:view") && (
<DropdownMenuItem>
<Settings className="w-4 h-4 mr-2" /> Admin
</DropdownMenuItem>
)}
{can(user, "users:manage") && (
<DropdownMenuItem>
<Users className="w-4 h-4 mr-2" /> ใช/บทบาท
</DropdownMenuItem>
)}
{can(user, "health:view") && (
<DropdownMenuItem asChild>
<a href="/health" className="flex items-center w-full">
<Server className="w-4 h-4 mr-2" /> Health{" "}
<ExternalLink className="w-3 h-3 ml-auto" />
</a>
</DropdownMenuItem>
)}
{can(user, "workflow:view") && (
<DropdownMenuItem asChild>
<a href="/workflow" className="flex items-center w-full">
<Workflow className="w-4 h-4 mr-2" /> Workflow (n8n){" "}
<ExternalLink className="w-3 h-3 ml-auto" />
</a>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="ml-2 rounded-2xl btn-sea">
<Plus className="w-4 h-4 mr-1" /> New
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{quickLinks.map(({ label, icon: Icon, perm, href }) =>
can(user, perm) ? (
<DropdownMenuItem key={label} asChild>
<Link href={href} className="flex items-center">
<Icon className="w-4 h-4 mr-2" />
{label}
</Link>
</DropdownMenuItem>
) : (
<Tooltip key={label}>
<TooltipTrigger asChild>
<div className="px-2 py-1.5 text-sm opacity-40 cursor-not-allowed flex items-center">
<Icon className="w-4 h-4 mr-2" />
{label}
</div>
</TooltipTrigger>
<TooltipContent>
ไมทธใชงาน ({perm})
</TooltipContent>
</Tooltip>
)
)}
<DropdownMenuSeparator />
<DropdownMenuItem>
<Layers className="w-4 h-4 mr-2" /> Import / Bulk upload
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
<div className="grid grid-cols-12 gap-6 px-4 py-6 mx-auto max-w-7xl">
{sidebarOpen && (
<aside className="col-span-12 lg:col-span-3 xl:col-span-3">
<div
className="p-4 border rounded-3xl"
style={{
background: "rgba(255,255,255,0.7)",
borderColor: "#ffffff66",
}}
>
<div className="flex items-center gap-2 mb-3">
<ShieldCheck
className="w-5 h-5"
style={{ color: sea.dark }}
/>
<div className="text-sm">
RBAC:{" "}
<span className="font-medium">{user?.role || "—"}</span>
</div>
</div>
<div className="relative mb-3">
<Search className="absolute w-4 h-4 -translate-y-1/2 left-3 top-1/2 opacity-70" />
<Input
placeholder="ค้นหา RFA / Drawing / Transmittal / Code…"
className="bg-white border-0 pl-9 rounded-2xl"
/>
</div>
<div
className="p-3 mb-3 border rounded-2xl"
style={{ borderColor: "#eef6f8", background: "#ffffffaa" }}
>
<div className="mb-2 text-xs font-medium">วกรอง</div>
<div className="grid grid-cols-2 gap-2">
<select
className="p-2 text-sm border rounded-xl"
value={filters.type}
onChange={(e) =>
setFilters((f) => ({ ...f, type: e.target.value }))
}
>
<option>All</option>
<option>RFA</option>
<option>Drawing</option>
<option>Transmittal</option>
<option>Correspondence</option>
</select>
<select
className="p-2 text-sm border rounded-xl"
value={filters.status}
onChange={(e) =>
setFilters((f) => ({ ...f, status: e.target.value }))
}
>
<option>All</option>
<option>Pending</option>
<option>Review</option>
<option>Sent</option>
</select>
<label className="flex items-center col-span-2 gap-2 text-sm">
<Switch
checked={filters.overdue}
onCheckedChange={(v) =>
setFilters((f) => ({ ...f, overdue: v }))
}
/>{" "}
แสดงเฉพาะ Overdue
</label>
</div>
<div className="flex gap-2 mt-2">
<Button
size="sm"
variant="outline"
className="rounded-xl"
style={{ borderColor: sea.mid, color: sea.dark }}
>
<Filter className="w-4 h-4 mr-1" />
Apply
</Button>
<Button
size="sm"
variant="ghost"
className="rounded-xl"
onClick={() =>
setFilters({
type: "All",
status: "All",
overdue: false,
})
}
>
Reset
</Button>
</div>
</div>
<div className="space-y-2">
{nav
.filter((item) => !item.perm || can(user, item.perm))
.map((n, i) => (
<SidebarItem
key={n.label}
label={n.label}
icon={n.icon}
active={i === 0}
badge={n.label === "RFAs" ? 12 : undefined}
/>
))}
</div>
<div className="flex items-center gap-2 mt-5 text-xs opacity-70">
<Database className="w-4 h-4" /> dms_db MariaDB 10.11
</div>
</div>
</aside>
)}
<main
className={`col-span-12 ${
sidebarOpen ? "lg:col-span-9 xl:col-span-9" : ""
} space-y-6`}
>
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.05, duration: 0.4 }}
>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{kpis.map((k) => (
<KPI key={k.key} {...k} onClick={() => onKpiClick(k.query)} />
))}
</div>
</motion.div>
<div className="flex items-center justify-between">
<div className="text-sm opacity-70">
ผลลพธจากตวกรอง: {filters.type}/{filters.status}
{filters.overdue ? " • Overdue" : ""}
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
className="rounded-xl"
style={{ borderColor: sea.mid, color: sea.dark }}
onClick={() => setDensityCompact((v) => !v)}
>
<SlidersHorizontal className="w-4 h-4 mr-1" /> Density:{" "}
{densityCompact ? "Compact" : "Comfort"}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant="outline"
className="rounded-xl"
style={{ borderColor: sea.mid, color: sea.dark }}
>
<Columns3 className="w-4 h-4 mr-1" /> Columns
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{Object.keys(showCols).map((key) => (
<DropdownMenuItem
key={key}
onClick={() =>
setShowCols((s) => ({ ...s, [key]: !s[key] }))
}
>
{showCols[key] ? (
<Eye className="w-4 h-4 mr-2" />
) : (
<EyeOff className="w-4 h-4 mr-2" />
)}
{key}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<Card className="border-0 rounded-2xl">
<CardContent className="p-0">
<div className="overflow-x-auto">
<table
className={`min-w-full text-sm ${
densityCompact ? "[&_*]:py-1" : ""
}`}
>
<thead
className="sticky top-[56px] z-10"
style={{
background: "white",
borderBottom: "1px solid #efefef",
}}
>
<tr className="text-left">
{showCols.type && <th className="px-3 py-2">ประเภท</th>}
{showCols.id && <th className="px-3 py-2">รห</th>}
{showCols.title && (
<th className="px-3 py-2">อเรอง</th>
)}
{showCols.status && (
<th className="px-3 py-2">สถานะ</th>
)}
{showCols.due && (
<th className="px-3 py-2">กำหนดส</th>
)}
{showCols.owner && (
<th className="px-3 py-2">บผดชอบ</th>
)}
{showCols.actions && (
<th className="px-3 py-2">ดการ</th>
)}
</tr>
</thead>
<tbody>
{visibleItems.length === 0 && (
<tr>
<td
className="px-3 py-8 text-center opacity-70"
colSpan={7}
>
ไมพบรายการตามตวกรองทเลอก
</td>
</tr>
)}
{visibleItems.map((row) => (
<tr
key={row.id}
className="border-b cursor-pointer hover:bg-gray-50/50"
style={{ borderColor: "#f3f3f3" }}
onClick={() => setPreviewOpen(true)}
>
{showCols.type && (
<td className="px-3 py-2">{row.t}</td>
)}
{showCols.id && (
<td className="px-3 py-2 font-mono">{row.id}</td>
)}
{showCols.title && (
<td className="px-3 py-2">{row.title}</td>
)}
{showCols.status && (
<td className="px-3 py-2">
<Tag>{row.status}</Tag>
</td>
)}
{showCols.due && (
<td className="px-3 py-2">{row.due}</td>
)}
{showCols.owner && (
<td className="px-3 py-2">{row.owner}</td>
)}
{showCols.actions && (
<td className="px-3 py-2">
<div className="flex gap-2">
<Button
size="sm"
className="rounded-xl btn-sea"
>
เป
</Button>
<Button
size="sm"
variant="outline"
className="rounded-xl"
style={{
borderColor: sea.mid,
color: sea.dark,
}}
>
Assign
</Button>
</div>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
<div
className="px-4 py-2 text-xs border-t opacity-70"
style={{ borderColor: "#efefef" }}
>
เคลดล: ใช / เลอนแถว, Enter เป, /
</div>
</CardContent>
</Card>
<Tabs defaultValue="overview" className="w-full">
<TabsList
className="border rounded-2xl bg-white/80"
style={{ borderColor: "#ffffff80" }}
>
<TabsTrigger value="overview">ภาพรวม</TabsTrigger>
<TabsTrigger value="reports">รายงาน</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="mt-4 space-y-4">
<div className="grid gap-4 lg:grid-cols-5">
<Card className="border-0 rounded-2xl lg:col-span-3">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div
className="font-semibold"
style={{ color: sea.textDark }}
>
สถานะโครงการ
</div>
<Tag>Phase 3 วนท 14</Tag>
</div>
<div className="mt-4 space-y-3">
<div>
<div className="text-sm opacity-70">
ความคบหนาโดยรวม
</div>
<Progress value={62} />
</div>
<div className="grid grid-cols-3 gap-3">
<div
className="p-4 border rounded-xl"
style={{
background: sea.light,
borderColor: sea.light,
}}
>
<div className="text-xs opacity-70">วนท 1</div>
<div className="text-lg font-semibold">
เสร 70%
</div>
</div>
<div
className="p-4 border rounded-xl"
style={{
background: sea.light,
borderColor: sea.light,
}}
>
<div className="text-xs opacity-70">วนท 2</div>
<div className="text-lg font-semibold">
เสร 58%
</div>
</div>
<div
className="p-4 border rounded-xl"
style={{
background: sea.light,
borderColor: sea.light,
}}
>
<div className="text-xs opacity-70">
วนท 34
</div>
<div className="text-lg font-semibold">
เสร 59%
</div>
</div>
</div>
</div>
</CardContent>
</Card>
<Card className="border-0 rounded-2xl lg:col-span-2">
<CardContent className="p-5 space-y-3">
<div className="flex items-center justify-between">
<div
className="font-semibold"
style={{ color: sea.textDark }}
>
System Health
</div>
<Tag>QNAP Container Station</Tag>
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2">
<Server className="w-4 h-4" /> Nginx Reverse Proxy{" "}
<span
className="ml-auto font-medium"
style={{ color: sea.dark }}
>
Healthy
</span>
</div>
<div className="flex items-center gap-2">
<Database className="w-4 h-4" /> MariaDB 10.11{" "}
<span
className="ml-auto font-medium"
style={{ color: sea.dark }}
>
OK
</span>
</div>
<div className="flex items-center gap-2">
<Workflow className="w-4 h-4" /> n8n (Postgres){" "}
<span
className="ml-auto font-medium"
style={{ color: sea.dark }}
>
OK
</span>
</div>
<div className="flex items-center gap-2">
<Shield className="w-4 h-4" /> RBAC Enforcement{" "}
<span
className="ml-auto font-medium"
style={{ color: sea.dark }}
>
Enabled
</span>
</div>
</div>
<div
className="pt-2 border-t"
style={{ borderColor: "#eeeeee" }}
>
<Button
variant="outline"
className="rounded-xl"
style={{ borderColor: sea.mid, color: sea.dark }}
>
เปดหน /health
</Button>
</div>
</CardContent>
</Card>
</div>
<Card className="border-0 rounded-2xl">
<CardContent className="p-5">
<div className="flex items-center justify-between mb-3">
<div
className="font-semibold"
style={{ color: sea.textDark }}
>
จกรรมลาส
</div>
<div className="flex gap-2">
<Tag>Admin</Tag>
<Tag>Editor</Tag>
<Tag>Viewer</Tag>
</div>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{recent.map((r) => (
<div
key={r.code}
className="p-4 transition border rounded-2xl hover:shadow-sm"
style={{
background: "white",
borderColor: "#efefef",
}}
>
<div className="text-xs opacity-70">
{r.type} {r.code}
</div>
<div
className="mt-1 font-medium"
style={{ color: sea.textDark }}
>
{r.title}
</div>
<div className="mt-2 text-xs opacity-70">{r.who}</div>
<div className="text-xs opacity-70">{r.when}</div>
</div>
))}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="reports" className="mt-4">
<div className="grid gap-4 lg:grid-cols-2">
<Card className="border-0 rounded-2xl">
<CardContent className="p-5">
<div
className="mb-2 font-semibold"
style={{ color: sea.textDark }}
>
Report A: RFA Drawings Revisions
</div>
<div className="text-sm opacity-70">
รวมท Drawing Revision + Code
</div>
<div className="mt-3">
<Button className="rounded-2xl btn-sea">
Export CSV
</Button>
</div>
</CardContent>
</Card>
<Card className="border-0 rounded-2xl">
<CardContent className="p-5">
<div
className="mb-2 font-semibold"
style={{ color: sea.textDark }}
>
Report B: ไทมไลน RFA vs Drawing Rev
</div>
<div className="text-sm opacity-70">
Query #2 กำหนดไว
</div>
<div className="mt-3">
<Button className="rounded-2xl btn-sea">
รายงาน
</Button>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs>
<div className="py-6 text-xs text-center opacity-70">
Sea-themed Dashboard Sidebar อนได RBAC แสดง/อน Faceted
search KPI click-through Preview drawer Column
visibility/Density
</div>
</main>
</div>
<PreviewDrawer open={previewOpen} onClose={() => setPreviewOpen(false)}>
<div className="space-y-2 text-sm">
<div>
<span className="opacity-70">รห:</span> RFA-LCP3-0013
</div>
<div>
<span className="opacity-70">อเรอง:</span>{" "}
นยนรายละเอยดทอระบายน
</div>
<div>
<span className="opacity-70">สถานะ:</span> <Tag>Pending</Tag>
</div>
<div>
<span className="opacity-70">แนบไฟล:</span> 2 รายการ (PDF, DWG)
</div>
<div className="flex gap-2 pt-2">
{can(user, "rfa:create") && (
<Button className="btn-sea rounded-xl">แกไข</Button>
)}
<Button
variant="outline"
className="rounded-xl"
style={{ borderColor: sea.mid, color: sea.dark }}
>
เปดเตมหน
</Button>
</div>
</div>
</PreviewDrawer>
<style jsx global>{`
.btn-sea {
background: ${sea.dark};
}
.btn-sea:hover {
background: ${sea.mid};
}
.menu-sea {
background: ${sea.dark};
}
.menu-sea:hover {
background: ${sea.mid};
}
`}</style>
</div>
</TooltipProvider>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,10 @@
// frontend/app/(protected)/layout.jsx
import Link from "next/link";
import { redirect } from "next/navigation";
import { usePathname } from 'next/navigation';
import { cookies, headers } from "next/headers";
import { can } from "@/lib/rbac";
import { Home, FileText, Users, Settings } from 'lucide-react'; // เพิ่ม Users, Settings หรือไอคอนที่ต้องการ
export const metadata = { title: "DMS | Protected" };

View File

@@ -1,114 +1,157 @@
// frontend/app/layout.jsx
import "./globals.css";
import Link from "next/link";
import { redirect } from "next/navigation";
import { cookies, headers } from "next/headers";
'use client';
export const metadata = {
title: "DMS",
description: "Document Management System — LCBP3 Phase 3",
};
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import {
Bell,
Home,
Users,
Settings,
Package2,
FileText, // Added for example
LineChart, // Added for example
} from 'lucide-react';
const API_BASE = (process.env.NEXT_PUBLIC_API_BASE || "").replace(/\/$/, "");
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
/** ดึงสถานะผู้ใช้แบบ global (ไม่บังคับล็อกอิน) */
async function fetchGlobalSession() {
const cookieStore = await cookies();
const cookieHeader = cookieStore.toString();
// **1. Import `useAuth` และ `can` จากไฟล์จริงของคุณ**
import { useAuth } from '@/lib/auth';
import { can } from '@/lib/rbac';
const hdrs = await headers();
const hostHdr = hdrs.get("host");
const protoHdr = hdrs.get("x-forwarded-proto") || "https";
export default function ProtectedLayout({ children }) {
const pathname = usePathname();
// **2. เรียกใช้งาน useAuth hook เพื่อดึงข้อมูล user**
const { user, logout } = useAuth();
const res = await fetch(`${API_BASE}/api/auth/me`, {
method: "GET",
headers: {
Cookie: cookieHeader,
"X-Forwarded-Host": hostHdr || "",
"X-Forwarded-Proto": protoHdr,
Accept: "application/json",
},
cache: "no-store",
});
if (!res.ok) return null;
try {
const data = await res.json();
return data?.ok ? data : null;
} catch {
return null;
}
}
/** ปุ่ม Logout แบบ Server Action (ไม่ต้องมี client component) */
async function LogoutAction() {
"use server";
const cookieStore = await cookies();
const cookieHeader = cookieStore.toString();
const hdrs = await headers();
const hostHdr = hdrs.get("host");
const protoHdr = hdrs.get("x-forwarded-proto") || "https";
// เรียก backend ให้ลบคุกกี้ออก (HttpOnly cookies)
await fetch(`${API_BASE}/api/auth/logout`, {
method: "POST",
headers: {
Cookie: cookieHeader,
"X-Forwarded-Host": hostHdr || "",
"X-Forwarded-Proto": protoHdr,
Accept: "application/json",
},
cache: "no-store",
});
// กลับไปหน้า login พร้อม next ไป dashboard
redirect("/login?next=/dashboard");
}
export default async function RootLayout({ children }) {
const session = await fetchGlobalSession();
const loggedIn = !!session?.user;
const navLinks = [
{ href: '/dashboard', label: 'Dashboard', icon: Home },
{ href: '/correspondences', label: 'Correspondences', icon: FileText },
{ href: '/drawings', label: 'Drawings', icon: FileText },
{ href: '/rfas', label: 'RFAs', icon: FileText },
{ href: '/transmittals', label: 'Transmittals', icon: FileText },
{ href: '/reports', label: 'Reports', icon: LineChart },
];
// **3. สร้าง object สำหรับเมนู Admin โดยเฉพาะ**
const adminLink = {
href: '/admin/users',
label: 'Admin',
icon: Settings,
requiredPermission: 'manage_users'
};
return (
<html lang="th">
<body className="bg-slate-50">
{/* Header รวมทุกหน้า */}
<header className="flex items-center justify-between w-full px-4 py-3 text-white bg-sky-900">
<h1 className="font-bold">Document Management System</h1>
<div className="flex items-center gap-3">
{loggedIn ? (
<div className="text-sm">
สวสด, <b>{session.user.username}</b> ({session.user.role})
</div>
) : (
<div className="text-sm">งไมไดเขาสระบบ</div>
)}
{/* ปุ่ม Login/Logout */}
{loggedIn ? (
<form action={LogoutAction}>
<button
type="submit"
className="px-3 py-1.5 rounded-lg bg-white/10 hover:bg-white/20"
>
ออกจากระบบ
</button>
</form>
) : (
<Link
href="/login?next=/dashboard"
className="px-3 py-1.5 rounded-lg bg-white/10 hover:bg-white/20"
>
เขาสระบบ
</Link>
)}
<div className="grid min-h-screen w-full md:grid-cols-[220px_1fr] lg:grid-cols-[280px_1fr]">
<div className="hidden border-r bg-muted/40 md:block">
<div className="flex h-full max-h-screen flex-col gap-2">
<div className="flex h-14 items-center border-b px-4 lg:h-[60px] lg:px-6">
<Link href="/" className="flex items-center gap-2 font-semibold">
<Package2 className="h-6 w-6" />
<span className="">LCB P3 DMS</span>
</Link>
<Button variant="outline" size="icon" className="ml-auto h-8 w-8">
<Bell className="h-4 w-4" />
<span className="sr-only">Toggle notifications</span>
</Button>
</div>
</header>
<div className="flex-1">
<nav className="grid items-start px-2 text-sm font-medium lg:px-4">
{navLinks.map(({ href, label, icon: Icon }) => (
<Link
key={href}
href={href}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-muted-foreground transition-all hover:text-primary',
pathname.startsWith(href) && 'bg-muted text-primary'
)}
>
<Icon className="h-4 w-4" />
{label}
</Link>
))}
{/* ====== ส่วนที่แก้ไข: ตรวจสอบสิทธิ์ด้วย `can` ====== */}
{user && can(user, adminLink.requiredPermission) && (
<>
<div className="my-2 border-t"></div>
<Link
href={adminLink.href}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-muted-foreground transition-all hover:text-primary',
pathname.startsWith('/admin') && 'bg-muted text-primary'
)}
>
<adminLink.icon className="h-4 w-4" />
{adminLink.label}
</Link>
</>
)}
{/* ====== จบส่วนที่แก้ไข ====== */}
<main>{children}</main>
</body>
</html>
</nav>
</div>
<div className="mt-auto p-4">
<Card>
<CardHeader className="p-2 pt-0 md:p-4">
<CardTitle>Need Help?</CardTitle>
<CardDescription>
Contact support for any issues or questions.
</CardDescription>
</CardHeader>
<CardContent className="p-2 pt-0 md:p-4 md:pt-0">
<Button size="sm" className="w-full">
Contact
</Button>
</CardContent>
</Card>
</div>
</div>
</div>
<div className="flex flex-col">
<header className="flex h-14 items-center gap-4 border-b bg-muted/40 px-4 lg:h-[60px] lg:px-6">
{/* Mobile navigation can be added here */}
<div className="w-full flex-1">
{/* Optional: Add a search bar */}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary" size="icon" className="rounded-full">
<Users className="h-5 w-5" />
<span className="sr-only">Toggle user menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{user ? user.username : 'My Account'}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>Settings</DropdownMenuItem>
<DropdownMenuItem>Support</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={logout}>Logout</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</header>
<main className="flex flex-1 flex-col gap-4 p-4 lg:gap-6 lg:p-6">
{children}
</main>
</div>
</div>
);
}
}

View File

@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": false,
"tailwind": {
"config": "tailwind.config.js",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

View File

@@ -0,0 +1,99 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref} />
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props} />
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}) => (
<div
className={cn("flex flex-col space-y-2 text-center sm:text-left", className)}
{...props} />
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props} />
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold", className)} {...props} />
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props} />
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)}
{...props} />
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}>
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@@ -0,0 +1,96 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props} />
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}>
{children}
<DialogPrimitive.Close
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}) => (
<div
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
{...props} />
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props} />
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props} />
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props} />
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,40 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

38
frontend/lib/auth copy.js Executable file
View File

@@ -0,0 +1,38 @@
// frontend/lib/auth.js
import { cookies } from "next/headers";
const COOKIE_NAME = "access_token";
/**
* Server-side session fetcher (ใช้ใน Server Components/Layouts)
* - อ่านคุกกี้แบบ async: await cookies()
* - ถ้าไม่มี token → return null
* - ถ้ามี → เรียก /api/auth/me ที่ backend เพื่อตรวจสอบ
*/
export async function getSession() {
// ✅ ต้อง await
const cookieStore = await cookies();
const token = cookieStore.get(COOKIE_NAME)?.value;
if (!token) return null;
// เรียก backend ตรวจ session (ปรับ endpoint ให้ตรงของคุณ)
const res = await fetch(`${process.env.NEXT_PUBLIC_API_BASE}/api/auth/me`, {
// ส่งต่อคุกกี้ไป backend (เลือกอย่างใดอย่างหนึ่ง)
// วิธี A: ส่ง header Cookie โดยตรง
headers: { Cookie: `${COOKIE_NAME}=${token}` },
// วิธี B: ถ้า proxy ผ่าน nginx ในโดเมนเดียวกัน ใช้ credentials รวมคุกกี้อัตโนมัติได้
// credentials: "include",
cache: "no-store",
});
if (!res.ok) return null;
const data = await res.json();
// คาดหวังโครงสร้าง { user, permissions } จาก backend
return {
user: data.user,
permissions: data.permissions || [],
token,
};
}

View File

@@ -8,9 +8,13 @@
"name": "dms-frontend",
"version": "0.7.0",
"dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
@@ -950,12 +954,46 @@
"node": ">=14"
}
},
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
"integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==",
"license": "MIT"
},
"node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT"
},
"node_modules/@radix-ui/react-alert-dialog": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz",
"integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dialog": "1.1.15",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-arrow": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
@@ -979,6 +1017,36 @@
}
}
},
"node_modules/@radix-ui/react-checkbox": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz",
"integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collection": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
@@ -1035,6 +1103,42 @@
}
}
},
"node_modules/@radix-ui/react-dialog": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
"integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-direction": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
@@ -1385,6 +1489,37 @@
}
}
},
"node_modules/@radix-ui/react-scroll-area": {
"version": "1.2.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz",
"integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.1",
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",

View File

@@ -10,9 +10,13 @@
"format": "prettier --write ."
},
"dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",

View File

@@ -7,12 +7,12 @@ services:
user: "${UID:-1000}:${GID:-1000}"
command: >
sh -c "
echo '📦 Installing dependencies...' &&
echo 'ߓ栉nstalling dependencies...' &&
npm install &&
echo '🎨 Initializing shadcn/ui...' &&
echo 'ߎ蠉nitializing shadcn/ui...' &&
npx shadcn@latest init -y -d &&
echo '📥 Adding components...' &&
npx shadcn@latest add -y button label input card badge tabs progress dropdown-menu tooltip switch &&
echo 'ߓ堁dding components...' &&
npx shadcn@latest add -y alert-dialog dialog checkbox scroll-area button label input card badge tabs progress dropdown-menu tooltip switch &&
echo '✅ Done! Check components/ui/ directory'
"

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,8 +0,0 @@
1
/var/lib/postgresql/data
1759373409
5432
/var/run/postgresql
*
117512122 0
ready

View File

Binary file not shown.

View File

@@ -1,99 +0,0 @@
# ------------------------------------------------------------
# git.np-dms.work
# ------------------------------------------------------------
map $scheme $hsts_header {
https "max-age=63072000; preload";
}
server {
set $forward_scheme http;
set $server "gitea";
set $port 3000;
listen 80;
listen [::]:80;
listen 443 ssl;
listen [::]:443 ssl;
server_name git.np-dms.work;
http2 on;
# Let's Encrypt SSL
include conf.d/include/letsencrypt-acme-challenge.conf;
include conf.d/include/ssl-cache.conf;
include conf.d/include/ssl-ciphers.conf;
ssl_certificate /etc/letsencrypt/live/npm-10/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/npm-10/privkey.pem;
# Force SSL
include conf.d/include/force-ssl.conf;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $http_connection;
proxy_http_version 1.1;
access_log /data/logs/proxy-host-5_access.log proxy;
error_log /data/logs/proxy-host-5_error.log warn;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Authorization $http_authorization;
proxy_connect_timeout 30s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
send_timeout 300s;
client_max_body_size 512m;
location / {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $http_connection;
proxy_http_version 1.1;
# Proxy!
include conf.d/include/proxy.conf;
}
# Custom
include /data/nginx/custom/server_proxy[.]conf;
}

View File

@@ -1,5 +1,5 @@
# ------------------------------------------------------------
# pma.np-dms.work
# git.np-dms.work
# ------------------------------------------------------------
@@ -10,8 +10,8 @@ map $scheme $hsts_header {
server {
set $forward_scheme http;
set $server "phpmyadmin";
set $port 80;
set $server "gitea";
set $port 3000;
listen 80;
listen [::]:80;
@@ -20,16 +20,17 @@ listen 443 ssl;
listen [::]:443 ssl;
server_name pma.np-dms.work;
http2 off;
server_name git.np-dms.work;
http2 on;
# Let's Encrypt SSL
include conf.d/include/letsencrypt-acme-challenge.conf;
include conf.d/include/ssl-cache.conf;
include conf.d/include/ssl-ciphers.conf;
ssl_certificate /etc/letsencrypt/live/npm-11/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/npm-11/privkey.pem;
ssl_certificate /etc/letsencrypt/live/npm-10/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/npm-10/privkey.pem;
@@ -45,28 +46,31 @@ http2 off;
# Force SSL
include conf.d/include/force-ssl.conf;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $http_connection;
proxy_http_version 1.1;
access_log /data/logs/proxy-host-6_access.log proxy;
error_log /data/logs/proxy-host-6_error.log warn;
access_log /data/logs/proxy-host-5_access.log proxy;
error_log /data/logs/proxy-host-5_error.log warn;
client_max_body_size 128m;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Authorization $http_authorization;
proxy_buffering off;
proxy_connect_timeout 30s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
send_timeout 300s;
client_max_body_size 512m;
@@ -80,6 +84,8 @@ proxy_buffering off;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $http_connection;

View File

@@ -1 +0,0 @@
PMA_token |s:32:"6537407a672f376761784c234d79666a"; HMAC_secret |s:16:";_tF,Ps<jX+#Z=f>";errors|a:0:{}

View File

@@ -1 +0,0 @@
PMA_token |s:32:"34347471654523645a3b4a32556f6c2c"; HMAC_secret |s:16:"cxqQ@%S|9[H3Y6^%";errors|a:0:{}

View File

@@ -1 +0,0 @@
PMA_token |s:32:"263a746d69672f6a57305b5842603462"; HMAC_secret |s:16:".e@/Y$FkQ[hrnThJ";errors|a:0:{}

View File

@@ -1 +0,0 @@
PMA_token |s:32:"53754e49395b233f312a3e444e604c6c"; HMAC_secret |s:16:"'W%y<SKRJnU;}^!|";errors|a:0:{}

View File

@@ -1 +0,0 @@
PMA_token |s:32:"4156236d5e362168327e646d41463b24"; HMAC_secret |s:16:"0JzUDdkie;5^OD$x";errors|a:0:{}

View File

@@ -1 +0,0 @@
PMA_token |s:32:"397472635b712b6d2a40624244354f3f"; HMAC_secret |s:16:"MIW6@(E`(8hUZPvn";errors|a:0:{}

View File

@@ -1 +0,0 @@
PMA_token |s:32:"59692f53212c545c55273d71446d4a3a"; HMAC_secret |s:16:"-+x:]'T{Gq%(y'M)";errors|a:0:{}

View File

@@ -1 +0,0 @@
PMA_token |s:32:"4239422850276e464b7e426d556b5277"; HMAC_secret |s:16:"q]Ldh$];4JH,{.MX";errors|a:0:{}

View File

@@ -1 +0,0 @@
PMA_token |s:32:"24545877502f6d7d7967723b5e6c3a38"; HMAC_secret |s:16:"B%B#Rs(y)DGW?uv6";errors|a:0:{}

View File

@@ -1 +0,0 @@
PMA_token |s:32:"222b524d303848785047625636644350"; HMAC_secret |s:16:"r>gT.Yji&;>+(WBB";errors|a:0:{}

View File

@@ -1 +0,0 @@
PMA_token |s:32:"2f5e6c66684a7b414659303778513a25"; HMAC_secret |s:16:"32szCS_&Uq+0r$jk";errors|a:0:{}

View File

@@ -1 +0,0 @@
PMA_token |s:32:"637c736e2b30345b2f5e34516d2a5279"; HMAC_secret |s:16:"dwf#X!d2CIQn#30!";browser_access_time|a:0:{}encryption_key|s:32:"<22>S];W<>

Some files were not shown because too many files have changed in this diff Show More