34 Commits

Author SHA1 Message Date
fb26bb7b25 chore(git): ignore n8n-postgres/ and stop tracking it 2025-10-11 11:58:53 +07:00
c55f464f3c feat: Gen package-lock... 2025-10-11 10:30:47 +07:00
aa799edf2c chore(git): stop tracking top-level npm/ (NPM data) and ignore it 2025-10-11 10:13:53 +07:00
cc47c6f5f1 chore(git): stop tracking top-level npm/ (NPM data) and ignore it 2025-10-11 10:09:54 +07:00
admin
15145260f9 feat: backend rebuild 2025-10-11 09:24:40 +07:00
360ab1ac12 251011 start 2025-10-11 08:10:49 +07:00
admin
e58e164e54 update README.md 2025-10-10 16:42:32 +07:00
bbfbc5b910 fix: tailwind v4 postcss, auth-server session, eslint cleanups 2025-10-09 15:47:56 +07:00
670228b76e xxx 2025-10-05 11:57:43 +07:00
admin
754e494e7f fronted แก้ layout build 2 2025-10-05 11:05:03 +07:00
admin
5dec188744 fronted แก้ layout build dev&proc 2025-10-05 10:57:54 +07:00
admin
02e509986b fronted build dev&proc 2025-10-05 10:18:59 +07:00
admin
da568bb85f fronted build js-cookiep 2025-10-05 09:40:55 +07:00
admin
3448594bc5 Apply .gitignore cleanup 2025-10-05 09:21:04 +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
admin
33022c1840 251004 frontend backend 2 2025-10-04 10:58:25 +07:00
admin
a70ad11035 251004 frontend backend 2025-10-04 10:56:56 +07:00
admin
10150583cc 251003 frontend 2 2025-10-03 16:52:13 +07:00
admin
4d7e69247d 251003 frontend NPM 2025-10-03 16:14:09 +07:00
eeb7808e29 251003 gitignore 2025-10-03 08:21:28 +07:00
admin
03a8a3b864 2nd File: frontend/app/(auth)/login/page.jsx 2025-10-02 09:49:50 +07:00
admin
6fea909902 File: frontend/app/(auth)/login/page.jsx 2025-10-02 09:04:00 +07:00
admin
dd48a26196 251002 frontend/app/(auth)/login/page.jsx 2025-10-02 08:54:52 +07:00
admin
cb4146fa35 251002 frontend/app/(protected)/layout.jsx 2025-10-02 08:30:45 +07:00
admin
60880fb12e gitignore 2025-10-02 08:11:20 +07:00
d3339d75bf layout errer 2025-10-01 17:15:51 +07:00
admin
a1e9600ad5 ปรับ frontend/app/(protected)/layout.jsx 2025-10-01 15:44:57 +07:00
2421 changed files with 34158 additions and 1476242 deletions

0
.github/copilot-instructions.md vendored Executable file → Normal file
View File

13
.gitignore vendored Executable file → Normal file
View File

@@ -1,10 +1,16 @@
# ยกเว้นโฟลเดอร์ # ยกเว้นโฟลเดอร์
.devcontainer/ .devcontainer/
.qsync/
@Recently-Snapshot/ @Recently-Snapshot/
Documents/ Documents/
mariadb/data/ mariadb/data/
n8n*/
n8n-postgres/ n8n-postgres/
phpmyadmin/sessions/ npm/
phpmyadmin/
pgadmin/
.tmp.driveupload
.qsync
# ===================================================== # =====================================================
# IDE/Editor settings # IDE/Editor settings
# ===================================================== # =====================================================
@@ -87,4 +93,9 @@ docker-compose.override.*.yml
/backend/.cache/ /backend/.cache/
/frontend/.cache/ /frontend/.cache/
.tmp/ .tmp/
.tmp*.*/
.cache/ .cache/
# Ignore Nginx Proxy Manager data
/npm/
/n8n-postgres/

View File

@@ -1,4 +1,4 @@
[/dms] [/dms]
max_log = 496206 max_log = 510381
number = 3 number = 3
finish = 1 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

506
Architech.md Executable file
View File

@@ -0,0 +1,506 @@
# DMS Architecture Deep Dive (Backend + Frontend)
**Project:** Document Management System (DMS) — LCBP3
**Platform:** QNAP TS473A (Container Station)
**Last updated:** 20251007 (UTC+7)
---
## 0) TL;DR (Executive Summary)
* Reverse proxy (Nginx/NPM) เผยแพร่ Frontend (Next.js) และ Backend (Node.js/Express) ผ่าน HTTPS (HSTS)
* Backend เชื่อม MariaDB 10.11 (ข้อมูลหลัก DMS) และแยก n8n + Postgres 16 สำหรับ workflow
* RBAC/ABAC ถูกบังคับใช้งานใน middleware + มีชุด SQL (tables → triggers → procedures → views → seed)
* ไฟล์จริง (PDF/DWG) เก็บนอก webroot ที่ **/share/dmsdata** พร้อมมาตรฐานการตั้งชื่อ+โฟลเดอร์
* Dev/Prod แยกชัดเจนผ่าน Docker multistage + dockercompose + โฟลเดอร์ persist logs/config/certs
---
## 1) Runtime Topology & Trust Boundaries
```
Internet Clients (Browser)
│ HTTPS 443 (HSTS) [QNAP mgmt = 8443]
┌─────────────────────────────────────────────────────┐
│ Reverse Proxy Layer │
│ ├─ Nginx (Alpine) or Nginx Proxy Manager (NPM) │
│ ├─ TLS (LE cert; SAN multisubdomain) │
│ └─ Routes: │
│ • /, /_next/* → Frontend (Next.js :3000) │
│ • /api/* → Backend (Express :3001) │
│ • /pma/* → phpMyAdmin │
│ • /n8n/* → n8n (Workflows) │
└─────────────────────────────────────────────────────┘
│ │
│ └──────────┐
▼ │
Frontend (Next.js) │
│ Cookie-based Auth (HttpOnly) │
▼ ▼
Backend (Node/Express ESM) ─────────► MariaDB 10.11
│ │
└────────────────────────────────────┘
Project data (.pdf/.dwg) @ /share/dms-data
n8n (workflows) ──► Postgres 16 (separate DB for automations)
```
**Trust Boundaries**
* Public zone: Internet ↔ Reverse proxy
* App zone: Reverse proxy ↔ FE/BE containers (internal Docker network)
* Data zone: Backend ↔ Databases (MariaDB, Postgres) + `/share/dms-data`
---
## 2) Frontend Architecture (Next.js / React)
### 2.1 Stack & Key libs
* **Next.js (App Router)**, **React**, ESM
* **Tailwind CSS**, **PostCSS**, **shadcn/ui** (components.json)
* Fetch API (credentials include) → Cookie Auth (HttpOnly)
### 2.2 Directory Layout
```
/frontend/
├─ app/
│ ├─ login/
│ ├─ dashboard/
│ ├─ users/
│ ├─ correspondences/
│ ├─ health/
│ └─ layout.tsx / page.tsx (ตาม App Router)
├─ public/
├─ Dockerfile (multi-stage: dev/prod)
├─ package.json
├─ next.config.js
└─ ...
```
### 2.3 Routing & Layouts
* **Public**: `/login`, `/health`
* **Protected**: `/dashboard`, `/users`, `/correspondences`, ... (client-side guard)
* เก็บ **middleware.ts (ของเดิม)** เพื่อหลีกเลี่ยง regression; ใช้ clientguard + server action อย่างระมัดระวัง
### 2.4 Auth Flow (Cookie-based)
1. ผู้ใช้ submit form `/login``POST /api/auth/login` (Backend)
2. Backend set **HttpOnly** cookie (JWT) + `SameSite=Lax/Strict`, `Secure`
3. หน้า protected เรียก `GET /api/auth/me` เพื่อตรวจสอบสถานะ
4. หาก 401 → redirect → `/login`
> **CORS/Fetch**: เปิด `credentials: 'include'` ทุกครั้ง, ตั้ง `NEXT_PUBLIC_API_BASE` เป็น origin ของ backend ผ่าน proxy (เช่น `https://lcbp3.np-dms.work`)
### 2.5 UI/UX
* Seablue palette, sidebar พับได้, cardbased KPI
* ตารางข้อมูลเตรียมรองรับ **serverside DataTables**
* shadcn/ui: Button, Card, Badge, Tabs, Dropdown, Tooltip, Switch, etc.
### 2.6 Config & ENV
* `NEXT_PUBLIC_API_BASE` (ex: `https://lcbp3.np-dms.work`)
* Build output แยก dev/prod; ระวัง EACCES บน QNAP → ใช้ user `node` + ปรับสิทธิ์โวลุ่ม `.next/*`
### 2.7 Error Handling & Observability (FE)
* Global error boundary (app router) + toast/alert patterns
* Network layer: แยก handler สำหรับ 401/403/500 + retry/backoff ที่จำเป็น
* Metrics (optional): webvitals, UX timing (เก็บฝั่ง n8n หรือ simple logging)
---
## 3) Backend Architecture (Node.js ESM / Express)
### 3.1 Stack & Structure
* Node 20.x, **ESM** modules, **Express**
* `mysql2/promise`, `jsonwebtoken`, `cookie-parser`, `cors`, `helmet`, `winston/morgan`
```tree
/backend/
├─ src/
│ ├─ index.js # bootstrap server, CORS, cookies, health
│ ├─ routes/
│ │ ├─ auth.js # /api/auth/* (login, me, logout)
│ │ ├─ users.js # /api/users/*
│ │ ├─ correspondences.js # /api/correspondences/*
│ │ ├─ drawings.js # /api/drawings/*
│ │ ├─ rfas.js # /api/rfas/*
│ │ └─ transmittals.js # /api/transmittals/*
│ ├─ middleware/
│ │ ├─ authGuard.js # verify JWT from cookie
│ │ ├─ requirePermission.js# RBAC/ABAC enforcement
│ │ ├─ errorHandler.js
│ │ └─ requestLogger.js
│ ├─ db/
│ │ ├─ pool.js # createPool, sane defaults
│ │ └─ models/ # query builders (User, Drawing, ...)
│ ├─ utils/
│ │ ├─ hash.js (bcrypt/argon2)
│ │ ├─ jwt.js
│ │ ├─ pagination.js
│ │ └─ responses.js
│ └─ config/
│ └─ index.js # env, constants
├─ Dockerfile
└─ package.json
```
### 3.2 Request Lifecycle
1. `helmet` + `cors` (allow specific origin; credentials true)
2. `cookie-parser`, `json limit` (e.g., 2MB)
3. `requestLogger` → trace + response time
4. Route handler → `authGuard` (protected) → `requirePermission` (perroute) → Controller
5. Error bubbles → `errorHandler` (JSON shape, status map)
### 3.3 Auth & RBAC/ABAC
* **JWT** ใน HttpOnly cookie; Claims: `sub` (user_id), `roles`, `exp`
* **authGuard**: ตรวจ token → แนบ `req.user`
* **requirePermission**: เช็ค permission ตามเส้นทาง/วิธี; แผนขยาย ABAC (เช่น project scope, owner, doc state)
* Roles/Permissions ถูก seed ใน SQL; มี **view เมทริกซ์** เพื่อ debug (เช่น `v_role_permission_matrix`)
**ตัวอย่าง pseudo** `requirePermission(permission)`
```js
export const requirePermission = (perm) => async (req, res, next) => {
if (!req.user) return res.status(401).json({ error: 'Unauthenticated' });
const ok = await checkPermission(req.user.user_id, perm, req.context);
if (!ok) return res.status(403).json({ error: 'Forbidden' });
return next();
};
```
### 3.4 Database Access & Pooling
* `createPool({ connectionLimit: 10~25, queueLimit: 0, waitForConnections: true })`
* ใช้ parameterized queries เสมอ; ปรับ `sql_mode` ที่จำเป็นใน `my.cnf`
### 3.5 File Storage & Secure Download
* Root: **/share/dmsdata**
* โครงโฟลเดอร์: `{module}/{yyyy}/{mm}/{entityId}/` + ชื่อไฟล์ตามมาตรฐาน (เช่น `DRW-<code>-REV-<rev>.pdf`)
* Endpoint download: ตรวจสิทธิ์ (RBAC/ABAC) → `res.sendFile()`/stream; ป้องกัน path traversal
* MIME allowlist + size limit + virus scan (optional; ภายหลัง)
### 3.6 Health & Readiness
* `GET /api/health``{ ok: true }`
* (optional) `/api/ready` ตรวจ DB ping + disk space (dmsdata)
### 3.7 Config & ENV (BE)
* `DB_HOST, DB_PORT, DB_USER, DB_PASS, DB_NAME`
* `JWT_SECRET, COOKIE_NAME, COOKIE_SAMESITE, COOKIE_SECURE`
* `CORS_ORIGIN, LOG_LEVEL, APP_BASE_URL`
* `FILE_ROOT=/share/dms-data`
### 3.8 Logging
* Access log (morgan) + App log (winston) → `/share/Container/dms/logs/backend/`
* รูปแบบ JSON (timestamp, level, msg, reqId) + daily rotation (logrotate/containerside)
---
## 4) Database (MariaDB 10.11)
### 4.1 Schema Overview (ย่อ)
* **RBAC core**: `users`, `roles`, `permissions`, `user_roles`, `role_permissions`
* **Domain**: `drawings`, `contracts`, `correspondences`, `rfas`, `transmittals`, `organizations`, `projects`, ...
* **Audit**: `audit_logs` (แผนขยาย), `deleted_at` (soft delete, แผนงาน)
```
[users]──<user_roles>──[roles]──<role_permissions>──[permissions]
└── activities/audit_logs (future expansion)
[drawings]──<mapping>──[contracts]
[rfas]──<links>──[drawings]
[correspondences] (internal/external flag)
```
### 4.2 Init SQL Pipeline
1. `01_*_deploy_table_rbac.sql` — สร้างตารางหลักทั้งหมด + RBAC
2. `02_*_triggers.sql` — บังคับ data rules, autoaudit fields
3. `03_*_procedures_handlers.sql` — upsert/bulk handlers (เช่น `sp_bulk_import_contract_dwg`)
4. `04_*_views.sql` — รายงาน/เมทริกซ์สิทธิ์ (`v_role_permission_matrix`, etc.)
5. `05_*_seed_data.sql` — ค่าพื้นฐาน domain (project, categories, statuses)
6. `06_*_seed_users.sql` — บัญชีเริ่มต้น (superadmin, editors, viewers)
7. `07_*_seed_contract_dwg.sql` — ข้อมูลตัวอย่างแบบสัญญา
### 4.3 Indexing & Performance
* Composite indexes ตามคอลัมน์ filter/sort (เช่น `(project_id, updated_at DESC)`)
* Fulltext index (optional) สำหรับ advanced search
* Query plan review (EXPLAIN) + เพิ่ม covering index ตามรายงาน
### 4.4 MySQL/MariaDB Config (my.cnf — แนวทาง)
```
[mysqld]
innodb_buffer_pool_size = 4G # ปรับตาม RAM/QNAP
innodb_log_file_size = 512M
innodb_flush_log_at_trx_commit = 1
max_connections = 200
sql_mode = STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci
```
> ปรับค่าให้เหมาะกับ workload จริง + เฝ้าดู IO/CPU ของ QNAP
### 4.5 Backup/Restore
* Logical backup: `mysqldump --routines --triggers --single-transaction`
* Physical (snapshot QNAP) + schedule ผ่าน n8n/cron
* เก็บสำเนา offNAS (encrypted)
---
## 5) Reverse Proxy & TLS
### 5.1 Nginx (Alpine) — ตัวอย่าง server block
> **สำคัญ:** บนสภาพแวดล้อมนี้ ให้ใช้คนละบรรทัด:
> `listen 443 ssl;`
> `http2 on;`
> หลีกเลี่ยง `listen 443 ssl http2;`
```nginx
server {
listen 80;
server_name lcbp3.np-dms.work;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name lcbp3.np-dms.work;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
add_header Strict-Transport-Security "max-age=63072000; preload" always;
# Frontend
location / {
proxy_pass http://frontend:3000;
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;
}
# Next.js static
location /_next/ {
proxy_pass http://frontend:3000;
}
# Backend API
location /api/ {
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_pass http://backend:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
# phpMyAdmin (sub-path)
location /pma/ {
proxy_pass http://phpmyadmin:80/;
}
# n8n
location /n8n/ {
proxy_pass http://n8n:5678/;
}
}
```
### 5.2 Nginx Proxy Manager (NPM) — Tips
* ระวังอย่าใส่ `proxy_http_version` ซ้ำซ้อน (duplicate directive) ใน Advanced
* ถ้าต้องแก้ไฟล์ด้านใน NPM → ระวังไฟล์ใน `/data/nginx/proxy_host/*.conf`
* จัดการ certificate / SAN หลาย subdomain ใน UI แต่ mainten ดีเรื่อง symlink/renew
### 5.3 TLS & Certificates
* Lets Encrypt (HTTP01 webroot/standalone) + HSTS
* QNAP mgmt เปลี่ยนเป็น 8443 → พอร์ต 443 public ว่างสำหรับ Nginx/NPM
---
## 6) Docker Compose Topology
### 6.1 Services (สรุป)
* `frontend` (Next.js) :3000
* `backend` (Express) :3001
* `mariadb` (10.11) :3306 (internal)
* `phpmyadmin` :80 (internal)
* `nginx` or `npm` :80/443 (published)
* `n8n` :5678 (internal)
* `postgres_n8n` (16-alpine)
* `pgadmin4`
### 6.2 Volumes & Paths
```
/share/Container/dms/
├─ mariadb/data
├─ mariadb/init/*.sql
├─ backend/ (code)
├─ frontend/ (code)
├─ phpmyadmin/{sessions,tmp,config.user.inc.php}
├─ nginx/{nginx.conf,dms.conf,certs/}
├─ n8n, n8n-postgres, n8n-cache
└─ logs/{backend,frontend,nginx,pgadmin,phpmyadmin,postgres_n8n}
/share/dms-data (pdf/dwg storage)
```
### 6.3 Healthchecks (suggested)
* **backend**: curl `http://localhost:3001/api/health`
* **frontend**: curl `/health` (simple JSON)
* **mariadb**: `mysqladmin ping` with credentials
* **nginx**: `nginx -t` at startup
### 6.4 Security Hardening
* รัน container ด้วย user nonroot (`user: node` สำหรับ FE/BE)
* จำกัด capabilities; readonly FS (ยกเว้นโวลุ่มจำเป็น)
* เฉพาะ backend เมานต์ `/share/dms-data`
---
## 7) Observability, Ops, and Troubleshooting
### 7.1 Logs
* Frontend → `/logs/frontend/*`
* Backend → `/logs/backend/*` (app/access/error)
* Nginx/NPM → `/logs/nginx/*`
* MariaDB → default datadir log + slow query (เปิดใน my.cnf หากต้องการ)
### 7.2 Common Issues & Playbooks
* **401 Unauthenticated**: ตรวจ `authGuard` → JWT cookie มี/หมดอายุ → เวลา server/FE sync → CORS `credentials: true`
* **EACCES Next.js**: สิทธิ์ `.next/*` + run as `node`, โวลุ่ม map ถูก user:group
* **NPM duplicate directive**: ลบซ้ำ `proxy_http_version` ใน Advanced / ตรวจ `proxy_host/*.conf`
* **LE cert path/symlink**: ตรวจ `/etc/letsencrypt/live/npm-*` symlink ชี้ถูก
* **DB field not found**: ตรวจ schema vs code (migration/init SQL) → sync ให้ตรง
### 7.3 Performance Guides
* **Backend**: keepalive, gzip/deflate at proxy, pool 1025, paginate, avoid N+1
* **Frontend**: prefetch critical routes, cache static, image optimization
* **DB**: เพิ่ม index จุด filter, analyze query (EXPLAIN), ปรับ buffer pool
---
## 8) Security & Compliance
* **HTTPS only** + HSTS (preload)
* **CORS**: allow list เฉพาะ FE origin; `Access-Control-Allow-Credentials: true`
* **Cookie**: HttpOnly, Secure, SameSite=Lax/Strict
* **Input Validation**: celebrate/zod (optional) + sanitize
* **Rate limiting**: per IP/route (optional)
* **AuditLog**: วางแผนเพิ่ม ครอบคลุม CRUD + mapping (actor, action, entity, before/after)
* **Backups**: DB + `/share/dms-data` + config (encrypted offNAS)
---
## 9) Backlog → Architecture Mapping
1. **RBAC Enforcement ครบ** → เติม `requirePermission` ทุก route + test matrix ผ่าน view
2. **AuditLog ครบ CRUD/Mapping** → trigger + table `audit_logs` + BE hook
3. **Upload/Download จริงของ Drawing Revisions** → BE endpoints + virus scan (optional)
4. **Dashboard KPI** → BE summary endpoints + FE cards/charts
5. **Serverside DataTables** → paging/sort/filter + indexesรองรับ
6. **รายงาน Export CSV/Excel/PDF** → BE export endpoints + FE buttons
7. **Soft delete** (`deleted_at`) → BE filter default scope + restore endpoint
8. **Validation เข้ม** → celebrate/zod schema + consistent error shape
9. **Indexing/Perf** → slow query log + EXPLAIN review
10. **Job/Cron Deadline Alerts** → n8n schedule + SMTP
---
## 10) Port & ENV Matrix (Quick Ref)
| Component | Ports | Key ENV |
| --------- | --------------- | ------------------------------------------------ |
| Nginx/NPM | 80/443 (public) | SSL paths, HSTS |
| Frontend | 3000 (internal) | `NEXT_PUBLIC_API_BASE` |
| Backend | 3001 (internal) | `DB_*`, `JWT_SECRET`, `CORS_ORIGIN`, `FILE_ROOT` |
| MariaDB | 3306 (internal) | `MY_CNF`, credentials |
| n8n | 5678 (internal) | `N8N_*`, webhook URL under `/n8n/` |
| Postgres | 5432 (internal) | n8n DB |
**QNAP mgmt**: 8443 (already moved)
---
## 11) Sample Snippets
### 11.1 Backend CORS (credentials)
```js
app.use(cors({
origin: ['https://lcbp3.np-dms.work'],
credentials: true,
}));
```
### 11.2 Secure Download (guarded)
```js
router.get('/files/:module/:id/:filename', authGuard, requirePermission('file.read'), async (req, res) => {
const { module, id, filename } = req.params;
// 1) ABAC: verify user can access this module/entity
const ok = await canReadFile(req.user.user_id, module, id);
if (!ok) return res.status(403).json({ error: 'Forbidden' });
const abs = path.join(FILE_ROOT, module, id, filename);
if (!abs.startsWith(FILE_ROOT)) return res.status(400).json({ error: 'Bad path' });
return res.sendFile(abs);
});
```
### 11.3 Healthcheck
```js
router.get('/health', (req, res) => res.json({ ok: true }));
```
---
## 12) Deployment Workflow (Suggested)
1. Git (Gitea) branch strategy `feature/*` → PR → main
2. Build images (dev/prod) via Dockerfile multistage; pin Node/MariaDB versions
3. `docker compose up -d --build` จาก `/share/Container/dms`
4. Validate: `/health`, `/api/health`, login roundtrip
5. Monitor logs + baseline perf; run SQL smoke tests (views/triggers/procs)
---
## 13) Appendix
* **Naming conventions**: snake_case DB, camelCase JS
* **Timezones**: store UTC in DB; display in app TZ (+07:00)
* **Character set**: UTF8 (`utf8mb4_unicode_ci`)
* **Large file policy**: size limit (e.g., 50200MB), allowlist extensions
* **Retention**: archive strategy for old revisions (optional)
---
> หากต้องการ เวอร์ชัน **README.md พร้อมโค้ดตัวอย่าง compose/nginx** จัดรูปแบบให้นำไปวางใน repo ได้ทันที แจ้งได้เลยว่าจะให้แตกไฟล์เป็น `/docs/Architecture.md` + `/nginx/dms.conf` + `/docker-compose.yml` template หรือรูปแบบอื่นที่สะดวกต่อการใช้งานของทีม

View File

@@ -1,483 +0,0 @@
diff --git a/backend/src/middleware/requireBearer.js b/backend/src/middleware/requireBearer.js
new file mode 100644
index 0000000..1111111
--- /dev/null
+++ b/backend/src/middleware/requireBearer.js
@@ -0,0 +1,44 @@
+// backend/src/middleware/requireBearer.js
+import jwt from "jsonwebtoken";
+import { findUserById } from "../db/models/users.js";
+
+export async function requireBearer(req, res, next) {
+ const hdr = req.get("Authorization") || "";
+ const m = hdr.match(/^Bearer\s+(.+)$/i);
+ if (!m) return res.status(401).json({ error: "Unauthenticated" });
+ try {
+ const payload = jwt.verify(m[1], process.env.JWT_ACCESS_SECRET, {
+ issuer: "dms-backend",
+ });
+ const user = await findUserById(payload.user_id);
+ if (!user) return res.status(401).json({ error: "Unauthenticated" });
+ req.user = {
+ user_id: user.user_id,
+ username: user.username,
+ email: user.email,
+ first_name: user.first_name,
+ last_name: user.last_name,
+ };
+ next();
+ } catch {
+ return res.status(401).json({ error: "Unauthenticated" });
+ }
+}
diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js
index 2222222..3333333 100644
--- a/backend/src/routes/auth.js
+++ b/backend/src/routes/auth.js
@@ -1,99 +1,109 @@
-// (เดิม) ผูกกับคุกกี้ / ส่ง ok:true ฯลฯ
+// backend/src/routes/auth.js — Bearer Token ล้วน
import { Router } from "express";
import jwt from "jsonwebtoken";
-import { findUserByUsername } from "../db/models/users.js";
+import { findUserByUsername, findUserById } from "../db/models/users.js";
import { verifyPassword } from "../utils/passwords.js";
-// NOTE: ลบการใช้งาน res.cookie(...) ทั้งหมด
+// NOTE: ไม่มีการใช้ res.cookie(...) อีกต่อไป
const router = Router();
function signAccessToken(user) {
return jwt.sign(
{ user_id: user.user_id, username: user.username },
process.env.JWT_ACCESS_SECRET,
- { issuer: "dms-backend", expiresIn: "30m" } // ปรับได้
+ { issuer: "dms-backend", expiresIn: "30m" }
);
}
function signRefreshToken(user) {
return jwt.sign(
- { user_id: user.user_id, username: user.username },
+ { user_id: user.user_id, username: user.username, t: "refresh" },
process.env.JWT_REFRESH_SECRET,
{ issuer: "dms-backend", expiresIn: "30d" }
);
}
router.post("/login", async (req, res) => {
const { username, password } = req.body || {};
const user = await findUserByUsername(username);
if (!user || !(await verifyPassword(password, user.password_hash))) {
return res.status(401).json({ error: "INVALID_CREDENTIALS" });
}
const token = signAccessToken(user);
const refresh_token = signRefreshToken(user);
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,
},
});
});
+router.post("/refresh", async (req, res) => {
+ const hdr = req.get("Authorization") || "";
+ const m = hdr.match(/^Bearer\s+(.+)$/i);
+ const r = m?.[1];
+ if (!r) return res.status(401).json({ error: "NO_REFRESH_TOKEN" });
+ try {
+ const payload = jwt.verify(r, process.env.JWT_REFRESH_SECRET, {
+ issuer: "dms-backend",
+ });
+ const user = await findUserById(payload.user_id);
+ if (!user) return res.status(401).json({ error: "USER_NOT_FOUND" });
+ const token = signAccessToken(user);
+ return res.json({ token });
+ } catch {
+ return res.status(401).json({ error: "INVALID_REFRESH_TOKEN" });
+ }
+});
+
export default router;
diff --git a/backend/src/index.js b/backend/src/index.js
index 4444444..5555555 100644
--- a/backend/src/index.js
+++ b/backend/src/index.js
@@ -1,60 +1,69 @@
import express from "express";
import cors from "cors";
import authRouter from "./routes/auth.js";
+import { requireBearer } from "./middleware/requireBearer.js";
-// import routers อื่น ๆ ตามจริง เช่น rfasRouter, transmittalsRouter
const app = express();
-// CORS เดิม (อาจมี credentials)
-app.use(cors({
- origin: true,
- credentials: true,
-}));
+// ✅ CORS สำหรับ Bearer: ไม่ต้อง credentials, อนุญาต Authorization header
+app.use(cors({
+ origin: [
+ "https://lcbp3.np-dms.work",
+ "http://localhost:3000"
+ ],
+ methods: ["GET","POST","PUT","PATCH","DELETE","OPTIONS"],
+ allowedHeaders: ["Authorization","Content-Type","Accept","Origin","Referer","User-Agent","X-Requested-With","Cache-Control","Pragma"],
+ exposedHeaders: ["Content-Disposition","Content-Length"]
+}));
app.use(express.json());
-// routes เดิม
-app.use("/api/auth", authRouter);
-// app.use("/api/rfas", rfasRouter);
-// app.use("/api/transmittals", transmittalsRouter);
+// ✅ เส้นทาง auth (ไม่ต้องมี token)
+app.use("/api/auth", authRouter);
+
+// ✅ ตั้ง guard สำหรับเส้นทางที่เหลือต้องล็อกอิน
+app.use("/api", requireBearer);
+// แล้วค่อย mount routers protected ใต้ /api
+// app.use("/api/rfas", rfasRouter);
+// app.use("/api/transmittals", transmittalsRouter);
app.use((err, _req, res, _next) => {
console.error(err);
res.status(500).json({ error: "INTERNAL_SERVER_ERROR" });
});
const port = process.env.PORT || 4000;
app.listen(port, () => console.log(`backend listening on :${port}`));
diff --git a/frontend/app/(auth)/login/page.jsx b/frontend/app/(auth)/login/page.jsx
index 6666666..7777777 100644
--- a/frontend/app/(auth)/login/page.jsx
+++ b/frontend/app/(auth)/login/page.jsx
@@ -1,200 +1,236 @@
// File: frontend/app/(auth)/login/page.jsx
"use client";
-// เวอร์ชันเดิม
+// ✅ Bearer-only + Debug toggle (NEXT_PUBLIC_DEBUG_AUTH)
import { useState, useMemo, Suspense } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import {
Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription } from "@/components/ui/alert";
-const API_BASE = process.env.NEXT_PUBLIC_API_BASE?.replace(/\/$/, "") || "";
+const API_BASE = process.env.NEXT_PUBLIC_API_BASE?.replace(/\/$/, "") || "";
+const DEBUG =
+ String(process.env.NEXT_PUBLIC_DEBUG_AUTH || "").trim() !== "" &&
+ process.env.NEXT_PUBLIC_DEBUG_AUTH !== "0" &&
+ process.env.NEXT_PUBLIC_DEBUG_AUTH !== "false";
+function dlog(...args) {
+ if (DEBUG && typeof window !== "undefined") console.debug("[login]", ...args);
+}
function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const nextPath = useMemo(
() => searchParams.get("next") || "/dashboard",
[searchParams]
);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [showPw, setShowPw] = useState(false);
const [remember, setRemember] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [err, setErr] = useState("");
async function onSubmit(e) {
e.preventDefault();
setErr("");
if (!username.trim() || !password) {
setErr("กรอกชื่อผู้ใช้และรหัสผ่านให้ครบ");
return;
}
try {
setSubmitting(true);
+ dlog("API_BASE =", API_BASE || "(empty → relative)");
+ dlog("nextPath =", nextPath, "remember =", remember);
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
cache: "no-store",
});
- const data = await res.json().catch(() => ({}));
+ dlog("response.status =", res.status);
+ dlog("response.headers.content-type =", res.headers.get("content-type"));
+ let data = {};
+ try { data = await res.json(); } catch (e) { dlog("response.json() error =", e); }
+ dlog("response.body =", data);
if (!res.ok) {
- setErr(data?.error || "เข้าสู่ระบบไม่สำเร็จ");
+ const msg =
+ data?.error === "INVALID_CREDENTIALS"
+ ? "ชื่อผู้ใช้หรือรหัสผ่านไม่ถูกต้อง"
+ : data?.error || `เข้าสู่ระบบไม่สำเร็จ (HTTP ${res.status})`;
+ dlog("login FAILED →", msg);
+ setErr(msg);
return;
}
+ if (!data?.token) {
+ dlog("login FAILED → data.token not found");
+ setErr("รูปแบบข้อมูลตอบกลับไม่ถูกต้อง (ไม่มี token)");
+ return;
+ }
const storage = remember ? window.localStorage : window.sessionStorage;
storage.setItem("dms.token", data.token);
storage.setItem("dms.refresh_token", data.refresh_token);
storage.setItem("dms.user", JSON.stringify(data.user || {}));
+ dlog("token stored in", remember ? "localStorage" : "sessionStorage");
try {
window.dispatchEvent(
new StorageEvent("storage", { key: "dms.auth", newValue: "login" })
);
} catch {}
- router.replace(nextPath);
+ dlog("navigating →", nextPath);
+ router.replace(nextPath);
} catch (e) {
+ dlog("exception =", e);
setErr("เชื่อมต่อเซิร์ฟเวอร์ไม่ได้ กรุณาลองใหม่");
} finally {
setSubmitting(false);
+ dlog("done");
}
}
return (
<div className="grid min-h-[calc(100vh-4rem)] place-items-center p-4">
<Card className="w-full max-w-md border-0 shadow-xl ring-1 ring-black/5 bg-white/90 backdrop-blur">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold text-sky-800">เข้าสู่ระบบ</CardTitle>
<CardDescription className="text-sky-700">Document Management System • LCBP3</CardDescription>
</CardHeader>
<CardContent>
{err ? (
<Alert className="mb-4"><AlertDescription>{err}</AlertDescription></Alert>
) : null}
<form onSubmit={onSubmit} className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="username">ชื่อผู้ใช้</Label>
<Input id="username" autoFocus autoComplete="username" value={username}
onChange={(e) => setUsername(e.target.value)} placeholder="เช่น superadmin" disabled={submitting}/>
</div>
<div className="grid gap-2">
<Label htmlFor="password">รหัสผ่าน</Label>
<div className="relative">
<Input id="password" type={showPw ? "text" : "password"} autoComplete="current-password"
value={password} onChange={(e) => setPassword(e.target.value)} placeholder="••••••••"
disabled={submitting} className="pr-10"/>
<button type="button" onClick={() => setShowPw((v) => !v)}
className="absolute inset-y-0 px-2 my-auto text-xs bg-white border rounded-md right-2 hover:bg-slate-50"
aria-label={showPw ? "ซ่อนรหัสผ่าน" : "แสดงรหัสผ่าน"} disabled={submitting}>
{showPw ? "Hide" : "Show"}
</button>
</div>
</div>
<div className="flex items-center justify-between pt-1">
<label className="inline-flex items-center gap-2 text-sm text-slate-600">
<input type="checkbox" className="size-4 accent-sky-700"
checked={remember} onChange={(e) => setRemember(e.target.checked)} disabled={submitting}/>
จดจำฉันไว้ในเครื่องนี้
</label>
<a href="/forgot-password" className="text-sm text-sky-700 hover:text-sky-900 hover:underline">ลืมรหัสผ่าน?</a>
</div>
<Button type="submit" disabled={submitting} className="mt-2 bg-sky-700 hover:bg-sky-800">
{submitting ? (<span className="inline-flex items-center gap-2"><Spinner /> กำลังเข้าสู่ระบบ…</span>) : ("เข้าสู่ระบบ")}
</Button>
+ {DEBUG ? (
+ <p className="mt-2 text-xs text-slate-500">
+ DEBUG: NEXT_PUBLIC_API_BASE = <code>{API_BASE || "(empty)"}</code>
+ </p>
+ ) : null}
</form>
</CardContent>
<CardFooter className="text-xs text-center text-slate-500">
&copy; {new Date().getFullYear()} np-dms.work
</CardFooter>
</Card>
</div>
);
}
export default function LoginPage() {
return (
<Suspense fallback={<LoginPageSkeleton />}>
<LoginForm />
</Suspense>
);
}
function LoginPageSkeleton() {
return (
<div className="grid min-h-[calc(100vh-4rem)] place-items-center p-4">
<Card className="w-full max-w-md border-0 shadow-xl ring-1 ring-black/5 bg-white/90 backdrop-blur">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold text-sky-800">เข้าสู่ระบบ</CardTitle>
<CardDescription className="text-sky-700">Document Management System • LCBP3</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 animate-pulse">
<div className="h-10 rounded bg-slate-200"></div>
<div className="h-10 rounded bg-slate-200"></div>
<div className="h-10 rounded bg-slate-200"></div>
</div>
</CardContent>
</Card>
</div>
);
}
function Spinner() {
return (
<svg className="animate-spin size-4" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
);
}
diff --git a/frontend/app/(protected)/layout.jsx b/frontend/app/(protected)/layout.jsx
new file mode 100644
index 0000000..8888888
--- /dev/null
+++ b/frontend/app/(protected)/layout.jsx
@@ -0,0 +1,38 @@
+"use client";
+import { useEffect, useState } from "react";
+import { usePathname, useRouter } from "next/navigation";
+
+export default function ProtectedLayout({ children }) {
+ const router = useRouter();
+ const pathname = usePathname();
+ const [ready, setReady] = useState(false);
+
+ useEffect(() => {
+ try {
+ const token =
+ (typeof window !== "undefined" &&
+ (localStorage.getItem("dms.token") ||
+ sessionStorage.getItem("dms.token"))) ||
+ null;
+ if (!token) {
+ const next = encodeURIComponent(pathname || "/dashboard");
+ router.replace(`/login?next=${next}`);
+ return;
+ }
+ } finally {
+ setReady(true);
+ }
+ }, [pathname, router]);
+
+ if (!ready) {
+ return (
+ <div className="grid min-h-[calc(100vh-4rem)] place-items-center p-6 text-slate-600">
+ กำลังตรวจสิทธิ์…
+ </div>
+ );
+ }
+ return <>{children}</>;
+}
diff --git a/frontend/lib/api.js b/frontend/lib/api.js
new file mode 100644
index 0000000..9999999
--- /dev/null
+++ b/frontend/lib/api.js
@@ -0,0 +1,45 @@
+// frontend/lib/api.js
+const API_BASE = process.env.NEXT_PUBLIC_API_BASE?.replace(/\/$/, "") || "";
+
+function getToken() {
+ if (typeof window === "undefined") return null;
+ return localStorage.getItem("dms.token") || sessionStorage.getItem("dms.token");
+}
+
+export async function apiFetch(path, options = {}) {
+ const token = getToken();
+ const headers = new Headers(options.headers || {});
+ headers.set("Accept", "application/json");
+ if (!headers.has("Content-Type")) headers.set("Content-Type", "application/json");
+ if (token) headers.set("Authorization", `Bearer ${token}`);
+
+ const res = await fetch(`${API_BASE}${path}`, { ...options, headers, cache: "no-store" });
+
+ if (res.status === 401) {
+ const refresh =
+ localStorage.getItem("dms.refresh_token") || sessionStorage.getItem("dms.refresh_token");
+ if (refresh) {
+ const r = await fetch(`${API_BASE}/api/auth/refresh`, {
+ method: "POST",
+ headers: { Authorization: `Bearer ${refresh}` },
+ });
+ if (r.ok) {
+ const { token: newToken } = await r.json();
+ const store = localStorage.getItem("dms.refresh_token") ? localStorage : sessionStorage;
+ store.setItem("dms.token", newToken);
+ const headers2 = new Headers(headers);
+ headers2.set("Authorization", `Bearer ${newToken}`);
+ return fetch(`${API_BASE}${path}`, { ...options, headers: headers2, cache: "no-store" });
+ }
+ }
+ }
+ return res;
+}
diff --git a/frontend/middleware.ts b/frontend/middleware.ts
index aaaaaaa..bbbbbbb 100644
--- a/frontend/middleware.ts
+++ b/frontend/middleware.ts
@@ -1,15 +1,14 @@
-import { NextResponse } from "next/server";
-import type { NextRequest } from "next/server";
-
-// เดิม: ตรวจคุกกี้แล้ว redirect /dashboard
-export function middleware(req: NextRequest) {
- // ... logic เดิมที่ใช้คุกกี้
- return NextResponse.next();
-}
-
-export const config = {
- matcher: ["/(protected/:path*)","/dashboard","/users/:path*","/api/:path*"],
-};
+import { NextResponse } from "next/server";
+// ✅ ไม่บล็อกเพจอีกต่อไป (Bearer อยู่ใน storage ฝั่ง client)
+export function middleware() {
+ return NextResponse.next();
+}
+// จำกัดให้ทำงานเฉพาะ /api ถ้าต้องการใช้ในอนาคต (ตอนนี้ผ่านเฉย ๆ)
+export const config = { matcher: ["/api/:path*"] };
diff --git a/frontend/app/(protected)/dashboard/page.jsx b/frontend/app/(protected)/dashboard/page.jsx
new file mode 100644
index 0000000..ccccccc
--- /dev/null
+++ b/frontend/app/(protected)/dashboard/page.jsx
@@ -0,0 +1,11 @@
+"use client";
+export default function DashboardPage() {
+ return (
+ <main className="p-6">
+ <h1 className="text-2xl font-semibold text-sky-800">Dashboard</h1>
+ <p className="text-slate-600 mt-2">
+ ยินดีต้อนรับสู่ DMS
+ </p>
+ </main>
+ );
+}

772
README.md Executable file → Normal file
View File

@@ -1,29 +1,695 @@
# บทบาท: คุณคือ Programmer และ Document Engineer ที่เชี่ยวชาญ # 📝 0. Project Title: Document Management System (DMS) Web Application for Laem Chabang Port Development Project, Phase 3
1. การพัฒนาเว็บแอป (Web Application Development)
2. Configuration of Container Station on QNAP ## 0. Project
3. Database: mariadb:10.11
4. Database management: phpmyadmin:5-apache ### 📌 0.1 Project Overview / Description
5. Backend: node:.js (ESM)
6. Frontend: next.js, react - ระบบ Document Management System (DMS) เป็นเว็บแอปพลิเคชันที่ออกแบบมาเพื่อจัดการเอกสารภายในโครงการอย่างมีประสิทธิภาพ
7. Workflow automation: n8n: - โดยมีฟังก์ชันหลักในการอัปโหลด จัดเก็บ ค้นหา แชร์ และควบคุมสิทธิ์การเข้าถึงเอกสาร
8. Workflow database: postgres:16-alpine - ระบบนี้จะช่วยลดการใช้เอกสารกระดาษ เพิ่มความปลอดภัยในการจัดเก็บข้อมูล
9. Workflow database management: pgadmin4 - เพิ่มความสะดวกในการทำงานร่วมกันระหว่างองกรณ์
10. Reverse proxy: nginx:1.27-alpine
11. linux on QNAP ### 🎯 0.2 Objectives
12. การจัดการฐานข้อมูล (Database Management)
13. การวิเคราะห์ฐานข้อมูล (Database Analysis) - พัฒนาระบบที่สามารถจัดการเอกสารได้อย่างเป็นระบบ
14. การจัดการฐานข้อมูลเชิงสัมพันธ์ (Relational Databases) - ลดความซ้ำซ้อนในการจัดเก็บเอกสาร
15. ภาษา SQL - เพิ่มความปลอดภัยในการเข้าถึงและจัดการเอกสาร
16. RBAC - รองรับการทำงานร่วมกันแบบออนไลน์
### 📦 0.3 Scope of Work
ระบบจะครอบคลุมฟีเจอร์หลักดังนี้:
- การลงทะเบียนและเข้าสู่ระบบ ของผู้ใช้งาน
- การอัปโหลดและจัดเก็บเอกสารในรูปแบบต่าง ๆ (PDF, DOCX, XLSX ฯลฯ)
- การจัดหมวดหมู่และแท็กเอกสาร
- การค้นหาเอกสารด้วยคำสำคัญหรือฟิลเตอร์
- การกำหนดสิทธิ์การเข้าถึงเอกสาร (เช่น อ่านอย่างเดียว, แก้ไข, ลบ)
- การบันทึกประวัติการใช้งานเอกสาร (Audit Trail)
- การมอบหมายงานให้กับผู้เกี่ยวข้อง และแจ้งเตือนเมื่อมีการมอบหมายงาน
- การแจ้งเตือนเมื่อถึงกำหนดวันที่ต้องส่งเอกสารต่อให้ ผู้เกี่ยวข้องอื่นๆ
- การแจ้งเตือนเมื่อมีการเปลี่ยนแปลงเอกสาร
### 👥 0.4 Target Users
- พนักงานภายใน ขององค์กร
- พนักงานควบคุมเอกสาร (Document Control)/ ผู้ดูแลระบบขององค์กร (admin)
- ผู้จัดการฝ่ายเอกสาร ขององค์กร
- ผู้จัดการโครงการ ขององค์กร
- คณะกรรมการ ของโครงการ
- ผู้ดูแลระบบ IT ของโครงการ (superadmin)
### 📈 0.5 Expected Outcomes
- ลดเวลาในการค้นหาเอกสารลงอย่างน้อย 50%
- ลดเวลาในการจัดทำรายงานเอกสาร ประจำวัน, ประจำสัปดาห์, ประจำเดือน, ประจำปี และ รายงานเอกสารทั้งโครงการ
- ลดการใช้เอกสารกระดาษในองค์กร
- เพิ่มความปลอดภัยในการจัดเก็บข้อมูล
- รองรับการทำงานแบบ Remote Work
### 📘 0.6 Requirements Use Cases
#### 📘 Use Case: Upload Document
Actor: พนักงานควบคุมเอกสาร (Document Control)
Description: พนักงานควบคุมเอกสารสามารถอัปโหลดเอกสารเข้าสู่ระบบเพื่อจัดเก็บและใช้งานในภายหลัง
Preconditions: พนักงานควบคุมเอกสารต้องเข้าสู่ระบบก่อน
Main Flow:
พนักงานควบคุมเอกสารเลือกเมนู “อัปโหลดเอกสาร”
เลือกไฟล์จากเครื่องคอมพิวเตอร์
กรอกข้อมูลประกอบ เช่น ชื่อเอกสาร หมวดหมู่ แท็ก
กดปุ่ม “อัปโหลด”
ระบบบันทึกเอกสารและแสดงผลการอัปโหลดสำเร็จ
Postconditions: เอกสารถูกจัดเก็บในระบบและสามารถค้นหาได้
#### 📘 Use Case: Assign Users to Document
Actor: พนักงานควบคุมเอกสาร (Document Control)
Description: พนักงานควบคุมเอกสารสามารถ มอบหมายงานให้กับ Users
Preconditions: พนักงานควบคุมเอกสารต้องเข้าสู่ระบบก่อน, เอกสารต้องอัปโหลดเรียบร้อยแล้ว
Main Flow:
พนักงานควบคุมเอกสารเลือกเมนู “มอบหมายงาน”
เลือกเอกสารในระบบ
เลือก Users กำหนดวันสิ้นสุดงาน
กดปุ่ม “มอบหมายงาน”
ระบบบันทึกเอกสารและแสดงผลการมอบหมายงานสำเร็จ
Postconditions: งานที่มอยหมาย จัดเก็บในระบบและสามารถค้นหาได้
#### 📘 Use Case: Search Document
Actor: ผู้ใช้งานทั่วไป
Description: ผู้ใช้งานสามารถค้นหาเอกสารจากระบบด้วยคำสำคัญหรือฟิลเตอร์
Preconditions: ผู้ใช้งานต้องเข้าสู่ระบบ
Main Flow:
ผู้ใช้งานกรอกคำค้นหรือเลือกฟิลเตอร์ (หมวดหมู่, วันที่, ผู้สร้าง, ผู้ได้รับมอบหมายงาน, สถานะ, title, subject)
กดปุ่ม “ค้นหา”
ระบบแสดงรายการเอกสารที่ตรงกับเงื่อนไข
Postconditions: ผู้ใช้งานสามารถเปิดดูหรือดาวน์โหลดเอกสารที่ค้นพบได้
#### 📘 Use Case: Manage Access
Actor: ผู้ดูแลระบบโครงการ (superadmin) / ผู้ดูแลระบบขององค์กร (admin)
Description: ผู้ดูแลระบบสามารถกำหนดสิทธิ์การเข้าถึงเอกสารให้กับผู้ใช้งาน
Preconditions: ผู้ดูแลระบบต้องเข้าสู่ระบบ
Main Flow:
ผู้ดูแลระบบเลือกเอกสาร
กด “จัดการสิทธิ์”
เลือกผู้ใช้งานและกำหนดสิทธิ์ (อ่าน, แก้ไข, ลบ)
กด “บันทึก”
Postconditions: สิทธิ์การเข้าถึงเอกสารถูกปรับตามที่กำหนด
#### 📘 Use Case: View Document History
Actor: ผู้ใช้งานทั่วไป / ผู้ดูแลระบบ
Description: ผู้ใช้งานสามารถดูประวัติการใช้งานเอกสาร เช่น การแก้ไข การดาวน์โหลด
Preconditions: ผู้ใช้งานต้องมีสิทธิ์เข้าถึงเอกสาร
Main Flow:
ผู้ใช้งานเปิดเอกสาร
เลือก “ดูประวัติ”
ระบบแสดงรายการกิจกรรมที่เกี่ยวข้องกับเอกสาร
Postconditions: ผู้ใช้งานสามารถตรวจสอบการเปลี่ยนแปลงย้อนหลังได้
### 🔄 0.7 Workflow อัตโนมัติในระบบ DMS
✅ ประโยชน์ของ Workflow อัตโนมัติใน DMS
- ลดภาระงานซ้ำ ๆ ของผู้ใช้งาน
- เพิ่มความปลอดภัยและการควบคุมเอกสาร
- เพิ่มความเร็วในการดำเนินงาน
- ลดข้อผิดพลาดจากการทำงานด้วยมือ
#### 🧩 Workflow: 1. Document treat Workflow
กรณี: เมื่อมีการอัปโหลดเอกสารต้องได้รับการมอบหมายงานให้กับ พนักงานภายในองค์กรณ์
ขั้นตอนอัตโนมัติ:
1. ผู้ใช้งานอัปโหลดเอกสารและเลือก “มอบหมายงาน”
2. ระบบส่งแจ้งเตือนไปยังผู้ได้รับมอบหมายงาน
3. ผู้อนุมัติสามารถตรวจสอบและกด “ตรวจสอบแล้ว”
4. ระบบบันทึกสถานะเอกสารและ ส่งต่อ ไปยัง องกรณือื่น ตามลำดับ เมื่อได้ผลและจัดทำเอกสารตอบแล้ว จึงแจ้งผลกลับไปยังผู้ส่ง
#### 📥 Workflow: 2. Auto Tagging & Categorization
กรณี: เอกสารที่อัปโหลดมีชื่อหรือเนื้อหาที่ตรงกับหมวดหมู่ที่กำหนดไว้
ขั้นตอนอัตโนมัติ:
เมื่ออัปโหลดเอกสาร ระบบวิเคราะห์ชื่อไฟล์หรือเนื้อหา
ระบบกำหนดหมวดหมู่และแท็กให้โดยอัตโนมัติ เช่น “ใบเสนอราคา” → หมวด “การเงิน”
ผู้ใช้งานสามารถแก้ไขได้หากต้องการ
#### 🔐 Workflow: 3. Access Control Workflow
กรณี: เอกสารที่มีความลับสูงต้องจำกัดการเข้าถึง
ขั้นตอนอัตโนมัติ:
เมื่ออัปโหลดเอกสารที่มีคำว่า “ลับ” หรือ “Confidential”
ระบบกำหนดสิทธิ์เริ่มต้นให้เฉพาะผู้ใช้งานระดับผู้จัดการขึ้นไป
ระบบแจ้งเตือนผู้ดูแลระบบให้ตรวจสอบสิทธิ์เพิ่มเติม
#### 📤 Workflow: 4. Expiry & Archiving Workflow
กรณี: เอกสารที่มีอายุการใช้งาน เช่น สัญญา หรือใบอนุญาต
ขั้นตอนอัตโนมัติ:
เมื่ออัปโหลดเอกสาร ผู้ใช้งานระบุวันหมดอายุ
ระบบแจ้งเตือนก่อนหมดอายุล่วงหน้า เช่น 30 วัน
เมื่อถึงวันหมดอายุ ระบบย้ายเอกสารไปยังหมวด “Archive” โดยอัตโนมัติ
#### 📊 Workflow: 5. Audit Trail & Notification Workflow
กรณี: มีการแก้ไขหรือดาวน์โหลดเอกสารสำคัญ
ขั้นตอนอัตโนมัติ:
ทุกการกระทำกับเอกสาร (เปิด, แก้ไข, ลบ) จะถูกบันทึกใน Audit Log
หากเอกสารถูกแก้ไขโดยผู้ใช้งานที่ไม่ใช่เจ้าของ ระบบแจ้งเตือนเจ้าของเอกสารทันที
## 🛠️ 1. DMS Architecture Deep Dive (Backend + Frontend)
### 1.1 Executive Summary
- Reverse proxy (Nginx/NPM) เผยแพร่ Frontend (Next.js) และ Backend (Node.js/Express) ผ่าน HTTPS (HSTS)
- Backend เชื่อม MariaDB 10.11 (ข้อมูลหลัก DMS) และแยก n8n + Postgres 16 สำหรับ workflow
- RBAC/ABAC ถูกบังคับใช้งานใน middleware + มีชุด SQL (tables → triggers → procedures → views → seed)
- ไฟล์จริง (PDF/DWG) เก็บนอก webroot ที่ /share/dmsdata พร้อมมาตรฐานการตั้งชื่อ+โฟลเดอร์
- Dev/Prod แยกชัดเจนผ่าน Docker multistage + dockercompose + โฟลเดอร์ persist logs/config/certs
### 1.2 Runtime Topology & Trust Boundaries
```text
Internet Clients (Browser)
│ HTTPS 443 (HSTS) [QNAP mgmt = 8443]
┌─────────────────────────────────────────────────────┐
│ Reverse Proxy Layer │
│ ├─ Nginx (Alpine) or Nginx Proxy Manager (NPM) │
│ ├─ TLS (LE cert; SAN multisubdomain) │
│ └─ Routes: │
│ • /, /_next/* → Frontend (Next.js :3000) │
│ • /api/* → Backend (Express :3001) │
│ • /pma/* → phpMyAdmin │
│ • /n8n/* → n8n (Workflows) │
└─────────────────────────────────────────────────────┘
│ │
│ └──────────┐
▼ │
Frontend (Next.js) │
│ Cookie-based Auth (HttpOnly) │
▼ ▼
Backend (Node/Express ESM) ─────────► MariaDB 10.11
│ │
└────────────────────────────────────┘
Project data (.pdf/.dwg) @ /share/dms-data
n8n (workflows) ──► Postgres 16 (separate DB for automations)
```
==Trust Boundaries==
- Public zone: Internet ↔ Reverse proxy
- App zone: Reverse proxy ↔ FE/BE containers (internal Docker network)
- # Data zone: Backend ↔ Databases (MariaDB, Postgres) + /share/dms-data
### 1.3 Frontend: Next.js (ESM) / React.js
#### 1.3.1 Stack & Key libs
- Next.js (App Router), React, ESM
- Tailwind CSS, PostCSS, shadcn/ui (components.json)
- Fetch API (credentials include) → Cookie Auth (HttpOnly)
#### 1.3.2 Directory Layout
```text
/frontend/
├─ app/
│ ├─ login/
│ ├─ dashboard/
│ ├─ users/
│ ├─ correspondences/
│ ├─ health/
│ └─ layout.tsx / page.tsx (ตาม App Router)
├─ public/
├─ Dockerfile (multi-stage: dev/prod)
├─ package.json
├─ next.config.js
└─ ...
```
#### 1.3.3 Routing & Layouts
- Public /login, /health
- Protected: /dashboard, /users, /correspondences, ... (client-side guard)
- เก็บ middleware.ts (ของเดิม) เพื่อหลีกเลี่ยง regression; ใช้ clientguard + server action อย่างระมัดระวัง
#### 1.3.4 Auth Flow (Cookie-based)
1. ผู้ใช้ submit form /login → POST /api/auth/login (Backend)
2. Backend set HttpOnly cookie (JWT) + SameSite=Lax/Strict, Secure
3. หน้า protected เรียก GET /api/auth/me เพื่อตรวจสอบสถานะ
4. หาก 401 → redirect → /login
**CORS/Fetch**: เเปิด credentials: 'include' ทุกครั้ง, ตั้ง NEXT_PUBLIC_API_BASE เป็น origin ของ backend ผ่าน proxy (เช่น https://lcbp3.np-dms.work)
#### 1.3.5 UI/UX
- Seablue palette, sidebar พับได้, cardbased KPI
- ตารางข้อมูลเตรียมรองรับ serverside DataTables\*\*
- shadcn/ui: Button, Card, Badge, Tabs, Dropdown, Tooltip, Switch, etc.
#### 1.3.6 Config & ENV
- NEXT_PUBLIC_API_BAS (ex: https://lcbp3.np-dms.work)
- Build output แยก dev/prod; ระวัง EACCES บน QNAP → ใช้ user node + ปรับสิทธิ์โวลุ่ม .next/\*
#### 1.3.7 Error Handling & Observability (FE)
- Global error boundary (app router) + toast/alert patterns
- Network layer: แยก handler สำหรับ 401/403/500 + retry/backoff ที่จำเป็น
- Metrics (optional): webvitals, UX timing (เก็บฝั่ง n8n หรือ simple logging)
---
### 1.4 Backend Architecture (Node.js ESM / Express)
#### 1.4.1 Stack & Structure
- Node 20.x, ESM modules, Express\*\*
- mysql2/promise, jsonwebtoken, cookie-parser, cors, helmet, winston/morgan
```text
/backend/
├─ src/
│ ├─ index.js # bootstrap server, CORS, cookies, health
│ ├─ routes/
│ │ ├─ auth.js # /api/auth/* (login, me, logout)
│ │ ├─ users.js # /api/users/*
│ │ ├─ correspondences.js # /api/correspondences/*
│ │ ├─ drawings.js # /api/drawings/*
│ │ ├─ rfas.js # /api/rfas/*
│ │ └─ transmittals.js # /api/transmittals/*
│ ├─ middleware/
│ │ ├─ authGuard.js # verify JWT from cookie
│ │ ├─ requirePermission.js# RBAC/ABAC enforcement
│ │ ├─ errorHandler.js
│ │ └─ requestLogger.js
│ ├─ db/
│ │ ├─ pool.js # createPool, sane defaults
│ │ └─ models/ # query builders (User, Drawing, ...)
│ ├─ utils/
│ │ ├─ hash.js (bcrypt/argon2)
│ │ ├─ jwt.js
│ │ ├─ pagination.js
│ │ └─ responses.js
│ └─ config/
│ └─ index.js # env, constants
├─ Dockerfile
└─ package.json
```
#### 1.4.2 Request Lifecycle
1. helmet + cors (allow specific origin; credentials true)
2. cookie-parser, json limit (e.g., 2MB)
3. requestLogger → trace + response time
4. Route handler → authGuard (protected) → requirePermission (perroute) → Controller
5. Error bubbles → errorHandler (JSON shape, status map)
#### 1.4.3 Auth & RBAC/ABAC
- JWT ใน HttpOnly cookie; Claims: sub (user_id), roles, exp
- authGuard: ตรวจ token → แนบ req.user
- requirePermission: เช็ค permission ตามเส้นทาง/วิธี; แผนขยาย ABAC (เช่น project scope, owner, doc state)
- Roles/Permissions ถูก seed ใน SQL; มี view เมทริกซ์ เพื่อ debug (เช่น v_role_permission_matrix)
\*\*ตัวอย่าง pseudo requirePermission(permission)
```js
export const requirePermission = (perm) => async (req, res, next) => {
if (!req.user) return res.status(401).json({ error: "Unauthenticated" });
const ok = await checkPermission(req.user.user_id, perm, req.context);
if (!ok) return res.status(403).json({ error: "Forbidden" });
return next();
};
```
#### 1.4.4 Database Access & Pooling
- createPool({ connectionLimit: 10~25, queueLimit: 0, waitForConnections: true })
- ใช้ parameterized queries เสมอ; ปรับ sql_mode ที่จำเป็นใน my.cnf
#### 1.4.5 File Storage & Secure Download
- Root: /share/dmsdata
- โครงโฟลเดอร์: {module}/{yyyy}/{mm}/{entityId}/ + ชื่อไฟล์ตามมาตรฐาน (เช่น DRW-code-REV-rev.pdf)
- Endpoint download: ตรวจสิทธิ์ (RBAC/ABAC) → res.sendFile()/stream; ป้องกัน path traversal
- MIME allowlist + size limit + virus scan (optional; ภายหลัง)
#### 1.4.6 Health & Readiness
- GET /api/health → { ok: true }
- (optional) /api/ready ตรวจ DB ping + disk space (dmsdata)
#### 1.4.7 Config & ENV (BE)
- DB_HOST, DB_PORT, DB_USER, DB_PASS, DB_NAME
- JWT_SECRET, COOKIE_NAME, COOKIE_SAMESITE, COOKIE_SECURE
- CORS_ORIGIN, LOG_LEVEL, APP_BASE_URL
- FILE_ROOT=/share/dms-data
#### 1.4.8 Logging
- Access log (morgan) + App log (winston) → /share/Container/dms/logs/backend/
- รูปแบบ JSON (timestamp, level, msg, reqId) + daily rotation (logrotate/containerside)
### 1.5 Database (MariaDB 10.11)
#### 1.5.1 Schema Overview (ย่อ)
- RBAC core: users, roles, permissions, user_roles, role_permissions
- Domain: drawings, contracts, correspondences, rfas, transmittals, organizations, projects, ...
- Audit: audit_logs (แผนขยาย), deleted_at (soft delete, แผนงาน)
```text
[users]──<user_roles>──[roles]──<role_permissions>──[permissions]
└── activities/audit_logs (future expansion)
[drawings]──<mapping>──[contracts]
[rfas]──<links>──[drawings]
[correspondences] (internal/external flag)
```
#### 1.5.2 Init SQL Pipeline
1. 01\_\*\_deploy_table_rbac.sql — สร้างตารางหลักทั้งหมด + RBAC
2. 02\_\*\_triggers.sql — บังคับ data rules, autoaudit fields
3. 03\_\*\_procedures_handlers.sql — upsert/bulk handlers (เช่น sp_bulk_import_contract_dwg)
4. 04\_\*\_views.sql — รายงาน/เมทริกซ์สิทธิ์ (v_role_permission_matrix, etc.)
5. 05\_\*\_seed_data.sql — ค่าพื้นฐาน domain (project, categories, statuses)
6. 06\_\*\_seed_users.sql — บัญชีเริ่มต้น (superadmin, editors, viewers)
7. 07\_\*\_seed_contract_dwg.sql — ข้อมูลตัวอย่างแบบสัญญา
#### 1.5.3 Indexing & Performance
- Composite indexes ตามคอลัมน์ filter/sort (เช่น (project_id, updated_at DESC))
- Fulltext index (optional) สำหรับ advanced search
- Query plan review (EXPLAIN) + เพิ่ม covering index ตามรายงาน
#### 1.5.4 MySQL/MariaDB Config (my.cnf — แนวทาง)
```conf
[mysqld]
innodb_buffer_pool_size = 4G # ปรับตาม RAM/QNAP
innodb_log_file_size = 512M
innodb_flush_log_at_trx_commit = 1
max_connections = 200
sql_mode = STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci
```
> ปรับค่าให้เหมาะกับ workload จริง + เฝ้าดู IO/CPU ของ QNAP
#### 1.5.5 Backup/Restore
- Logical backup: mysqldump --routines --triggers --single-transaction
- Physical (snapshot QNAP) + schedule ผ่าน n8n/cron
- เก็บสำเนา offNAS (encrypted)
### 1.6 Reverse Proxy & TLS
#### 1.6.1 Nginx (Alpine) — ตัวอย่าง server block
> สำคัญ: บนสภาพแวดล้อมนี้ ให้ใช้คนละบรรทัด:
> listen 443 ssl;
> http2 on;
> หลีกเลี่ยง listen 443 ssl http2;
```conf
server {
listen 80;
server_name lcbp3.np-dms.work;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name lcbp3.np-dms.work;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
add_header Strict-Transport-Security "max-age=63072000; preload" always;
# Frontend
location / {
proxy_pass http://frontend:3000;
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;
}
# Next.js static
location /_next/ {
proxy_pass http://frontend:3000;
}
# Backend API
location /api/ {
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_pass http://backend:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
# phpMyAdmin (sub-path)
location /pma/ {
proxy_pass http://phpmyadmin:80/;
}
# n8n
location /n8n/ {
proxy_pass http://n8n:5678/;
}
}
```
#### 1.6.2 Nginx Proxy Manager (NPM) — Tips
- ระวังอย่าใส่ proxy_http_version ซ้ำซ้อน (duplicate directive) ใน Advanced
- ถ้าต้องแก้ไฟล์ด้านใน NPM → ระวังไฟล์ใน /data/nginx/proxy_host/\*.conf
- จัดการ certificate / SAN หลาย subdomain ใน UI แต่ mainten ดีเรื่อง symlink/renew
#### 1.6.3 TLS & Certificates
- Lets Encrypt (HTTP01 webroot/standalone) + HSTS
- QNAP mgmt เปลี่ยนเป็น 8443 → พอร์ต 443 public ว่างสำหรับ Nginx/NPM
### 1.7 Docker Compose Topology
#### 1.7.1 Services (สรุป)
- frontend (Next.js) :3000
- backend (Express) :3001
- mariadb (10.11) :3306 (internal)
- phpmyadmin :80 (internal)
- nginx or npm :80/443 (published)
- n8n :5678 (internal)
- postgres_n8n (16-alpine)
- pgadmin4
#### 1.7.2 Volumes & Paths
```text
/share/Container/dms/
├─ mariadb/data
├─ mariadb/init/*.sql
├─ backend/ (code)
├─ frontend/ (code)
├─ phpmyadmin/{sessions,tmp,config.user.inc.php}
├─ nginx/{nginx.conf,dms.conf,certs/}
├─ n8n, n8n-postgres, n8n-cache
└─ logs/{backend,frontend,nginx,pgadmin,phpmyadmin,postgres_n8n}
/share/dms-data (pdf/dwg storage)
```
#### 1.7.3 Healthchecks (suggested)
- backend:
```sh
curl http://localhost:3001/api/health
```
- frontend: curl /health (simple JSON)
- mariadb: mysqladmin ping with credentials
- nginx: nginx -t at startup
#### 1.7.4 Security Hardening
- รัน container ด้วย user nonroot (user: node สำหรับ FE/BE)
- จำกัด capabilities; readonly FS (ยกเว้นโวลุ่มจำเป็น)
- เฉพาะ backend เมานต์ /share/dms-data
### 1.8 Observability, Ops, and Troubleshooting
#### 1.8.1 Logs
- Frontend → /logs/frontend/\*
- Backend → /logs/backend/\* (app/access/error)
- Nginx/NPM → /logs/nginx/\*
- MariaDB → default datadir log + slow query (เปิดใน my.cnf หากต้องการ)
#### 1.8.2 Common Issues & Playbooks
- 401 Unauthenticated: ตรวจ authGuard → JWT cookie มี/หมดอายุ → เวลา server/FE sync → CORS credentials: true
- EACCES Next.js: สิทธิ์ .next/\* + run as`node, โวลุ่ม map ถูก user:group
- NPM duplicate directive: ลบซ้ำ proxy_http_version ใน Advanced / ตรวจ proxy_host/\*.conf
- LE cert path/symlink: ตรวจ /etc/letsencrypt/live/npm-\* symlink ชี้ถูก
- DB field not found: ตรวจ schema vs code (migration/init SQL) → sync ให้ตรง
#### 1.8.3 Performance Guides
- Backend: keepalive, gzip/deflate at proxy, pool 1025, paginate, avoid N+1
- Frontend: prefetch critical routes, cache static, image optimization
- DB: เพิ่ม index จุด filter, analyze query (EXPLAIN), ปรับ buffer pool
### 1.9 Security & Compliance
- HTTPS only + HSTS (preload)
- CORS: allow list เฉพาะ FE origin; Access-Control-Allow-Credentials: true
- Cookie: HttpOnly, Secure, SameSite=Lax/Strict
- Input Validation: celebrate/zod (optional) + sanitize
- Rate limiting: per IP/route (optional)
- AuditLog: วางแผนเพิ่ม ครอบคลุม CRUD + mapping (actor, action, entity, before/after)
- Backups: DB + /share/dms-data + config (encrypted offNAS)
### 1.10 Backlog → Architecture Mapping
1. RBAC Enforcement ครบ → เติม requirePermission ทุก route + test matrix ผ่าน view
2. AuditLog ครบ CRUD/Mapping → trigger + table audit_logs + BE hook
3. Upload/Download จริงของ Drawing Revisions → BE endpoints + virus scan (optional)
4. Dashboard KPI → BE summary endpoints + FE cards/charts
5. Serverside DataTables → paging/sort/filter + indexesรองรับ
6. รายงาน Export CSV/Excel/PDF → BE export endpoints + FE buttons
7. Soft delete (deleted_at) → BE filter default scope + restore endpoint
8. Validation เข้ม → celebrate/zod schema + consistent error shape
9. Indexing/Perf → slow query log + EXPLAIN review
10. Job/Cron Deadline Alerts → n8n schedule + SMTP
### 1.11 Port & ENV Matrix (Quick Ref)
| Component | Ports | Key ENV |
| Nginx/NPM | 80/443 (public) | SSL paths, HSTS |
| Frontend | 3000 (internal) | NEXT*PUBLIC_API_BASE |
| Backend | 3001 (internal) | DB*\*, JWT*SECRET, CORS_ORIGIN, FILE_ROOT |
| MariaDB | 3306 (internal) | MY_CNF, credentials |
| n8n | 5678 (internal) | N8N*, webhook URL under /n8n/ |
| Postgres | 5432 (internal) | n8n DB |
QNAP mgmt: 8443 (already moved)
### 1.12 Sample Snippets
#### 1.12.1 Backend CORS (credentials)
```js
app.use(
cors({
origin: ["https://lcbp3.np-dms.work"],
credentials: true,
})
);
```
#### 1.12.2 Secure Download (guarded)
```js
router.get(
"/files/:module/:id/:filename",
authGuard,
requirePermission("file.read"),
async (req, res) => {
const { module, id, filename } = req.params;
// 1) ABAC: verify user can access this module/entity
const ok = await canReadFile(req.user.user_id, module, id);
if (!ok) return res.status(403).json({ error: "Forbidden" });
const abs = path.join(FILE_ROOT, module, id, filename);
if (!abs.startsWith(FILE_ROOT))
return res.status(400).json({ error: "Bad path" });
return res.sendFile(abs);
}
);
```
#### 1.12.3 Healthcheck
```js
router.get("/health", (req, res) => res.json({ ok: true }));
```
### 13 Deployment Workflow (Suggested)
1. Git (Gitea) branch strategy feature/\* → PR → main
2. Build images (dev/prod) via Dockerfile multistage; pin Node/MariaDB versions
3. docker compose up -d --build จาก /share/Container/dms
4. Validate: /health, /api/health, login roundtrip
5. Monitor logs + baseline perf; run SQL smoke tests (views/triggers/procs)
### 14 Appendix
- Naming conventions: snake_case DB, camelCase JS
- Timezones: store UTC in DB; display in app TZ (+07:00)
- Character set: UTF8 (utf8mb4_unicode_ci)
- Large file policy: size limit (e.g., 50200MB), allowlist extensions
- Retention: archive strategy for old revisions (optional)
## บทบาท: คุณคือ Programmer และ Document Engineer ที่เชี่ยวชาญ
1. การพัฒนาเว็บแอป (Web Application Development)
2. Configuration of Container Station on QNAP
3. Database: mariadb:10.11
4. Database management: phpmyadmin:5-apache
5. Backend: node:.js (ESM)
6. Frontend: next.js, react
7. Workflow automation: n8n:
8. Workflow database: postgres:16-alpine
9. Workflow database management: pgadmin4
10. Reverse proxy: nginx:1.27-alpine
11. linux on QNAP
12. การจัดการฐานข้อมูล (Database Management)
13. การวิเคราะห์ฐานข้อมูล (Database Analysis)
14. การจัดการฐานข้อมูลเชิงสัมพันธ์ (Relational Databases)
15. ภาษา SQL
16. RBAC
## 2. ระบบที่ใช้
## Server
- ใช้ Container Station เป็น SERVER บน QNAP (Model: TS-473A, RAM: 32GB, CPU: AMD Ryzen V1500B 4 cores 8 threads) **เปลี่ยน port 443 ของ QNAP เป็น 8443 แล้ว**
## การพัฒนาโครงการ
- ด้วย Visual Studio Code บน Windows 11
- ใช้ ๊ UI ของ Container Station เป็นหลัก
## โครงสร้างโฟลเดอร์ (บน QNAP)
# ระบบที่ใช้
## Server
- ใช้ Container Station เป็น SERVER บน QNAP (Model: TS-473A, RAM: 32GB, CPU: AMD Ryzen V1500B 4 cores 8 threads)
*** เปลี่ยน port 443 ของ QNAP เป็น 8443 แล้ว ***
## การพัฒนาโครงการ
- ด้วย Visual Studio Code บน Windows 11
- ใช้ ๊ UI ของ Container Station เป็นหลัก
## โครงสร้างโฟลเดอร์ (บน QNAP)
/share/Container/dms/ /share/Container/dms/
├─ docker-compose.yml # Create โดย UI Container Station ├─ docker-compose.yml # Create โดย UI Container Station
├─ mariadb/ ├─ mariadb/
@@ -75,31 +741,37 @@
├─ n8n-cache/ ├─ n8n-cache/
├─ n8n-postgres/ ├─ n8n-postgres/
└─ logs/ └─ logs/
├─ backend/ ├─ backend/
├─ frontend/ ├─ frontend/
├─ nginx/ ├─ nginx/
├─ pgadmin/ ├─ pgadmin/
├─ phpmyadmin/ ├─ phpmyadmin/
└─ postgres_n8n/ └─ postgres_n8n/
/share/dms-data # เก็บข้อมมูล .pdf, .dwg แยกตาม correspondences, documents /share/dms-data # เก็บข้อมมูล .pdf, .dwg แยกตาม correspondences, documents
# ภาษา: ใช้ภาษาไทยในการโต้ตอบ ยกเว้น ศัพท์เทคนิคหรือศัพท์เฉพาะทาง
# ไฟล์ที่ ีupload
- Dockerfile ของ backend
- package.json ของ backend
- docker-compose.yml ชอง Container station
- nginx.conf, dms.conf ของ nginx
- dms_v0_5_0_data_v5_1_sql.zip ประกอบด้วย
- 01_dms_data_v5_1_deploy_table_rbac.sql # Create all data table & RBAC table here!
- 02_dms_data_v5_1_triggers.sql # Create all triggers here!
- 03_dms_data_v5_1_procedures_handlers.sql # Create all procedures here!
- 04_dms_data_v5_1_views.sql # Create all views here!
- 05 dms_data_v5_1_seeก_data.sql # Seed nescesary data here!
- 06_dms_data_v5_1_seed_users.sql # Seed users data here!
# งานที่ต้องการ: # งานที่ต้องการ:
- ไม่ใช้ .env เด็ดขาด Container Station ไม่รองรับ และ docker-compose.yml ได้ทดสอบ รันบน Container station มาแล้ว
- Code ของ backend ทั้งหมด - ไม่ใช้ .env เด็ดขาด Container Station ไม่รองรับ และ docker-compose.yml ได้ทดสอบ รันบน Container station มาแล้ว
- การทดสอบระบบ backend ทุกส่วน ให้พร้อม สำหรับ frontend - 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

96
b.env
View File

@@ -1,96 +0,0 @@
TZ=Asia/Bangkok
GENERIC_TIMEZONE=Asia/Bangkok
PUBLIC_DOMAIN=np-dms.work
PUBLIC_FRONTEND_URL=https://lcbp3.np-dms.work
PUBLIC_BACKEND_URL=https://lcbp3.np-dms.work/api
PUBLIC_N8N_URL=https://lcbp3.np-dms.work/n8n
MARIADB_HOST=mariadb
MARIADB_PORT=3306
MARIADB_ROOT_PASSWORD=Center#2025
MARIADB_DATABASE=dms
MARIADB_USER=center
MARIADB_PASSWORD=Center#2025
# MARIADB_HOST_PORT=7307
# BACKEND_HOST_PORT=7001
# FRONTEND_HOST_PORT=7000
# PHPMYADMIN_HOST_PORT=7070
NGINX_HTTP_HOST_PORT=80
NGINX_HTTPS_HOST_PORT=443
N# 8N_HOST_PORT=7081
NODE_ENV=production
JWT_SECRET=8b0df02e4aee9f9f79a4f2d8ba77b0b82c1ee3446b68cb0bae94ab54d60f8d9e
JWT_EXPIRES_IN=12h
PASSWORD_SALT_ROUNDS=10
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX=200
CORS_ORIGINS=https://lcbp3.np-dms.work,http://localhost:7000,http://192.168.20.248:7000
NEXT_TELEMETRY_DISABLED=1
PMA_HOST=mariadb
PMA_PORT=3306
PMA_ABSOLUTE_URI=https://lcbp3.np-dms.work.com/pma/
UPLOAD_LIMIT=256M
MEMORY_LIMIT=512M
NGINX_SERVER_NAME=np-dms.work.com
NGINX_PROXY_READ_TIMEOUT=300
# QNAP_SSL_CERT_HOST=/etc/qnap-ssl/combine
# QNAP_SSL_KEY_HOST=/etc/qnap-ssl/key
# NGINX_SSL_CERT=/etc/nginx/certs/fullchain.pem
# NGINX_SSL_KEY=/etc/nginx/certs/privkey.pem
# NGINX_SSL_KEY=/etc/nginx/certs
QNAP_SSL_CERT=/etc/config/QcloudSSLCertificate/cert
NGINX_SSL_CERT=/etc/qnap-ssl
N8N_BASIC_AUTH_ACTIVE=true
N8N_BASIC_AUTH_USER=n8n
N8N_BASIC_AUTH_PASSWORD=Center#2025
N8N_PATH=/n8n/
N8N_PROTOCOL=https
N8N_PROXY_HOPS=1
N8N_SECURE_COOKIE=true
N8N_HOST=dcs.mycloudnas.com
N8N_PORT=5678
N8N_EDITOR_BASE_URL=https://lcbp3.np-dms.work/n8n/
WEBHOOK_URL=https://lcbp3.np-dms.work/n8n/
N8N_ENCRYPTION_KEY=9AAIB7Da9DW1qAhJE5/Bz4SnbQjeAngI
# --- n8n → MariaDB ---
# DB_TYPE=mysqldb
# DB_MYSQLDB_HOST=mariadb
# DB_MYSQLDB_PORT=3306
# DB_MYSQLDB_DATABASE=n8n
# DB_MYSQLDB_USER=n8n_user
# DB_MYSQLDB_PASSWORD=Center#2025 # เปลี่ยนเป็นรหัสแข็งแรงของคุณ
# ==== n8n → PostgreSQL (แทน MariaDB/MySQL) ====
DB_TYPE=postgresdb
DB_POSTGRESDB_HOST=postgres_n8n
DB_POSTGRESDB_PORT=5432
DB_POSTGRESDB_DATABASE=n8n
DB_POSTGRESDB_USER=n8n
DB_POSTGRESDB_PASSWORD=Center#2025
# path โฟลเดอร์ n8n เดิม (มี database.sqlite)
# HOST_N8N=/share/Container/dms/n8n
HOST_BASE=/share/Container/dms
HOST_MARIADB=${HOST_BASE}/mariadb
HOST_BACKEND=${HOST_BASE}/backend
HOST_FRONTEND=${HOST_BASE}/frontend
HOST_PHPMYADMIN=${HOST_BASE}/phpmyadmin
HOST_NGINX=${HOST_BASE}/nginx
HOST_LOGS=${HOST_BASE}/logs
HOST_SCRIPTS=${HOST_BASE}/scripts
HOST_N8N=/share/Container/dms/n8n
HOST_N8N_CACHE=${HOST_BASE}/n8n-cache
HOST_DATA=/share/dms-data
# BACKEND_LOG_DIR=${HOST_LOGS}/backend
BACKEND_LOG_DIR=/app/logs

View File

@@ -0,0 +1,29 @@
# Backend build
## วิธีสร้าง package-lock.json ด้วย Docker
### 1. เช็ค uid:gid ของโฟลเดอร์โปรเจกต์บน QNAP
stat -c "%u:%g" .
### 2. ใช้ค่าที่ได้มาแทน UID:GID
```bash
docker run --rm \
-v "/share/Container/dms/frontend:/app" -w /app \
--user UID:GID -e HOME=/tmp \
node:20-alpine sh -lc 'mkdir -p /tmp && npm install --package-lock-only --ignore-scripts'
```
สร้าง package-lock.json โดย ไม่ติดตั้ง node_modules
--user $(id -u):$(id -g) ทำให้ไฟล์ที่ได้เป็นเจ้าของโดยยูสเซอร์ปัจจุบัน (กันปัญหา root-owned)
## ขั้นตอน Build บน QNAP
docker compose -f docker-backend-build.yml build --no-cache 2>&1 | tee backend_build.log
## สำหรับ build local
cd backend
docker build -t dms-backend:dev --target dev .

Binary file not shown.

0
backend/ed25519 → backend/.backup/ed25519 Executable file → Normal file
View File

0
backend/ed25519.pub → backend/.backup/ed25519.pub Executable file → Normal file
View File

View File

@@ -1,8 +1,11 @@
.git .git
.vscode
.backup
node_modules node_modules
logs logs
*.log *.log
Dockerfile* Dockerfile*.*
*.yml
README*.md README*.md
coverage coverage
tmp tmp

15
backend/.eslintrc.json Normal file
View File

@@ -0,0 +1,15 @@
{
"env": {
"node": true,
"es2021": true,
"jest": true
},
"extends": ["eslint:recommended", "plugin:prettier/recommended"],
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"rules": {
"prettier/prettier": "warn"
}
}

3
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
# Ignore Nginx Proxy Manager data
/npm/

7
backend/.prettierrc.json Normal file
View File

@@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "es5",
"arrowParens": "always",
"printWidth": 80
}

View File

@@ -67,3 +67,4 @@ EXPOSE 3001
HEALTHCHECK --interval=30s --timeout=5s --retries=10 \ HEALTHCHECK --interval=30s --timeout=5s --retries=10 \
CMD wget -qO- http://127.0.0.1:3001/health || exit 1 CMD wget -qO- http://127.0.0.1:3001/health || exit 1
CMD ["node","src/index.js"] CMD ["node","src/index.js"]

77
backend/docker-compose.yml Executable file
View File

@@ -0,0 +1,77 @@
# File: backend/docker-compose.yml
# DMS Container v0_8_0 แยก service/ lcbp3-backend
x-restart: &restart_policy
restart: unless-stopped
x-logging: &default_logging
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "5"
volumes:
backend_node_modules:
services:
backend:
<<: [*restart_policy, *default_logging]
image: dms-backend:dev
# pull_policy: never # <-- FINAL FIX ADDED HERE
container_name: dms_backend
stdin_open: true
tty: true
#user: "node"
user: "1000:1000"
working_dir: /app
deploy:
resources:
limits:
cpus: "2.0"
memory: 1G
reservations:
cpus: "0.25"
memory: 256M
environment:
TZ: "Asia/Bangkok"
CHOKIDAR_USEPOLLING: "1"
CHOKIDAR_INTERVAL: "300"
WATCHPACK_POLLING: "true"
# NODE_ENV: "production"
NODE_ENV: "development"
PORT: "3001"
DB_HOST: "mariadb"
DB_PORT: "3306"
DB_USER: "center"
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://backend.np-dms.work,http://localhost:3000,http://127.0.0.1:3000,https://lcbp3.np-dms.work"
COOKIE_DOMAIN: ".np-dms.work"
RATE_LIMIT_WINDOW_MS: "900000"
RATE_LIMIT_MAX: "200"
BACKEND_LOG_DIR: "/app/logs"
networks:
lcbp3: {}
volumes:
- "/share/Container/dms/backend/src:/app/src:rw"
# - "/share/Container/dms/backend/package.json:/app/package.json"
# - "/share/Container/dms/backend/package-lock.json:/app/package-lock.json"
- "/share/dms-data:/share/dms-data:rw"
- "/share/Container/dms/logs/backend:/app/logs:rw"
# - "/share/Container/dms/backend/node_modules:/app/node_modules"
- "backend_node_modules:/app/node_modules"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3001/health"]
interval: 10s
timeout: 5s
retries: 30
networks:
lcbp3:
external: true

6041
backend/package-lock.json generated Executable file → Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "dms-backend", "name": "dms-backend",
"version": "0.6.0", "version": "0.8.0",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.js", "main": "src/index.js",
@@ -11,7 +11,12 @@
"dev": "nodemon --watch src src/index.js", "dev": "nodemon --watch src src/index.js",
"dev:desktop": "node --watch src/index.js", "dev:desktop": "node --watch src/index.js",
"start": "node src/index.js", "start": "node src/index.js",
"lint": "echo 'lint placeholder'", "lint": "eslint . --ext .js",
"lint:fix": "eslint . --ext .js --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"test:watch:coverage": "jest --watch --coverage",
"health": "node -e \"fetch('http://localhost:'+ (process.env.BACKEND_PORT||3001) +'/health').then(r=>r.text()).then(console.log).catch(e=>{console.error(e);process.exit(1)})\"", "health": "node -e \"fetch('http://localhost:'+ (process.env.BACKEND_PORT||3001) +'/health').then(r=>r.text()).then(console.log).catch(e=>{console.error(e);process.exit(1)})\"",
"postinstall": "node -e \"console.log('Installed dms-backend %s','0.6.0')\"" "postinstall": "node -e \"console.log('Installed dms-backend %s','0.6.0')\""
}, },
@@ -33,6 +38,12 @@
"winston": "^3.13.0" "winston": "^3.13.0"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.1.10" "nodemon": "^3.1.10",
"eslint": "^8.56.0",
"prettier": "^3.1.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"jest": "^29.7.0",
"supertest": "^6.3.4"
} }
} }

View File

@@ -1,25 +0,0 @@
{
"name": "dms-backend",
"version": "0.5.0",
"private": true,
"type": "module",
"scripts": {
"dev": "node --env-file=../.env src/index.js",
"start": "node src/index.js",
"health": "node -e \"fetch('http://localhost:'+ (process.env.BACKEND_PORT||3001) +'/health').then(r=>r.text()).then(console.log).catch(e=>{console.error(e);process.exit(1)})\""
},
"dependencies": {
"bcrypt": "5.1.1",
"cors": "2.8.5",
"dotenv": "16.4.5",
"express": "4.19.2",
"express-rate-limit": "7.4.0",
"helmet": "7.1.0",
"jsonwebtoken": "9.0.2",
"mariadb": "3.3.1",
"morgan": "1.10.0",
"sequelize": "6.37.3"
}
}

View File

@@ -1,38 +0,0 @@
{
"name": "dms-backend",
"version": "0.6.0",
"private": true,
"type": "module",
"main": "src/index.js",
"engines": {
"node": ">=20.0.0"
},
"scripts": {
"dev": "nodemon --watch src src/index.js",
"start": "node src/index.js",
"lint": "echo 'lint placeholder'",
"health": "node -e \"fetch('http://localhost:'+ (process.env.BACKEND_PORT||3001) +'/health').then(r=>r.text()).then(console.log).catch(e=>{console.error(e);process.exit(1)})\"",
"postinstall": "node -e \"console.log('Installed dms-backend %s','0.6.0')\""
},
"dependencies": {
"bcrypt": "5.1.1",
"bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.7",
"cors": "2.8.5",
"dotenv": "16.4.5",
"express": "^4.21.2",
"express-rate-limit": "7.4.0",
"helmet": "7.1.0",
"jsonwebtoken": "9.0.2",
"mariadb": "3.3.1",
"morgan": "^1.10.1",
"multer": "^2.0.2",
"mysql2": "^3.11.0",
"sequelize": "6.37.3",
"winston": "^3.13.0"
},
"devDependencies": {
"nodemon": "^3.1.10"
}
}

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();
}

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

View File

@@ -1,4 +1,5 @@
// FILE: backend/src/index.js (ESM) ไฟล์ฉบับ “Bearer-only” // FILE: backend/src/index.js (ESM) ไฟล์ฉบับ “Bearer-only”
// FILE: src/index.js (ESM)
import fs from "node:fs"; import fs from "node:fs";
import express from "express"; import express from "express";
import cors from "cors"; import cors from "cors";
@@ -36,9 +37,7 @@ const ALLOW_ORIGINS = [
"http://127.0.0.1:3000", "http://127.0.0.1:3000",
FRONTEND_ORIGIN, FRONTEND_ORIGIN,
...(process.env.CORS_ALLOWLIST ...(process.env.CORS_ALLOWLIST
? process.env.CORS_ALLOWLIST.split(",") ? process.env.CORS_ALLOWLIST.split(",").map((x) => x.trim()).filter(Boolean)
.map((x) => x.trim())
.filter(Boolean)
: []), : []),
].filter(Boolean); ].filter(Boolean);
@@ -106,12 +105,8 @@ app.get("/health", async (_req, res) => {
}); });
app.get("/livez", (_req, res) => res.send("ok")); app.get("/livez", (_req, res) => res.send("ok"));
app.get("/readyz", async (_req, res) => { app.get("/readyz", async (_req, res) => {
try { try { await sql.query("SELECT 1"); res.send("ready"); }
await sql.query("SELECT 1"); catch { res.status(500).send("not-ready"); }
res.send("ready");
} catch {
res.status(500).send("not-ready");
}
}); });
app.get("/info", (_req, res) => app.get("/info", (_req, res) =>
res.json({ res.json({
@@ -164,9 +159,7 @@ async function shutdown(signal) {
try { try {
console.log(`[SHUTDOWN] ${signal} received`); console.log(`[SHUTDOWN] ${signal} received`);
await new Promise((resolve) => server.close(resolve)); await new Promise((resolve) => server.close(resolve));
try { try { await sql.end(); } catch {}
await sql.end();
} catch {}
console.log("[SHUTDOWN] complete"); console.log("[SHUTDOWN] complete");
process.exit(0); process.exit(0);
} catch (e) { } catch (e) {

View File

@@ -3,118 +3,41 @@
// - Project-scoped access control base on user_project_roles + permissions // - Project-scoped access control base on user_project_roles + permissions
// - Requires req.user.roles and req.user.permissions to be populated (e.g. via auth.js with enrichment) // - Requires req.user.roles and req.user.permissions to be populated (e.g. via auth.js with enrichment)
// - Uses UserProjectRole model to check project membership // - Uses UserProjectRole model to check project membership
// Helper ABAC เสริมบางเคส (ถ้าต้องการฟิลเตอร์/บังคับ project_id ตรง ๆ)
// หมายเหตุ: โดยหลักแล้วคุณควรใช้ requirePerm() ที่บังคับ ABAC อัตโนมัติจาก permissions.scope_level
import { sequelize } from "../db/sequelize.js"; export function projectScopedViewFallback(moduleName) {
import UPRModel from "../db/models/UserProjectRole.js"; // ใช้ในเคส legacy เท่านั้น
/**
* ดึง project_id ที่ผู้ใช้เข้าถึงได้ (จาก user_project_roles)
*/
export async function getUserProjectIds(user_id) {
const UPR = UPRModel(sequelize);
const rows = await UPR.findAll({ where: { user_id } });
return [...new Set(rows.map((r) => r.project_id))];
}
/**
* projectScopedView(moduleName) -> middleware
* - ต้องมี permission '<module>:view' หรือ
* - เป็นสมาชิกของโปรเจ็กต์ (ผ่าน user_project_roles)
* Behavior:
* - ถ้า query ไม่มี project_id และผู้ใช้ไม่ใช่ Admin:
* จำกัดผลลัพธ์ให้เฉพาะโปรเจ็กต์ที่ผู้ใช้เป็นสมาชิก
* - ถ้ามี project_id: บังคับตรวจสิทธิ์การเป็นสมาชิกของโปรเจ็กต์นั้น (เว้นแต่เป็น Admin)
*/
export function projectScopedView(moduleName) {
return async (req, res, next) => { return async (req, res, next) => {
const roles = req.user?.roles || []; const p = req.principal;
const isAdmin = roles.includes("Admin"); if (!p) return res.status(401).json({ error: "Unauthenticated" });
const permName = `${moduleName}:view`;
const hasViewPerm = (req.user?.permissions || []).includes(permName);
// Admin ผ่านได้เสมอ const hasViewPerm = p.can?.(`${moduleName}.view`) || p.permissions?.has?.(`${moduleName}.view`);
if (isAdmin) return next(); if (p.is_superadmin) return next();
const qProjectId = req.query?.project_id const qProjectId = req.query?.project_id ? Number(req.query.project_id) : null;
? Number(req.query.project_id)
: null;
const memberProjects = await getUserProjectIds(req.user?.user_id);
if (qProjectId) { if (qProjectId) {
// ต้องเป็นสมาชิกโปรเจ็กต์นั้น หรือมี perm view if (hasViewPerm || p.inProject(qProjectId)) return next();
if (hasViewPerm || memberProjects.includes(qProjectId)) return next(); return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
return res
.status(403)
.json({ error: "Forbidden: not a member of project" });
} else { } else {
// ไม่มี project_id: ถ้ามี perm view → อนุญาตทั้งหมด
// ถ้าไม่มี perm view → จำกัดด้วยรายการโปรเจ็กต์ที่เป็นสมาชิก (บันทึกไว้ใน req.abac.filterProjectIds)
if (hasViewPerm) return next(); if (hasViewPerm) return next();
if (!memberProjects.length) if (!p.project_ids?.length) return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
return res
.status(403)
.json({ error: "Forbidden: no accessible projects" });
req.abac = req.abac || {}; req.abac = req.abac || {};
req.abac.filterProjectIds = memberProjects; req.abac.filterProjectIds = p.project_ids;
return next(); return next();
} }
}; };
} }
/**
* บังคับเป็นสมาชิกโปรเจ็กต์จากค่า project_id ใน body
* ใช้กับ create endpoints
*/
export function requireProjectMembershipFromBody() {
return async (req, res, next) => {
const roles = req.user?.roles || [];
const isAdmin = roles.includes("Admin");
if (isAdmin) return next();
const pid = Number(req.body?.project_id);
if (!pid) return res.status(400).json({ error: "project_id required" });
const memberProjects = await getUserProjectIds(req.user?.user_id);
if (!memberProjects.includes(pid))
return res.status(403).json({ error: "Forbidden: not a project member" });
next();
};
}
/**
* บังคับเป็นสมาชิกโปรเจ็กต์โดยอ้างอิงจากเรคคอร์ด (ใช้กับ update/delete)
* opts: { modelLoader: (sequelize)=>Model, idParam: 'id', projectField: 'project_id' }
*/
export function requireProjectMembershipByRecord(opts) {
const { modelLoader, idParam = "id", projectField = "project_id" } = opts;
return async (req, res, next) => {
const roles = req.user?.roles || [];
const isAdmin = roles.includes("Admin");
if (isAdmin) return next();
const id = Number(req.params[idParam]);
if (!id) return res.status(400).json({ error: "Invalid id" });
const Model = modelLoader(sequelize);
const row = await Model.findByPk(id);
if (!row) return res.status(404).json({ error: "Not found" });
const pid = Number(row[projectField]);
const memberProjects = await getUserProjectIds(req.user?.user_id);
if (!memberProjects.includes(pid))
return res.status(403).json({ error: "Forbidden: not a project member" });
next();
};
}
/**
* บังคับให้ view ทุกอันต้องส่ง project_id (ยกเว้น Admin)
*/
export function requireProjectIdQuery() { export function requireProjectIdQuery() {
return async (req, res, next) => { return (req, res, next) => {
const roles = req.user?.roles || []; const p = req.principal;
const isAdmin = roles.includes("Admin"); if (!p) return res.status(401).json({ error: "Unauthenticated" });
if (isAdmin) return next(); if (p.is_superadmin) return next();
const qProjectId = req.query?.project_id const qProjectId = req.query?.project_id ? Number(req.query.project_id) : null;
? Number(req.query.project_id) if (!qProjectId) return res.status(400).json({ error: "project_id query required" });
: null;
if (!qProjectId)
return res.status(400).json({ error: "project_id query required" });
next(); next();
}; };
} }

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);
}

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

@@ -1,50 +1,30 @@
// FILE: src/middleware/auth.js // FILE: backend/src/middleware/auth.js
// Authentication & Authorization middleware // (ถ้ายังใช้อยู่) ปรับให้สอดคล้อง Bearer + principal
// - JWT-based authentication
// - Role & Permission enrichment
// - RBAC (Role-Based Access Control) helpers
// - Requires User, Role, Permission, UserRole, RolePermission models
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import { config } from "../config.js";
import { User, Role, UserRole } from "../db/sequelize.js";
export function signAccessToken(payload) { export function signAccessToken(payload) {
return jwt.sign(payload, config.JWT.SECRET, { const { JWT_SECRET = "dev-secret", JWT_EXPIRES_IN = "30m" } = process.env;
expiresIn: config.JWT.EXPIRES_IN, return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN, issuer: "dms-backend" });
});
} }
export function signRefreshToken(payload) { export function signRefreshToken(payload) {
return jwt.sign(payload, config.JWT.REFRESH_SECRET, { const { JWT_REFRESH_SECRET = "dev-refresh", JWT_REFRESH_EXPIRES_IN = "30d" } = process.env;
expiresIn: config.JWT.REFRESH_EXPIRES_IN, return jwt.sign({ ...payload, t: "refresh" }, JWT_REFRESH_SECRET, { expiresIn: JWT_REFRESH_EXPIRES_IN, issuer: "dms-backend" });
});
} }
// ถ้าจะใช้ standalone (ไม่แนะนำถ้ามี authJwt แล้ว)
export function requireAuth(req, res, next) { export function requireAuth(req, res, next) {
if (req.path === "/health") return next(); // อนุญาต health เสมอ const h = req.headers.authorization || "";
const hdr = req.headers.authorization || ""; const m = /^Bearer\s+(.+)$/i.exec(h || "");
const token = hdr.startsWith("Bearer ") ? hdr.slice(7) : null; if (!m) return res.status(401).json({ error: "Missing token" });
if (!token) return res.status(401).json({ error: "Missing token" });
try { try {
req.user = jwt.verify(token, config.JWT.SECRET); const { JWT_SECRET = "dev-secret" } = process.env;
const payload = jwt.verify(m[1], JWT_SECRET, { issuer: "dms-backend" });
req.auth = { user_id: payload.user_id, username: payload.username };
req.user = req.user || {};
req.user.user_id = payload.user_id;
req.user.username = payload.username;
next(); next();
} catch { } catch {
return res.status(401).json({ error: "Invalid/Expired token" }); return res.status(401).json({ error: "Invalid/Expired token" });
} }
} }
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);
}

View File

@@ -25,6 +25,10 @@ export function authJwt() {
// แนบข้อมูลขั้นต่ำให้ middleware ถัดไป // แนบข้อมูลขั้นต่ำให้ middleware ถัดไป
req.auth = { user_id: payload.user_id, username: payload.username }; req.auth = { user_id: payload.user_id, username: payload.username };
//req.user = { user_id: payload.user_id, username: payload.username }; //req.user = { user_id: payload.user_id, username: payload.username };
// เผื่อโค้ดเก่าอ้างอิง req.user
req.user = req.user || {};
req.user.user_id = payload.user_id;
req.user.username = payload.username;
next(); next();
} catch (e) { } catch (e) {
return res.status(401).json({ error: "Unauthenticated" }); return res.status(401).json({ error: "Unauthenticated" });

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;

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

@@ -5,15 +5,90 @@
// - Uses rbac.js utility to load principal info // - Uses rbac.js utility to load principal info
// - Attaches to req.principal // - Attaches to req.principal
// - Requires req.user.user_id to be populated (e.g. via auth.js or authJwt.js) // - Requires req.user.user_id to be populated (e.g. via auth.js or authJwt.js)
// โหลด principal จาก DB แล้วแนบไว้ใน req.principal
// NOTE: ตรงนี้สมมุติว่าคุณมี service/query ฝั่ง DB อยู่แล้ว (เช่น sql/Sequelize)
// ถ้าคุณมีฟังก์ชันโหลด principal อยู่ที่อื่น ให้แทน logic DB ตรง FIXME ด้านล่าง
// ใช้ req.auth.user_id และตั้ง req.principal ให้ครบ (RBAC + ABAC)
import { loadPrincipal } from "../utils/rbac.js"; import sql from "../db/index.js";
export function loadPrincipalMw() { export function loadPrincipalMw() {
return async (req, res, next) => { return async (req, res, next) => {
try { try {
if (!req.user?.user_id) const uid = req?.auth?.user_id || req?.user?.user_id;
return res.status(401).json({ error: "Unauthenticated" }); if (!uid) return res.status(401).json({ error: "Unauthenticated" });
req.principal = await loadPrincipal(req.user.user_id);
// --- 1) users (รวม org_id)
const [[u]] = await sql.query(
`SELECT user_id, username, email, first_name, last_name, org_id, is_active
FROM users WHERE user_id=? LIMIT 1`,
[uid]
);
if (!u || u.is_active === 0) return res.status(401).json({ error: "Unauthenticated" });
// --- 2) roles (global)
const [roleRows] = await sql.query(
`SELECT r.role_id, r.role_code, r.role_name
FROM user_roles ur
JOIN roles r ON r.role_id = ur.role_id
WHERE ur.user_id=?`,
[uid]
);
const roleCodes = new Set(roleRows.map(r => r.role_code));
const is_superadmin = roleCodes.has("SUPER_ADMIN");
// --- 3) permissions (ผ่าน role_permissions)
const [permRows] = await sql.query(
`SELECT DISTINCT p.perm_code
FROM user_roles ur
JOIN role_permissions rp ON rp.role_id = ur.role_id
JOIN permissions p ON p.permission_id = rp.permission_id AND p.is_active=1
WHERE ur.user_id=?`,
[uid]
);
const permSet = new Set(permRows.map(x => x.perm_code));
// --- 4) project scope (user_project_roles)
const [projRows] = await sql.query(
`SELECT DISTINCT project_id FROM user_project_roles WHERE user_id=?`,
[uid]
);
const project_ids = projRows.map(r => r.project_id);
// --- 5) org scope: users.org_id + orgs จาก project_parties ของโปรเจ็คที่เข้าถึง
const baseOrgIds = u.org_id ? [u.org_id] : [];
let projOrgIds = [];
if (project_ids.length) {
const [rows] = await sql.query(
`SELECT DISTINCT org_id FROM project_parties WHERE project_id IN (?)`,
[project_ids]
);
projOrgIds = rows.map(r => r.org_id);
}
const org_ids = Array.from(new Set([...baseOrgIds, ...projOrgIds]));
req.principal = {
user_id: u.user_id,
username: u.username,
email: u.email,
first_name: u.first_name,
last_name: u.last_name,
org_id: u.org_id || null,
roles: roleRows.map(r => ({ role_id: r.role_id, role_code: r.role_code, role_name: r.role_name })),
permissions: permSet, // Set ของ perm_code
project_ids,
org_ids,
is_superadmin,
// helpers
can: (code) => is_superadmin || permSet.has(code),
canAny: (codes=[]) => is_superadmin || codes.some(c => permSet.has(c)),
canAll: (codes=[]) => is_superadmin || codes.every(c => permSet.has(c)),
inProject: (pid) => is_superadmin || project_ids.includes(Number(pid)),
inOrg: (oid) => is_superadmin || org_ids.includes(Number(oid)),
};
next(); next();
} catch (err) { } catch (err) {
console.error("loadPrincipal error", err); console.error("loadPrincipal error", err);

View File

@@ -2,16 +2,14 @@
// Permission guard middleware // Permission guard middleware
// - Checks if user has required permissions // - Checks if user has required permissions
// - Requires req.user.permissions to be populated (e.g. via auth.js or authJwt.js with enrichment) // - Requires req.user.permissions to be populated (e.g. via auth.js or authJwt.js with enrichment)
// เปลี่ยนให้เป็น wrapper ที่เรียก req.principal (ทางเก่ายังใช้ได้)**
/**
* requirePerm('rfa:create') => ตรวจว่ามี permission นี้ใน req.user.permissions
* ต้องแน่ใจว่าเรียก enrichPermissions() มาก่อน หรือคำนวณที่จุดเข้าใช้งาน
*/
export function requirePerm(...allowedPerms) { export function requirePerm(...allowedPerms) {
return (req, res, next) => { return (req, res, next) => {
const perms = req.user?.permissions || []; const p = req.principal;
const ok = perms.some((p) => allowedPerms.includes(p)); if (!p) return res.status(401).json({ error: "Unauthenticated" });
if (!ok) return res.status(403).json({ error: "Forbidden" }); const ok = p.is_superadmin || allowedPerms.some((code) => p.permissions?.has?.(code));
if (!ok) return res.status(403).json({ error: "FORBIDDEN", need_any_of: allowedPerms });
next(); next();
}; };
} }

View File

@@ -2,38 +2,39 @@
// Permission calculation and enrichment middleware // Permission calculation and enrichment middleware
// - Computes effective permissions for a user based on their roles // - Computes effective permissions for a user based on their roles
// - Attaches permissions to req.user.permissions // - Attaches permissions to req.user.permissions
// ใช้เฉพาะกรณีที่คุณยังมี stack Sequelize เดิมอยู่ และอยาก enrich จาก Role/Permission model
// โดยทั่วไป ถ้าคุณใช้ loadPrincipalMw() อยู่แล้ว สามารถไม่ใช้ไฟล์นี้ได้
import { Role, Permission, UserRole, RolePermission } from "../db/sequelize.js"; import { Permission, UserRole, RolePermission } from "../db/sequelize.js";
/**
* คืนชุด permission (string[]) ของ user_id
*/
export async function computeEffectivePermissions(user_id) { export async function computeEffectivePermissions(user_id) {
// ดึง roles ของผู้ใช้
const userRoles = await UserRole.findAll({ where: { user_id } }); const userRoles = await UserRole.findAll({ where: { user_id } });
const roleIds = userRoles.map((r) => r.role_id); const roleIds = userRoles.map((r) => r.role_id);
if (!roleIds.length) return []; if (!roleIds.length) return [];
// ดึง permission ผ่าน role_permissions
const rp = await RolePermission.findAll({ where: { role_id: roleIds } }); const rp = await RolePermission.findAll({ where: { role_id: roleIds } });
const permIds = [...new Set(rp.map((x) => x.permission_id))]; const permIds = [...new Set(rp.map((x) => x.permission_id))];
if (!permIds.length) return []; if (!permIds.length) return [];
const perms = await Permission.findAll({ where: { permission_id: permIds } }); const perms = await Permission.findAll({ where: { permission_id: permIds } });
return [...new Set(perms.map((p) => p.permission_name))]; // ใช้ perm_code ให้สอดคล้อง seed
return [...new Set(perms.map((p) => p.perm_code))];
} }
/**
* middleware: เติม permissions ลง req.user.permissions
*/
export function enrichPermissions() { export function enrichPermissions() {
return async (req, _res, next) => { return async (req, _res, next) => {
if (!req.user?.user_id) return next(); const uid = req?.auth?.user_id || req?.user?.user_id;
if (!uid) return next();
try { try {
const perms = await computeEffectivePermissions(req.user.user_id); const perms = await computeEffectivePermissions(uid);
// อัปเดตทั้ง req.principal และ req.user (เผื่อโค้ดเก่า)
req.principal = req.principal || {};
req.principal.permissions = new Set(perms);
req.user = req.user || {};
req.user.permissions = perms; req.user.permissions = perms;
} catch (e) { } catch {
req.user.permissions = []; if (req.principal) req.principal.permissions = new Set();
if (req.user) req.user.permissions = [];
} }
next(); next();
}; };

View File

@@ -5,18 +5,19 @@
export function requireRole(...allowed) { export function requireRole(...allowed) {
return (req, res, next) => { return (req, res, next) => {
const roles = req.user?.roles || []; const roles = (req.principal?.roles || []).map(r => r.role_code);
const ok = roles.some((r) => allowed.includes(r)); const ok = roles.some((r) => allowed.includes(r)) || req.principal?.is_superadmin;
if (!ok) return res.status(403).json({ error: "Forbidden" }); if (!ok) return res.status(403).json({ error: "FORBIDDEN_ROLE", need_any_of: allowed });
next(); next();
}; };
} }
export function requirePermission(...allowedPerms) { export function requirePermissionCode(...codes) {
return (req, res, next) => { return (req, res, next) => {
const perms = req.user?.permissions || []; const p = req.principal;
const ok = perms.some((p) => allowedPerms.includes(p)); if (!p) return res.status(401).json({ error: "Unauthenticated" });
if (!ok) return res.status(403).json({ error: "Forbidden" }); const ok = p.is_superadmin || codes.some((c) => p.permissions?.has?.(c));
if (!ok) return res.status(403).json({ error: "FORBIDDEN", need_any_of: codes });
next(); next();
}; };
} }

View File

@@ -0,0 +1,18 @@
// FILE: src/middleware/requireBearer.js
import jwt from "jsonwebtoken";
import { findUserById } from "../db/models/users.js";
export async function requireBearer(req, res, next) {
const hdr = req.get("Authorization") || "";
const m = hdr.match(/^Bearer\s+(.+)$/i);
if (!m) return res.status(401).json({ error: "Unauthenticated" });
try {
const payload = jwt.verify(m[1], process.env.JWT_ACCESS_SECRET, { issuer: "dms-backend" });
const user = await findUserById(payload.user_id);
if (!user) return res.status(401).json({ error: "Unauthenticated" });
req.user = { user_id: user.user_id, username: user.username, email: user.email, first_name: user.first_name, last_name: user.last_name };
next();
} catch {
return res.status(401).json({ error: "Unauthenticated" });
}
}

View File

@@ -6,32 +6,59 @@
// - Uses canPerform() utility from rbac.js // - Uses canPerform() utility from rbac.js
// - Supports global, org, and project scopes // - Supports global, org, and project scopes
// - Requires req.principal to be populated (e.g. via loadPrincipal middleware) // - Requires req.principal to be populated (e.g. via loadPrincipal middleware)
// เช็คตาม perm_code + ABAC อัตโนมัติจาก permissions.scope_level
import sql from "../db/index.js";
import { canPerform } from "../utils/rbac.js"; let _permMap = null;
let _loadedAt = 0;
const TTL_MS = 60_000;
async function getPermRegistry() {
const now = Date.now();
if (_permMap && now - _loadedAt < TTL_MS) return _permMap;
const [rows] = await sql.query(
`SELECT perm_code, scope_level FROM permissions WHERE is_active=1`
);
_permMap = new Map(rows.map(r => [r.perm_code, r.scope_level])); // GLOBAL | ORG | PROJECT
_loadedAt = now;
return _permMap;
}
/** /**
* requirePerm('correspondence.create', { scope: 'org', getOrgId: req => ... }) * requirePerm('rfas.view', { projectParam: 'project_id', orgParam: 'org_id' })
* scope: 'global' | 'org' | 'project' * - GLOBAL: แค่มี perm ก็ผ่าน
* - ORG: ต้องมี perm + อยู่ใน org scope (อ่าน org_id จาก param หากระบุ; ไม่ระบุจะใช้ req.principal.org_id)
* - PROJECT:ต้องมี perm + อยู่ใน project scope (อ่าน project_id จาก param)
*/ */
export function requirePerm( export function requirePerm(permCode, { projectParam, orgParam } = {}) {
permCode,
{ scope = "global", getOrgId = null, getProjectId = null } = {}
) {
return async (req, res, next) => { return async (req, res, next) => {
try { const p = req.principal;
const orgId = getOrgId ? await getOrgId(req) : null; if (!p) return res.status(401).json({ error: "Unauthenticated" });
const projectId = getProjectId ? await getProjectId(req) : null;
if (canPerform(req.principal, permCode, { scope, orgId, projectId })) if (!(p.is_superadmin || p.permissions?.has?.(permCode))) {
return next(); return res.status(403).json({ error: "FORBIDDEN", need: permCode });
return res.status(403).json({
error: "FORBIDDEN",
message: `Require ${permCode} (${scope}-scoped)`,
});
} catch (e) {
console.error("requirePerm error", e);
res.status(500).json({ error: "Permission check error" });
} }
const registry = await getPermRegistry();
const scope = registry.get(permCode) || "GLOBAL";
const readParam = (name) => req.params?.[name] ?? req.query?.[name] ?? req.body?.[name];
if (scope === "PROJECT") {
const pid = Number(projectParam ? readParam(projectParam) : undefined);
if (!p.is_superadmin) {
if (!pid || !p.inProject(pid)) {
return res.status(403).json({ error: "FORBIDDEN_PROJECT", project_id: pid || null });
}
}
} else if (scope === "ORG") {
const oid = Number(orgParam ? readParam(orgParam) : p.org_id);
if (!p.is_superadmin) {
if (!oid || !p.inOrg(oid)) {
return res.status(403).json({ error: "FORBIDDEN_ORG", org_id: oid || null });
}
}
}
next();
}; };
} }

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,18 +1,10 @@
// FILE: src/routes/auth.js (ESM) — Bearer only, refresh via header/body, forgot/reset password // FILE: backend/src/routes/auth.js (ESM) — Bearer only, refresh via header/body, forgot/reset password
// 03.2 เพิ่ม auth.js และ lookup.js ให้สอดคล้อง RBAC/permission_code
// ตาม src/config/permissions.js) และอ่าน scope จาก DB เสมอ
/*สมมติว่ามีตาราง password_resets สำหรับเก็บโทเคนรีเซ็ต:
password_resets(
id BIGINT PK, user_id BIGINT, token_hash CHAR(64),
expires_at DATETIME, used_at DATETIME NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
INDEX(token_hash), INDEX(user_id), INDEX(expires_at)
*/
import { Router } from "express"; import { Router } from "express";
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import sql from "../db/index.js"; import sql from "../db/index.js";
import { cookieOpts } from "../utils/cookie.js";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { requireAuth } from "../middleware/auth.js";
import crypto from "node:crypto"; import crypto from "node:crypto";
const r = Router(); const r = Router();
@@ -89,6 +81,18 @@ r.post("/login", async (req, res) => {
const token = signAccessToken(user); const token = signAccessToken(user);
const refresh_token = signRefreshToken(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({ return res.json({
token, token,
refresh_token, refresh_token,
@@ -101,15 +105,24 @@ r.post("/login", async (req, res) => {
}, },
}); });
}); });
/* ========================= /* =========================
* 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 * POST /api/auth/refresh
* - รองรับ refresh token จาก: * - รองรับ refresh token จาก:
* 1) Authorization: Bearer <refresh_token> * 1) Authorization: Bearer <refresh_token>
* 2) req.body.refresh_token * 2) req.body.refresh_token
* - ออก token ใหม่ + refresh ใหม่ (rotation) * - ออก token ใหม่ + refresh ใหม่ (rotation)
* ========================= */ * ========================= */
r.post("/refresh", async (req, res) => { r.post("/refresh", async (req, res) => {
const fromHeader = getBearer(req); const fromHeader = getBearer(req);
const fromBody = (req.body || {}).refresh_token; const fromBody = (req.body || {}).refresh_token;
const refreshToken = fromHeader || fromBody; const refreshToken = fromHeader || fromBody;
@@ -134,6 +147,18 @@ r.post("/refresh", async (req, res) => {
const token = signAccessToken(user); const token = signAccessToken(user);
const new_refresh = signRefreshToken(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({ return res.json({
token, token,
refresh_token: new_refresh, refresh_token: new_refresh,
@@ -148,7 +173,8 @@ r.post("/refresh", async (req, res) => {
} catch { } catch {
return res.status(401).json({ error: "INVALID_REFRESH_TOKEN" }); return res.status(401).json({ error: "INVALID_REFRESH_TOKEN" });
} }
}); })
);
/* ========================= /* =========================
* POST /api/auth/forgot-password * POST /api/auth/forgot-password
@@ -242,6 +268,8 @@ r.post("/reset-password", async (req, res) => {
* - frontend ลบ token เอง * - frontend ลบ token เอง
* ========================= */ * ========================= */
r.post("/logout", (_req, res) => { r.post("/logout", (_req, res) => {
res.clearCookie("access_token", { path: "/" });
res.clearCookie("refresh_token", { path: "/" });
return res.json({ ok: true }); return res.json({ ok: true });
}); });

View File

@@ -1,4 +1,6 @@
// FILE: src/routes/categories.js // FILE: src/routes/categories.js
// อ่าน: ใช้ organizations.view (GLOBAL)
// สร้าง/แก้/ลบ: ใช้ settings.manage (GLOBAL)
import { Router } from "express"; import { Router } from "express";
import sql from "../db/index.js"; import sql from "../db/index.js";
import { requirePerm } from "../middleware/requirePerm.js"; import { requirePerm } from "../middleware/requirePerm.js";

View File

@@ -1,4 +1,6 @@
// FILE: src/routes/contract_dwg.js // FILE: src/routes/contract_dwg.js
// ใน seed ยังไม่มี contract_dwg.* → ผูกชั่วคราวกับสิทธิ์กลุ่ม drawings:
// read → drawings.view, create/update/delete → drawings.upload/delete (PROJECT scope)
import { Router } from "express"; import { Router } from "express";
import sql from "../db/index.js"; import sql from "../db/index.js";
import { requirePerm } from "../middleware/requirePerm.js"; import { requirePerm } from "../middleware/requirePerm.js";

View File

@@ -1,4 +1,7 @@
// FILE: src/routes/contracts.js // FILE: src/routes/contracts.js
// ไม่มี contract.* ใน seed → map เป็นงานดูแลองค์กร/โปรเจ็กต์:
// list/get → projects.view (ORG)
// create/update/delete → projects.manage (ORG)
import { Router } from "express"; import { Router } from "express";
import sql from "../db/index.js"; import sql from "../db/index.js";
import { requirePerm } from "../middleware/requirePerm.js"; import { requirePerm } from "../middleware/requirePerm.js";

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

@@ -1,4 +1,5 @@
// FILE: backend/src/routes/mvp.js // FILE: backend/src/routes/mvp.js
// (generic entity maps — ใช้ projects.view อ่าน และ projects.manage เขียน/ลบ)
import { Router } from "express"; import { Router } from "express";
import sql from "../db/index.js"; import sql from "../db/index.js";
import { requirePerm } from "../middleware/requirePerm.js"; import { requirePerm } from "../middleware/requirePerm.js";

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

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;

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

View File

@@ -0,0 +1,17 @@
// FILE: backend/src/utils/cookie.js
export const cookieOpts = (maxAgeMs) => {
const isProd = process.env.NODE_ENV === "production";
const opts = {
httpOnly: true,
secure: true, // หลัง Nginx/HTTPS
sameSite: "none", // ส่งข้าม subdomain ได้
path: "/",
maxAge: maxAgeMs,
};
if (process.env.COOKIE_DOMAIN) opts.domain = process.env.COOKIE_DOMAIN; // เช่น .np-dms.work
if (!isProd && process.env.ALLOW_INSECURE_COOKIE === "1") {
opts.secure = false;
opts.sameSite = "lax";
}
return opts;
};

31
backend/src/utils/jwt.js Normal file
View File

@@ -0,0 +1,31 @@
// FILE: backend/src/utils/jwt.js
import jwt from "jsonwebtoken";
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET || "dev_access_secret";
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || "dev_refresh_secret";
export const ACCESS_TTL_MS = parseInt(
process.env.ACCESS_TTL_MS || `${15 * 60 * 1000}`,
10
); // 15 นาที
export const REFRESH_TTL_MS = parseInt(
process.env.REFRESH_TTL_MS || `${7 * 24 * 60 * 60 * 1000}`,
10
); // 7 วัน
export function signAccessToken(payload) {
return jwt.sign(payload, ACCESS_SECRET, {
expiresIn: Math.floor(ACCESS_TTL_MS / 1000),
});
}
export function signRefreshToken(payload) {
return jwt.sign(payload, REFRESH_SECRET, {
expiresIn: Math.floor(REFRESH_TTL_MS / 1000),
});
}
export function verifyAccessToken(token) {
return jwt.verify(token, ACCESS_SECRET, { clockTolerance: 10 }); // เผื่อเวลา QNAP คลาด
}
export function verifyRefreshToken(token) {
return jwt.verify(token, REFRESH_SECRET, { clockTolerance: 10 });
}

View File

@@ -0,0 +1,15 @@
import app from "../src/index.js"; // สมมติว่าคุณ export app จาก src/index.js
import request from "supertest";
// ปิด server หลังจากเทสเสร็จ
afterAll((done) => {
app.server.close(done);
});
describe("GET /health", () => {
it("should respond with 200 OK and a health message", async () => {
const response = await request(app).get("/health");
expect(response.statusCode).toBe(200);
expect(response.text).toContain("Backend is healthy");
});
});

View File

@@ -1,11 +0,0 @@
{
"folders": [
{
"path": "."
},
{
"path": "S:/Documents"
}
],
"settings": {}
}

2
docker-backend-build.yml Executable file → Normal file
View File

@@ -16,7 +16,7 @@ services:
target: prod target: prod
image: dms-backend:prod image: dms-backend:prod
command: ["true"] command: ["true"]
# docker compose -f docker-backend-build.yml build --no-cache # docker compose -f docker-backend-build.yml build --no-cache 2>&1 | tee backend_build.log
# ***** สำหรับ build บน server เอา ## ออก ***** # ***** สำหรับ build บน server เอา ## ออก *****
# สำหรับ build บน local # สำหรับ build บน local
# cd backend # cd backend

23
docker-compose.yml Executable file → Normal file
View File

@@ -1,4 +1,4 @@
# DMS Container v0_6_0 # DMS Container v0_7_0
# version: "3.8" # version: "3.8"
x-restart: &restart_policy x-restart: &restart_policy
restart: unless-stopped restart: unless-stopped
@@ -82,10 +82,15 @@ services:
DB_PASSWORD: "Center#2025" DB_PASSWORD: "Center#2025"
DB_NAME: "dms" DB_NAME: "dms"
JWT_SECRET: "9a6d8705a6695ab9bae4ca1cd46c72a6379aa72404b96e2c5b59af881bb55c639dd583afdce5a885c68e188da55ce6dbc1fb4aa9cd4055ceb51507e56204e4ca" JWT_SECRET: "9a6d8705a6695ab9bae4ca1cd46c72a6379aa72404b96e2c5b59af881bb55c639dd583afdce5a885c68e188da55ce6dbc1fb4aa9cd4055ceb51507e56204e4ca"
JWT_ACCESS_SECRET: "9a6d8705a6695ab9bae4ca1cd46c72a6379aa72404b96e2c5b59af881bb55c639dd583afdce5a885c68e188da55ce6dbc1fb4aa9cd4055ceb51507e56204e4ca"
JWT_REFRESH_SECRET: "743e798bb10d6aba168bf68fc3cf8eff103c18bd34f1957a3906dc87987c0df139ab72498f2fe20d6c4c580f044ccba7d7bfa4393ee6035b73ba038f28d7480c"
ACCESS_TTL_MS: "900000"
REFRESH_TTL_MS: "604800000"
JWT_EXPIRES_IN: "12h" JWT_EXPIRES_IN: "12h"
PASSWORD_SALT_ROUNDS: "10" PASSWORD_SALT_ROUNDS: "10"
FRONTEND_ORIGIN: "https://lcbp3.np-dms.work" FRONTEND_ORIGIN: "https://lcbp3.np-dms.work"
CORS_ORIGINS: "https://backend.np-dms.work,http://localhost:3000,http://127.0.0.1:3000" 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_WINDOW_MS: "900000"
RATE_LIMIT_MAX: "200" RATE_LIMIT_MAX: "200"
BACKEND_LOG_DIR: "/app/logs" BACKEND_LOG_DIR: "/app/logs"
@@ -133,6 +138,7 @@ services:
NEXT_PUBLIC_AUTH_MODE: "cookie" NEXT_PUBLIC_AUTH_MODE: "cookie"
NEXT_PUBLIC_DEBUG_AUTH: "1" NEXT_PUBLIC_DEBUG_AUTH: "1"
NEXT_TELEMETRY_DISABLED: "1" NEXT_TELEMETRY_DISABLED: "1"
INTERNAL_API_BASE: "http://backend:3001"
JWT_ACCESS_SECRET: "9a6d8705a6695ab9bae4ca1cd46c72a6379aa72404b96e2c5b59af881bb55c639dd583afdce5a885c68e188da55ce6dbc1fb4aa9cd4055ceb51507e56204e4ca" JWT_ACCESS_SECRET: "9a6d8705a6695ab9bae4ca1cd46c72a6379aa72404b96e2c5b59af881bb55c639dd583afdce5a885c68e188da55ce6dbc1fb4aa9cd4055ceb51507e56204e4ca"
JWT_REFRESH_SECRET: "743e798bb10d6aba168bf68fc3cf8eff103c18bd34f1957a3906dc87987c0df139ab72498f2fe20d6c4c580f044ccba7d7bfa4393ee6035b73ba038f28d7480c" JWT_REFRESH_SECRET: "743e798bb10d6aba168bf68fc3cf8eff103c18bd34f1957a3906dc87987c0df139ab72498f2fe20d6c4c580f044ccba7d7bfa4393ee6035b73ba038f28d7480c"
expose: expose:
@@ -149,7 +155,11 @@ services:
backend: backend:
condition: service_healthy condition: service_healthy
healthcheck: healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"] test:
[
"CMD-SHELL",
'wget -qO- http://127.0.0.1:3000/health | grep -q ''"ok":true''',
]
interval: 15s interval: 15s
timeout: 5s timeout: 5s
retries: 30 retries: 30
@@ -261,12 +271,13 @@ services:
NODE_ENV: "production" NODE_ENV: "production"
N8N_PATH: "/n8n/" N8N_PATH: "/n8n/"
N8N_PUBLIC_URL: "https://n8n.np-dms.work/" N8N_PUBLIC_URL: "https://n8n.np-dms.work/"
WEBHOOK_URL: "https://ln8n.np-dms.work/" WEBHOOK_URL: "https://n8n.np-dms.work/"
N8N_EDITOR_BASE_URL: "https://n8n.np-dms.work/" N8N_EDITOR_BASE_URL: "https://n8n.np-dms.work/"
N8N_PROTOCOL: "https" N8N_PROTOCOL: "https"
N8N_HOST: "n8n.np-dms.work" N8N_HOST: "n8n.np-dms.work"
N8N_PORT: "5678" N8N_PORT: "5678"
N8N_PROXY_HOPS: "1" N8N_PROXY_HOPS: "1"
N8N_DIAGNOSTICS_ENABLED: "false"
N8N_SECURE_COOKIE: "true" N8N_SECURE_COOKIE: "true"
N8N_ENCRYPTION_KEY: "9AAIB7Da9DW1qAhJE5/Bz4SnbQjeAngI" N8N_ENCRYPTION_KEY: "9AAIB7Da9DW1qAhJE5/Bz4SnbQjeAngI"
N8N_BASIC_AUTH_ACTIVE: "true" N8N_BASIC_AUTH_ACTIVE: "true"
@@ -324,8 +335,8 @@ services:
depends_on: depends_on:
backend: backend:
condition: service_healthy condition: service_healthy
frontend: # frontend:
condition: service_healthy # condition: service_healthy
phpmyadmin: phpmyadmin:
condition: service_started condition: service_started
n8n: n8n:

1
docker-frontend-build.yml Executable file → Normal file
View File

@@ -24,6 +24,7 @@ services:
command: ["true"] command: ["true"]
# docker compose -f docker-frontend-build.yml build --no-cache # docker compose -f docker-frontend-build.yml build --no-cache
# docker compose -f docker-frontend-build.yml build --no-cache 2>&1 | tee frontend_build.log
# สร้าง package-lock.json # สร้าง package-lock.json
# cd frontend # cd frontend

0
frontend/.editorconfig Normal file → Executable file
View File

0
frontend/.eslintrc.json Normal file → Executable file
View File

0
frontend/.prettierrc.json Normal file → Executable file
View File

4
frontend/Dockerfile Normal file → Executable file
View File

@@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1.6 # syntax=docker/dockerfile:1.6
############ Base ############ ############ Base ############
FROM node:24-alpine AS base FROM node:20-alpine AS base
WORKDIR /app WORKDIR /app
RUN apk add --no-cache bash curl tzdata \ RUN apk add --no-cache bash curl tzdata \
&& ln -snf /usr/share/zoneinfo/Asia/Bangkok /etc/localtime \ && ln -snf /usr/share/zoneinfo/Asia/Bangkok /etc/localtime \
@@ -66,6 +66,8 @@ RUN echo "=== Checking components ===" && \
echo "=== Checking .next permissions ===" && \ echo "=== Checking .next permissions ===" && \
ls -lad /app/.next ls -lad /app/.next
RUN npm ci --no-audit --no-fund --include=dev
RUN npm run build RUN npm run build
############ Prod runtime (optimized) ############ ############ Prod runtime (optimized) ############

View File

@@ -1,4 +1,4 @@
// Simple health endpoint for compose/ops // File: frontend/api/health/route.js
export async function GET() { export async function GET() {
return new Response(JSON.stringify({ status: 'ok', service: 'frontend', ts: Date.now() }), { return new Response(JSON.stringify({ status: 'ok', service: 'frontend', ts: Date.now() }), {
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },

0
frontend/app/(auth)/layout.jsx Normal file → Executable file
View File

View File

@@ -32,6 +32,24 @@ function LoginForm() {
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [err, setErr] = useState(""); const [err, setErr] = useState("");
// Helper function to verify session is ready after login
async function verifySessionIsReady() {
const MAX_RETRIES = 5;
const RETRY_DELAY = 300; // ms
for (let i = 0; i < MAX_RETRIES; i++) {
const me = await fetch(`${API_BASE}/api/auth/me`, {
method: "GET",
credentials: "include",
cache: "no-store",
}).then(r => r.ok ? r.json() : null).catch(() => null);
if (me?.ok) return true;
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY));
}
return false;
}
async function onSubmit(e) { async function onSubmit(e) {
e.preventDefault(); e.preventDefault();
setErr(""); setErr("");
@@ -66,9 +84,20 @@ function LoginForm() {
return; return;
} }
// คุกกี้ (HttpOnly) ถูกตั้งด้วย Set-Cookie จาก backend แล้ว // ✅ ยืนยันว่าเซสชันพร้อมใช้งานก่อน (กัน redirect วน)
dlog("login ok → redirect", nextPath); // ✅ รอ session ให้พร้อมจริง (retry สูงสุด ~1.5s)
router.replace(nextPath); const ok = await verifySessionIsReady();
if (!ok) {
setErr("ล็อกอินสำเร็จ แต่ยังไม่เห็นเซสชันจากเซิร์ฟเวอร์ (ลองใหม่หรือตรวจคุกกี้)");
return;
}
// ✅ ใช้ hard navigation ให้ SSR เห็นคุกกี้แน่นอน
if (typeof window !== "undefined") {
window.location.href = nextPath || "/dashboard";
} else {
router.replace(nextPath || "/dashboard");
}
} catch (e) { } catch (e) {
dlog("exception =", e); dlog("exception =", e);
setErr("เชื่อมต่อเซิร์ฟเวอร์ไม่ได้ กรุณาลองใหม่"); setErr("เชื่อมต่อเซิร์ฟเวอร์ไม่ได้ กรุณาลองใหม่");
@@ -155,12 +184,23 @@ function LoginPageSkeleton() {
<CardDescription className="text-sky-700">Document Management System LCBP3</CardDescription> <CardDescription className="text-sky-700">Document Management System LCBP3</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid gap-4 animate-pulse"> {/* ✅ ปรับปรุง Skeleton ให้สมจริงขึ้น */}
<div className="h-10 rounded bg-slate-200"></div> <div className="grid gap-4">
<div className="h-10 rounded bg-slate-200"></div> <div className="grid gap-2">
<div className="h-10 rounded bg-slate-200"></div> <div className="w-20 h-4 rounded bg-slate-200 animate-pulse"></div>
<div className="h-10 rounded bg-slate-200 animate-pulse"></div>
</div>
<div className="grid gap-2">
<div className="w-16 h-4 rounded bg-slate-200 animate-pulse"></div>
<div className="h-10 rounded bg-slate-200 animate-pulse"></div>
</div>
<div className="h-10 mt-2 rounded bg-slate-200 animate-pulse"></div>
</div> </div>
</CardContent> </CardContent>
{/* ✅ เพิ่ม Skeleton สำหรับ Footer */}
<CardFooter className="flex justify-center">
<div className="w-48 h-4 rounded bg-slate-200 animate-pulse"></div>
</CardFooter>
</Card> </Card>
</div> </div>
); );

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,152 @@
// 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(() => {
// Reset state ทุกครั้งที่ dialog เปิดขึ้นมาใหม่
if (isOpen) {
if (isEditMode) {
// โหมดแก้ไข: ตั้งค่าฟอร์มด้วยข้อมูล Role ที่มีอยู่
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]); // ให้ re-run effect นี้เมื่อ 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) {
// โหมดแก้ไข: อัปเดต Permissions ของ Role ที่มีอยู่
await api.put(`/rbac/roles/${role.id}/permissions`, {
permissionIds: Array.from(selectedPermissions)
});
} else {
// โหมดสร้างใหม่: สร้าง Role ใหม่ก่อน
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(); // บอกให้หน้าแม่ (roles/page.jsx) โหลดข้อมูลใหม่
setIsOpen(false); // ปิด Dialog
} 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>
{isEditMode ? 'Select the permissions for this role.' : 'Define a new role and its initial permissions.'}
</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 cursor-pointer">
{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="button" variant="outline" onClick={() => setIsOpen(false)} disabled={isLoading}>
Cancel
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? 'Saving...' : 'Save Changes'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,284 @@
// 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, 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 }) {
// State for form fields
const [formData, setFormData] = useState({});
const [selectedSystemRoles, setSelectedSystemRoles] = useState(new Set());
// State for project role assignments
const [projectRoles, setProjectRoles] = useState([]);
const [selectedProjectId, setSelectedProjectId] = useState('');
const [selectedRoleId, setSelectedRoleId] = useState('');
// State for prerequisite data (fetched once)
const [allRoles, setAllRoles] = useState([]);
const [allProjects, setAllProjects] = useState([]);
// UI State
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const isEditMode = !!user;
// Effect to fetch prerequisite data (all roles and projects) when dialog opens
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);
setError('Could not load required data (roles, projects).');
}
};
if (isOpen) {
fetchPrerequisites();
}
}, [isOpen]);
// Effect to set up the form when the user prop changes (for editing) or when opening for creation
useEffect(() => {
const setupForm = async () => {
if (isEditMode) {
// Edit mode: populate form with user data
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) || []));
// Fetch this user's specific project roles
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 {
// Create mode: reset all fields
setFormData({ username: '', email: '', password: '', first_name: '', last_name: '', is_active: true });
setSelectedSystemRoles(new Set());
setProjectRoles([]);
}
// Reset local state
setError('');
setSelectedProjectId('');
setSelectedRoleId('');
};
if (isOpen) {
setupForm();
}
}, [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
});
// Refresh the list after adding
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 }
});
// Refresh list visually without another API call
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 (e) => {
e.preventDefault();
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(); // Tell the parent page to refresh its data
setIsOpen(false); // Close the dialog
} 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">
<form onSubmit={handleSaveUserDetails}>
<DialogHeader>
<DialogTitle>{isEditMode ? `Edit User: ${user.username}` : 'Create New User'}</DialogTitle>
</DialogHeader>
<ScrollArea className="max-h-[70vh] -mr-6 pr-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4 p-4">
{/* Section 1: User Details & System Roles */}
<div className="space-y-4 border-r-0 md:border-r md:pr-4">
<h3 className="font-semibold border-b pb-2">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="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="first_name">First Name</Label>
<Input id="first_name" value={formData.first_name || ''} onChange={handleInputChange} />
</div>
<div className="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>
<ScrollArea className="h-24 w-full rounded-md border p-2">
{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 leading-none cursor-pointer">{role.name}</label>
</div>
))}
</ScrollArea>
</div>
<div className="flex items-center space-x-2 pt-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="font-semibold border-b pb-2">Project Role Assignments</h3>
{isEditMode ? (
<>
<div className="p-4 border rounded-lg bg-muted/50 space-y-3">
<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 type="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>
<ScrollArea className="h-48 w-full rounded-md border p-1">
<div className="space-y-1 p-1">
{projectRoles.length > 0 ? projectRoles.map(pr => (
<div key={pr.id} className="flex justify-between items-center text-sm p-2 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 type="button" variant="ghost" size="icon" className="h-7 w-7" onClick={() => handleRemoveProjectRole(pr)} disabled={isLoading}>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
)) : <p className="text-sm text-muted-foreground italic text-center py-2">No project assignments.</p>}
</div>
</ScrollArea>
</div>
</>
) : <p className="text-sm text-muted-foreground italic text-center py-4">Save the user first to assign project roles.</p>}
</div>
</div>
</ScrollArea>
{error && <p className="text-sm text-red-500 text-center pt-2">{error}</p>}
<DialogFooter className="pt-4 border-t">
<Button type="button" variant="outline" onClick={() => setIsOpen(false)} disabled={isLoading}>
Cancel
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? 'Saving...' : 'Save User Details'}
</Button>
</DialogFooter>
</form>
</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 items-center justify-between">
<h2 className="text-2xl font-semibold">Roles & Permissions</h2>
<Button onClick={handleCreate}>
<PlusCircle className="w-4 h-4 mr-2" /> Add Role
</Button>
</div>
{roles.map(role => (
<Card key={role.id}>
<CardHeader>
<div className="flex items-start justify-between">
<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="mb-2 text-sm font-medium">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,5 +1,5 @@
import { getSession } from "@/lib/auth"; import { requireSession } from '@/lib/auth-server';
export default async function Page(){ export default async function Page() {
const { user } = await getSession(); const { user } = await requireSession();
return <div className="rounded-2xl p-5 bg-white">Drawings list/table (อเชอม backend)</div>; return <div className="p-5 bg-white rounded-2xl">Drawings list/table (อเชอม backend)</div>;
} }

View File

@@ -1,182 +1,95 @@
// frontend/app/(protected)/layout.jsx // File: frontend/app/(protected)/layout.jsx
import Link from "next/link"; 'use client';
import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth";
import { can } from "@/lib/rbac";
export const metadata = { title: "DMS | Protected" }; import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/auth';
export default async function ProtectedLayout({ children }) { import { Bell, LogOut, Users } from 'lucide-react';
// ตรวจ session ฝั่งเซิร์ฟเวอร์ ด้วยคุกกี้จริง import { Button } from '@/components/ui/button';
const session = await getSession(); import {
if (!session) { DropdownMenu,
redirect("/login"); DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
// NOTE: ให้ชี้ไปยังไฟล์จริงของคุณ
// เดิมบางโปรเจ็กต์ใช้ "../_components/SideNavigation"
// ที่นี่อ้าง absolute import ตาม tsconfig/baseUrl
import { SideNavigation } from '@/app/_components/SideNavigation';
export default function ProtectedLayout({ children }) {
const { user, isAuthenticated, loading, logout } = useAuth();
const router = useRouter();
// Guard ฝั่ง client: ถ้าไม่ได้ล็อกอิน ให้เด้งไป /login
useEffect(() => {
if (!loading && !isAuthenticated) {
router.push('/login');
} }
const { user } = session; }, [loading, isAuthenticated, router]);
// ระหว่างรอเช็คสถานะ หรือยังไม่ authenticated -> แสดง loading
if (loading || !isAuthenticated) {
return (
<div className="flex items-center justify-center h-screen">
<div className="text-sm text-muted-foreground">Loading session</div>
</div>
);
}
const handleLogout = async () => {
try {
await logout();
} finally {
router.replace('/login');
}
};
return ( return (
<section className="grid grid-cols-12 gap-6 p-4 mx-auto max-w-7xl"> <div className="grid min-h-screen w-full md:grid-cols-[220px_1fr] lg:grid-cols-[280px_1fr]">
<aside className="col-span-12 lg:col-span-3 xl:col-span-3"> {/* Sidebar */}
<div className="p-4 border rounded-3xl bg-white/70"> <aside className="hidden border-r bg-muted/40 md:block">
<div className="mb-3 text-sm"> <SideNavigation user={user} />
RBAC: <b>{user.role}</b>
</div>
<nav className="space-y-2">
<Link
className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white"
href="/dashboard"
>
แดชบอร
</Link>
<Link
className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white"
href="/drawings"
>
Drawings
</Link>
<Link
className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white"
href="/rfas"
>
RFAs
</Link>
<Link
className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white"
href="/transmittals"
>
Transmittals
</Link>
<Link
className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white"
href="/correspondences"
>
Correspondences
</Link>
<Link
className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white"
href="/contracts-volumes"
>
Contracts & Volumes
</Link>
<Link
className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white"
href="/reports"
>
Reports
</Link>
{can(user, "workflow:view") && (
<Link
className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white"
href="/workflow"
>
Workflow (n8n)
</Link>
)}
{can(user, "health:view") && (
<Link
className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white"
href="/health"
>
Health
</Link>
)}
{can(user, "users:manage") && (
<Link
className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white"
href="/users"
>
ใช/บทบาท
</Link>
)}
</nav>
</div>
</aside> </aside>
<main className="col-span-12 space-y-6 lg:col-span-9 xl:col-span-9"> {/* Main */}
{/* System / Quick Actions */} <div className="flex flex-col">
<div className="flex items-center gap-2"> <header className="flex h-14 items-center gap-4 border-b bg-muted/40 px-4 lg:h-[60px] lg:px-6">
<div className="flex-1 text-lg font-semibold"> <div className="flex-1" />
Document Management System LCP3 Phase 3
</div>
{can(user, "admin:view") && ( <Button variant="ghost" size="icon" className="relative">
<a <Bell className="w-5 h-5" />
className="px-3 py-2 text-white rounded-xl" <span className="absolute inline-flex w-2 h-2 rounded-full right-1 top-1 bg-primary" />
style={{ background: "#0D5C75" }} </Button>
href="/admin"
>
Admin
</a>
)}
{can(user, "users:manage") && (
<a
className="px-3 py-2 text-white rounded-xl"
style={{ background: "#0D5C75" }}
href="/users"
>
ใช/บทบาท
</a>
)}
{can(user, "health:view") && (
<a
className="px-3 py-2 text-white rounded-xl"
style={{ background: "#0D5C75" }}
href="/health"
>
Health
</a>
)}
{can(user, "workflow:view") && (
<a
className="px-3 py-2 text-white rounded-xl"
style={{ background: "#0D5C75" }}
href="/workflow"
>
Workflow
</a>
)}
{can(user, "rfa:create") && (
<a
className="px-3 py-2 text-white rounded-xl"
style={{ background: "#0D5C75" }}
href="/rfas/new"
>
+ RFA
</a>
)}
{can(user, "drawing:upload") && (
<a
className="px-3 py-2 text-white rounded-xl"
style={{ background: "#0D5C75" }}
href="/drawings/upload"
>
+ Upload Drawing
</a>
)}
{can(user, "transmittal:create") && (
<a
className="px-3 py-2 text-white rounded-xl"
style={{ background: "#0D5C75" }}
href="/transmittals/new"
>
+ Transmittal
</a>
)}
{can(user, "correspondence:create") && (
<a
className="px-3 py-2 text-white rounded-xl"
style={{ background: "#0D5C75" }}
href="/correspondences/new"
>
+ หนงสอสอสาร
</a>
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary" size="icon" className="rounded-full">
<Users className="w-5 h-5" />
<span className="sr-only">Toggle user menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{user?.username || 'My Account'}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>Profile Settings</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout} className="text-red-500 focus:text-red-600">
<LogOut className="w-4 h-4 mr-2" />
<span>Logout</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</header>
<main className="flex flex-col flex-1 gap-4 p-4 lg:gap-6 lg:p-6">
{children} {children}
</main> </main>
</section> </div>
</div>
); );
} }

0
frontend/app/_auth/AuthDriver.js Normal file → Executable file
View File

150
frontend/app/_auth/client.js Executable file
View File

@@ -0,0 +1,150 @@
// frontend/app/page.jsx
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import {
Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { login as authLogin, logout as authLogout, me as fetchMe, credentials, authHeaders } from '@/app/_auth/client';
export default function HomePage() {
const router = useRouter();
const [me, setMe] = useState(null);
const [loading, setLoading] = useState(true);
// login dialog states
const [open, setOpen] = useState(false);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [submitting, setSubmitting] = useState(false);
const [err, setErr] = useState('');
// โหลดสถานะผู้ใช้แบบไม่ redirect
useEffect(() => {
let cancelled = false;
(async () => {
try {
const user = await fetchMe();
if (!cancelled) setMe(user);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, []);
async function handleLogin(e) {
e?.preventDefault();
setSubmitting(true);
setErr('');
try {
const user = await authLogin({ username, password });
if (!user) {
setErr('ชื่อผู้ใช้หรือรหัสผ่านไม่ถูกต้อง');
return;
}
setMe(user);
setOpen(false);
router.push('/dashboard');
} catch {
setErr('เกิดข้อผิดพลาดในการเชื่อมต่อเซิร์ฟเวอร์');
} finally {
setSubmitting(false);
}
}
async function handleLogout() {
await authLogout().catch(() => {});
setMe(null);
}
if (loading) {
return (
<div className="space-y-6">
<div className="w-56 rounded h-7 bg-muted animate-pulse" />
<div className="grid gap-4 md:grid-cols-3">
{[...Array(3)].map((_, i) => <div key={i} className="rounded h-28 bg-muted animate-pulse" />)}
</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold">Welcome to DMS</h2>
{/* แถบสถานะล็อกอิน + ปุ่ม */}
<div className="flex items-center gap-2">
{me ? (
<>
<span className="text-sm text-muted-foreground">สวสด, <b>{me.first_name || me.username}</b></span>
<Button variant="secondary" onClick={() => router.push('/dashboard')}>Go to Dashboard</Button>
<Button variant="outline" onClick={handleLogout}>Logout</Button>
</>
) : (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>Login</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[420px]">
<DialogHeader>
<DialogTitle>เขาสระบบ</DialogTitle>
<DialogDescription>กรอกชอผใชและรหสผานของค</DialogDescription>
</DialogHeader>
<form onSubmit={handleLogin} className="grid gap-3">
<div className="grid gap-1.5">
<Label htmlFor="username">Username</Label>
<Input id="username" value={username} onChange={e => setUsername(e.target.value)} required />
</div>
<div className="grid gap-1.5">
<Label htmlFor="password">Password</Label>
<Input id="password" type="password" value={password} onChange={e => setPassword(e.target.value)} required />
</div>
{err && <p className="text-sm text-red-600">{err}</p>}
<Button type="submit" disabled={submitting}>{submitting ? 'กำลังเข้าสู่ระบบ...' : 'Login'}</Button>
</form>
</DialogContent>
</Dialog>
)}
</div>
</div>
{/* เนื้อหาอื่น ๆ (เหมือนเดิม) */}
<Tabs defaultValue="overview" className="w-full">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="activity">Activity</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<div className="grid gap-4 mt-4 md:grid-cols-3">
<Card><CardHeader><CardTitle>📑 RFAs</CardTitle></CardHeader><CardContent><p className="text-3xl font-bold">24</p></CardContent></Card>
<Card><CardHeader><CardTitle>📐 Drawings</CardTitle></CardHeader><CardContent><p className="text-3xl font-bold">112</p></CardContent></Card>
<Card><CardHeader><CardTitle>📤 Transmittals</CardTitle></CardHeader><CardContent><p className="text-3xl font-bold">8</p></CardContent></Card>
</div>
</TabsContent>
<TabsContent value="activity">
<div className="mt-4 space-y-3">
<p> User <b>editor01</b> uploaded Drawing D-2025-07</p>
<p> Transmittal T-2025-02 issued to Contractor</p>
<p> RFA R-2025-03 marked as Resolved</p>
</div>
</TabsContent>
</Tabs>
<div className="pt-2">
<Button asChild disabled={!me}><Link href="/dashboard">Go to Dashboard</Link></Button>
{!me && <span className="ml-2 text-sm text-muted-foreground">กรณา Login อน</span>}
</div>
</div>
);
}

0
frontend/app/_auth/drivers/bearerDriver.js Normal file → Executable file
View File

0
frontend/app/_auth/drivers/cookieDriver.js Normal file → Executable file
View File

0
frontend/app/_auth/useAuthGuard.jsx Normal file → Executable file
View File

View File

@@ -0,0 +1,84 @@
// File: frontend/app/_components/SideNavigation.jsx
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Home, FileText, Users, Settings, Package2, Upload, PlusCircle, Workflow, BarChart } from 'lucide-react';
import { can } from "@/lib/rbac";
import { cn } from "@/lib/utils";
// Component นี้จะรับ user object ที่มี roles และ permissions มาจาก Server Component Parent
export function SideNavigation({ user }) {
const pathname = usePathname();
// สร้าง Array ของเมนูหลักตามโครงสร้างเดิมของคุณ
const mainNavLinks = [
{ href: '/dashboard', label: 'Dashboard', icon: Home, perm: null }, // หน้าแรกเข้าได้ทุกคน
{ href: '/correspondences', label: 'Correspondences', icon: FileText, perm: 'correspondence:view' },
{ href: '/drawings', label: 'Drawings', icon: FileText, perm: 'drawing:view' },
{ href: '/rfas', label: 'RFAs', icon: FileText, perm: 'rfa:view' },
{ href: '/transmittals', label: 'Transmittals', icon: FileText, perm: 'transmittal:view' },
{ href: '/reports', label: 'Reports', icon: BarChart, perm: 'report:view' },
];
// สร้าง Array ของเมนู Admin ตามโครงสร้างเดิมของคุณ
const adminNavLinks = [
{ href: '/admin', label: 'Admin', icon: Settings, perm: 'admin:view' },
{ href: '/users', label: 'ผู้ใช้/บทบาท', icon: Users, perm: 'users:manage' },
{ href: '/workflow', label: 'Workflow', icon: Workflow, perm: 'workflow:view' },
];
return (
<div className="flex flex-col h-full max-h-screen gap-2">
<div className="flex h-14 items-center border-b px-4 lg:h-[60px] lg:px-6">
<Link href="/dashboard" className="flex items-center gap-2 font-semibold">
<Package2 className="w-6 h-6" />
<span className="">LCB P3 DMS</span>
</Link>
</div>
<div className="flex-1 overflow-y-auto">
<nav className="grid items-start px-2 text-sm font-medium lg:px-4">
{/* Render เมนูหลัก */}
{mainNavLinks.map(({ href, label, icon: Icon, perm }) =>
(perm === null || can(user, perm)) && (
<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 === href || (href !== '/dashboard' && pathname.startsWith(href))) ? 'bg-muted text-primary' : ''
)}
>
<Icon className="w-4 h-4" />
{label}
</Link>
)
)}
{/* Render เมนู Admin ถ้ามีสิทธิ์อย่างน้อย 1 เมนู */}
{adminNavLinks.some(link => can(user, link.perm)) && (
<>
<div className="my-2 border-t"></div>
{adminNavLinks.map(({ href, label, icon: Icon, perm }) =>
can(user, perm) && (
<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="w-4 h-4" />
{label}
</Link>
)
)}
</>
)}
</nav>
</div>
{/* ส่วน Card ด้านล่างสามารถคงไว้ หรือเอาออกได้ตามต้องการ */}
</div>
);
}

View File

@@ -0,0 +1,44 @@
// File: frontend/app/_components/TopBar.jsx <<'JSX'
'use client';
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipProvider, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { Bell } from "lucide-react";
import { useAuth } from "@/lib/auth";
import Link from "next/link";
export default function TopBar() {
const { user, loading, logout } = useAuth();
return (
<header className="flex h-14 items-center gap-4 border-b bg-background px-4 lg:h-[60px] lg:px-6">
<div className="flex-1" />
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="relative">
<Bell className="h-5 w-5" />
<span className="absolute right-1 top-1 inline-flex h-2 w-2 rounded-full bg-primary" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Notifications</TooltipContent>
</Tooltip>
</TooltipProvider>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="ml-2">
{loading ? "Loading..." : (user?.first_name || "Account")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem asChild><Link href="/profile">Profile</Link></DropdownMenuItem>
<DropdownMenuItem asChild><Link href="/settings">Settings</Link></DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={logout}>Logout</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</header>
);
}

View File

@@ -0,0 +1,85 @@
//File: frontend/app/_components/navigation.jsx
'use client'; // <-- 1. กำหนดให้ไฟล์นี้เป็น Client Component
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Home, FileText, Settings, Package2 } from 'lucide-react';
import { can } from "@/lib/rbac";
import { cn } from "@/lib/utils";
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
export function Navigation({ user }) { // 2. รับข้อมูล user มาจาก props
const pathname = usePathname(); // 3. ใช้งาน usePathname ได้แล้ว
const navLinks = [
{ href: '/dashboard', label: 'Dashboard', icon: Home },
{ href: '/correspondences', label: 'Correspondences', icon: FileText },
{ href: '/drawings', label: 'Drawings', icon: FileText },
// ... เพิ่มเมนูอื่นๆ ตามต้องการ
];
const adminLink = {
href: '/admin/users',
label: 'Admin',
icon: Settings,
requiredPermission: 'manage_users'
};
return (
<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="/dashboard" className="flex items-center gap-2 font-semibold">
<Package2 className="h-6 w-6" />
<span className="">LCB P3 DMS</span>
</Link>
{/* Bell Icon can be here if needed */}
</div>
<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>
))}
{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>
</>
)}
</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>
);
}

146
frontend/app/globals.bak.css Executable file
View File

@@ -0,0 +1,146 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ====== shadcn/ui theme (light + dark) ====== */
:root {
/* โทน “น้ำทะเล” ตามธีมของคุณ */
--primary: 199 90% 40%;
--primary-foreground: 0 0% 100%;
--secondary: 199 60% 92%;
--secondary-foreground: 220 15% 20%;
--muted: 210 20% 96%;
--muted-foreground: 220 10% 35%;
--accent: 199 95% 48%;
--accent-foreground: 0 0% 100%;
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 100%;
--card: 0 0% 100%;
--card-foreground: 220 15% 15%;
--popover: 0 0% 100%;
--popover-foreground: 220 15% 15%;
--border: 214 32% 91%;
--input: 214 32% 91%;
--ring: 199 90% 40%;
--radius: 0.8rem; /* โค้งมนตามแนวทาง UI ของโปรเจ็ค */
}
.dark {
--background: 220 18% 10%;
--foreground: 0 0% 100%;
--primary: 199 95% 58%;
--primary-foreground: 220 18% 10%;
--secondary: 218 14% 20%;
--secondary-foreground: 0 0% 100%;
--muted: 220 14% 18%;
--muted-foreground: 220 10% 70%;
--accent: 199 95% 62%;
--accent-foreground: 220 18% 10%;
--destructive: 0 62% 46%;
--destructive-foreground: 0 0% 100%;
--card: 220 18% 12%;
--card-foreground: 0 0% 100%;
--popover: 220 18% 12%;
--popover-foreground: 0 0% 100%;
--border: 220 14% 28%;
--input: 220 14% 28%;
--ring: 199 95% 62%;
}
/* Base styling */
@layer base {
* {
@apply border-border;
}
html,
body {
@apply h-full;
}
body {
@apply bg-background text-foreground antialiased;
}
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
/* Utility: container max width (ช่วยเรื่อง layout) */
.container {
@apply mx-auto px-4;
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

231
frontend/app/globals.css Executable file → Normal file
View File

@@ -1,144 +1,185 @@
/* File: frontend/app/globals.css */
@import "tailwindcss";
@plugin "tailwindcss-animate";
@custom-variant dark (&:is(.dark *));
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
/* ====== shadcn/ui theme (light + dark) ====== */ /* === Base & Theme (shadcn style) === */
:root { @layer base {
:root {
/* Sea palette — light */
--background: 0 0% 100%;
--foreground: 220 15% 15%;
--card: 0 0% 100%;
--card-foreground: 220 15% 15%;
--popover: 0 0% 100%;
--popover-foreground: 220 15% 15%;
/* โทน “น้ำทะเล” ตามธีมของคุณ */ /* sea tones */
--primary: 199 90% 40%; --primary: 199 90% 40%;
--primary-foreground: 0 0% 100%; --primary-foreground: 0 0% 100%;
--secondary: 199 60% 92%; --secondary: 199 60% 92%;
--secondary-foreground: 220 15% 20%; --secondary-foreground: 220 15% 20%;
--muted: 210 20% 96%; --muted: 210 20% 96%;
--muted-foreground: 220 10% 35%; --muted-foreground: 220 10% 35%;
--accent: 199 95% 48%; --accent: 199 95% 48%;
--accent-foreground: 0 0% 100%; --accent-foreground: 0 0% 100%;
--destructive: 0 84% 60%; --destructive: 0 84% 60%;
--destructive-foreground: 0 0% 100%; --destructive-foreground: 0 0% 100%;
--card: 0 0% 100%;
--card-foreground: 220 15% 15%;
--popover: 0 0% 100%;
--popover-foreground: 220 15% 15%;
--border: 214 32% 91%; --border: 214 32% 91%;
--input: 214 32% 91%; --input: 214 32% 91%;
--ring: 199 90% 40%; --ring: 199 90% 40%;
--radius: 0.8rem; /* โค้งมนตามแนวทาง UI ของโปรเจ็ค */ --radius: 0.8rem;
} }
.dark { .dark {
/* Sea palette — dark */
--background: 220 18% 10%; --background: 220 18% 10%;
--foreground: 0 0% 100%; --foreground: 0 0% 100%;
--card: 220 18% 12%;
--card-foreground: 0 0% 100%;
--popover: 220 18% 12%;
--popover-foreground: 0 0% 100%;
--primary: 199 95% 58%; --primary: 199 95% 58%;
--primary-foreground: 220 18% 10%; --primary-foreground: 220 18% 10%;
--secondary: 218 14% 20%; --secondary: 218 14% 20%;
--secondary-foreground: 0 0% 100%; --secondary-foreground: 0 0% 100%;
--muted: 220 14% 18%; --muted: 220 14% 18%;
--muted-foreground: 220 10% 70%; --muted-foreground: 220 10% 70%;
--accent: 199 95% 62%; --accent: 199 95% 62%;
--accent-foreground: 220 18% 10%; --accent-foreground: 220 18% 10%;
--destructive: 0 62% 46%; --destructive: 0 62% 46%;
--destructive-foreground: 0 0% 100%; --destructive-foreground: 0 0% 100%;
--card: 220 18% 12%;
--card-foreground: 0 0% 100%;
--popover: 220 18% 12%;
--popover-foreground: 0 0% 100%;
--border: 220 14% 28%; --border: 220 14% 28%;
--input: 220 14% 28%; --input: 220 14% 28%;
--ring: 199 95% 62%; --ring: 199 95% 62%;
}
* { @apply border-border; }
html, body { @apply h-full; }
body { @apply antialiased bg-background text-foreground; }
} }
/* Base styling */ /* Utilities */
@layer base { .container { @apply px-4 mx-auto; }
* {
@apply border-border; @theme inline {
} --radius-sm: calc(var(--radius) - 4px);
html, --radius-md: calc(var(--radius) - 2px);
body { --radius-lg: var(--radius);
@apply h-full; --radius-xl: calc(var(--radius) + 4px);
} --color-background: var(--background);
body { --color-foreground: var(--foreground);
@apply bg-background text-foreground antialiased; --color-card: var(--card);
} --color-card-foreground: var(--card-foreground);
:root { --color-popover: var(--popover);
--background: 0 0% 100%; --color-popover-foreground: var(--popover-foreground);
--foreground: 0 0% 3.9%; --color-primary: var(--primary);
--card: 0 0% 100%; --color-primary-foreground: var(--primary-foreground);
--card-foreground: 0 0% 3.9%; --color-secondary: var(--secondary);
--popover: 0 0% 100%; --color-secondary-foreground: var(--secondary-foreground);
--popover-foreground: 0 0% 3.9%; --color-muted: var(--muted);
--primary: 0 0% 9%; --color-muted-foreground: var(--muted-foreground);
--primary-foreground: 0 0% 98%; --color-accent: var(--accent);
--secondary: 0 0% 96.1%; --color-accent-foreground: var(--accent-foreground);
--secondary-foreground: 0 0% 9%; --color-destructive: var(--destructive);
--muted: 0 0% 96.1%; --color-border: var(--border);
--muted-foreground: 0 0% 45.1%; --color-input: var(--input);
--accent: 0 0% 96.1%; --color-ring: var(--ring);
--accent-foreground: 0 0% 9%; --color-chart-1: var(--chart-1);
--destructive: 0 84.2% 60.2%; --color-chart-2: var(--chart-2);
--destructive-foreground: 0 0% 98%; --color-chart-3: var(--chart-3);
--border: 0 0% 89.8%; --color-chart-4: var(--chart-4);
--input: 0 0% 89.8%; --color-chart-5: var(--chart-5);
--ring: 0 0% 3.9%; --color-sidebar: var(--sidebar);
--chart-1: 12 76% 61%; --color-sidebar-foreground: var(--sidebar-foreground);
--chart-2: 173 58% 39%; --color-sidebar-primary: var(--sidebar-primary);
--chart-3: 197 37% 24%; --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--chart-4: 43 74% 66%; --color-sidebar-accent: var(--sidebar-accent);
--chart-5: 27 87% 67%; --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--radius: 0.5rem; --color-sidebar-border: var(--sidebar-border);
} --color-sidebar-ring: var(--sidebar-ring);
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
} }
/* Utility: container max width (ช่วยเรื่อง layout) */ :root {
.container { --radius: 0.625rem;
@apply mx-auto px-4; --card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
} }
@layer base { @layer base {
* { * {
@apply border-border; @apply border-border outline-ring/50;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;

View File

@@ -1,112 +1,20 @@
// File: frontend/app/(protected)/layout.jsx // File: frontend/app/layout.jsx
import Link from "next/link"; import './globals.css';
import { redirect } from "next/navigation"; import { Inter } from 'next/font/google';
import { cookies, headers } from "next/headers";
// ถ้ามี lib rbac เดิมอยู่ให้ใช้ต่อได้
import { can } from "@/lib/rbac";
// แก้ title ให้ถูกสะกด export const metadata = {
export const metadata = { title: "DMS | Protected" }; title: 'DMS',
description: 'Document Management System',
};
const API_BASE = (process.env.NEXT_PUBLIC_API_BASE || "").replace(/\/$/, ""); const inter = Inter({ subsets: ['latin'] });
async function fetchSessionFromAPI() {
// ดึงคุกกี้จริงจากฝั่งเซิร์ฟเวอร์ แล้วส่งต่อให้ backend
const cookieHeader = cookies().toString(); // serialize ทั้งชุด
const hostHdr = headers().get("host");
const protoHdr = headers().get("x-forwarded-proto") || "https";
const res = await fetch(`${API_BASE}/api/auth/me`, {
method: "GET",
headers: {
Cookie: cookieHeader,
// เผื่อ backend ตรวจ origin/proto/host
"X-Forwarded-Host": hostHdr || "",
"X-Forwarded-Proto": protoHdr,
Accept: "application/json",
},
// server component ไม่ต้องใช้ credentials
cache: "no-store",
});
if (!res.ok) return null;
try {
const data = await res.json();
return data?.ok ? data : null;
} catch {
return null;
}
}
export default async function ProtectedLayout({ children }) {
const session = await fetchSessionFromAPI();
if (!session) {
// พยายามส่ง next path กลับไปที่ /login
redirect("/login?next=/dashboard");
}
const { user } = session;
export default function RootLayout({ children }) {
return ( return (
<section className="grid grid-cols-12 gap-6 p-4 mx-auto max-w-7xl"> <html lang="th" suppressHydrationWarning>
<aside className="col-span-12 lg:col-span-3 xl:col-span-3"> <body className={`${inter.className} min-h-screen bg-background text-foreground antialiased`}>
<div className="p-4 border rounded-3xl bg-white/70">
<div className="mb-3 text-sm">RBAC: <b>{user.role}</b></div>
<nav className="space-y-2">
<Link className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" href="/dashboard">แดชบอร</Link>
<Link className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" href="/drawings">Drawings</Link>
<Link className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" href="/rfas">RFAs</Link>
<Link className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" href="/transmittals">Transmittals</Link>
<Link className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" href="/correspondences">Correspondences</Link>
<Link className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" href="/contracts-volumes">Contracts & Volumes</Link>
<Link className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" href="/reports">Reports</Link>
{can(user, "workflow:view") && (
<Link className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" href="/workflow">Workflow (n8n)</Link>
)}
{can(user, "health:view") && (
<Link className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" href="/health">Health</Link>
)}
{can(user, "users:manage") && (
<Link className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" href="/users">ใช/บทบาท</Link>
)}
</nav>
</div>
</aside>
<main className="col-span-12 space-y-6 lg:col-span-9 xl:col-span-9">
{/* System / Quick Actions */}
<div className="flex items-center gap-2">
<div className="flex-1 text-lg font-semibold">Document Management System LCBP3 Phase 3</div>
{can(user, "admin:view") && (
<a className="px-3 py-2 text-white rounded-xl" style={{ background: "#0D5C75" }} href="/admin">Admin</a>
)}
{can(user, "users:manage") && (
<a className="px-3 py-2 text-white rounded-xl" style={{ background: "#0D5C75" }} href="/users">ใช/บทบาท</a>
)}
{can(user, "health:view") && (
<a className="px-3 py-2 text-white rounded-xl" style={{ background: "#0D5C75" }} href="/health">Health</a>
)}
{can(user, "workflow:view") && (
<a className="px-3 py-2 text-white rounded-xl" style={{ background: "#0D5C75" }} href="/workflow">Workflow</a>
)}
{can(user, "rfa:create") && (
<a className="px-3 py-2 text-white rounded-xl" style={{ background: "#0D5C75" }} href="/rfas/new">+ RFA</a>
)}
{can(user, "drawing:upload") && (
<a className="px-3 py-2 text-white rounded-xl" style={{ background: "#0D5C75" }} href="/drawings/upload">+ Upload Drawing</a>
)}
{can(user, "transmittal:create") && (
<a className="px-3 py-2 text-white rounded-xl" style={{ background: "#0D5C75" }} href="/transmittals/new">+ Transmittal</a>
)}
{can(user, "correspondence:create") && (
<a className="px-3 py-2 text-white rounded-xl" style={{ background: "#0D5C75" }} href="/correspondences/new">+ หนงสอสอสาร</a>
)}
</div>
{children} {children}
</main> </body>
</section> </html>
); );
} }

View File

@@ -1,64 +1,142 @@
// app/page.jsx // frontend/app/page.jsx
"use client"; 'use client';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { useEffect, useState } from 'react';
import { Button } from "@/components/ui/button"; import Link from 'next/link';
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
// 👉 ใช้ driver เดิมของโปรเจ็กต์ (relative path + credentials: 'include')
import cookieDriver from '@/app/_auth/drivers/cookieDriver';
export default function HomePage() { export default function HomePage() {
const router = useRouter();
const [me, setMe] = useState(null);
const [loading, setLoading] = useState(true);
// dialog/login state
const [open, setOpen] = useState(false);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [submitting, setSubmitting] = useState(false);
const [err, setErr] = useState('');
// โหลดสถานะผู้ใช้แบบไม่ redirect
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await cookieDriver.me(); // ภายในยิง /api/auth/me ด้วย credentials: 'include'
if (!cancelled && res && (res.user || res.username)) {
const u = res.user ?? res;
setMe(u);
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, []);
async function onSubmit(e) {
e?.preventDefault();
setErr('');
if (!username.trim() || !password) {
setErr('กรอกชื่อผู้ใช้และรหัสผ่านให้ครบ');
return;
}
try {
setSubmitting(true);
const ok = await cookieDriver.login({ username, password }); // ยิง /api/auth/login แบบ relative
if (!ok) {
setErr('ชื่อผู้ใช้หรือรหัสผ่านไม่ถูกต้อง');
return;
}
// ตรวจ me อีกรอบให้ชัวร์ว่ามีคุกกี้แล้ว
const res = await cookieDriver.me();
const u = res?.user ?? res ?? { username };
setMe(u);
setOpen(false);
router.push('/dashboard');
} catch {
setErr('เชื่อมต่อเซิร์ฟเวอร์ไม่ได้');
} finally {
setSubmitting(false);
}
}
async function onLogout() {
try { await cookieDriver.logout?.(); } catch {}
setMe(null);
}
if (loading) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="w-56 rounded h-7 bg-muted animate-pulse" />
<div className="grid gap-4 md:grid-cols-3">
{[...Array(3)].map((_, i) => <div key={i} className="h-24 rounded bg-muted animate-pulse" />)}
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* header */}
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold">Welcome to DMS</h2> <h2 className="text-2xl font-bold">Welcome to DMS</h2>
<div className="flex items-center gap-2">
<Tabs defaultValue="overview" className="w-full"> {me ? (
<TabsList> <>
<TabsTrigger value="overview">Overview</TabsTrigger> <span className="text-sm text-muted-foreground">สวสด, <b>{me.first_name || me.username}</b></span>
<TabsTrigger value="activity">Activity</TabsTrigger> <Button variant="secondary" onClick={() => router.push('/dashboard')}>Go to Dashboard</Button>
</TabsList> <Button variant="outline" onClick={onLogout}>Logout</Button>
</>
<TabsContent value="overview"> ) : (
<div className="grid md:grid-cols-3 gap-4 mt-4"> <Dialog open={open} onOpenChange={setOpen}>
<Card> <DialogTrigger asChild><Button>Login</Button></DialogTrigger>
<CardHeader> <DialogContent className="sm:max-w-[420px]">
<CardTitle>📑 RFAs</CardTitle> <DialogHeader>
</CardHeader> <DialogTitle>เขาสระบบ</DialogTitle>
<CardContent> <DialogDescription>กรอกชอผใชและรหสผานของค</DialogDescription>
<p className="text-3xl font-bold">24</p> </DialogHeader>
</CardContent> <form onSubmit={onSubmit} className="grid gap-3">
</Card> <div className="grid gap-1.5">
<Label htmlFor="username">Username</Label>
<Card> <Input id="username" value={username} onChange={(e) => setUsername(e.target.value)} required />
<CardHeader> </div>
<CardTitle>📐 Drawings</CardTitle> <div className="grid gap-1.5">
</CardHeader> <Label htmlFor="password">Password</Label>
<CardContent> <Input id="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
<p className="text-3xl font-bold">112</p> </div>
</CardContent> {err && <p className="text-sm text-red-600">{err}</p>}
</Card> <Button type="submit" disabled={submitting}>{submitting ? 'กำลังเข้าสู่ระบบ…' : 'Login'}</Button>
</form>
<Card> </DialogContent>
<CardHeader> </Dialog>
<CardTitle>📤 Transmittals</CardTitle> )}
</CardHeader>
<CardContent>
<p className="text-3xl font-bold">8</p>
</CardContent>
</Card>
</div> </div>
</TabsContent>
<TabsContent value="activity">
<div className="mt-4 space-y-3">
<p>
User <b>editor01</b> uploaded Drawing D-2025-07
</p>
<p> Transmittal T-2025-02 issued to Contractor</p>
<p> RFA R-2025-03 marked as Resolved</p>
</div> </div>
</TabsContent>
</Tabs>
<Button className="mt-6">Go to Dashboard</Button> {/* เนื้อหาย่อก่อนเข้าดาชบอร์ด */}
<div className="grid gap-4 mt-2 md:grid-cols-3">
<Card><CardHeader><CardTitle>📑 RFAs</CardTitle></CardHeader><CardContent><p className="text-3xl font-bold"></p></CardContent></Card>
<Card><CardHeader><CardTitle>📐 Drawings</CardTitle></CardHeader><CardContent><p className="text-3xl font-bold"></p></CardContent></Card>
<Card><CardHeader><CardTitle>📤 Transmittals</CardTitle></CardHeader><CardContent><p className="text-3xl font-bold"></p></CardContent></Card>
</div>
<div className="pt-2">
<Button asChild disabled={!me}><Link href="/dashboard">Go to Dashboard</Link></Button>
{!me && <span className="ml-2 text-sm text-muted-foreground">กรณา Login อน</span>}
</div>
</div> </div>
); );
} }

8
frontend/components.json Normal file → Executable file
View File

@@ -7,16 +7,12 @@
"config": "tailwind.config.js", "config": "tailwind.config.js",
"css": "app/globals.css", "css": "app/globals.css",
"baseColor": "neutral", "baseColor": "neutral",
"cssVariables": true, "cssVariables": true
"prefix": ""
}, },
"iconLibrary": "lucide", "iconLibrary": "lucide",
"aliases": { "aliases": {
"components": "@/components", "components": "@/components",
"utils": "@/lib/utils", "utils": "@/lib/utils"
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}, },
"registries": {} "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,
}

0
frontend/components/ui/alert.jsx Normal file → Executable file
View File

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,
}

0
frontend/components/ui/label.jsx Normal file → Executable file
View File

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 }

121
frontend/components/ui/select.jsx Executable file
View File

@@ -0,0 +1,121 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn("p-1", position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]")}>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props} />
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props} />
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@@ -0,0 +1,86 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props} />
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props} />
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
{...props} />
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props} />
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props} />
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props} />
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props} />
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

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