feat: 管理員認證系統與部署配置優化
Admin Authentication: - 新增 LDAP 認證服務整合公司 AD API - 新增頁面狀態管理 (released/dev) - 非管理員無法存取 dev 狀態頁面 - Portal 動態顯示/隱藏 tabs 基於權限 Deployment Configuration: - 更新 .env.example 包含所有環境變數 - start_server.sh 自動載入 .env 檔案 - 新增 deploy.sh 部署腳本 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
56
.env.example
56
.env.example
@@ -1,13 +1,59 @@
|
||||
# Database Configuration
|
||||
# Copy this file to .env and fill in your actual values
|
||||
# ============================================================
|
||||
# MES Dashboard Environment Configuration
|
||||
# ============================================================
|
||||
# Copy this file to .env and fill in your actual values:
|
||||
# cp .env.example .env
|
||||
# nano .env
|
||||
# ============================================================
|
||||
|
||||
# Oracle Database connection
|
||||
# ============================================================
|
||||
# Database Configuration (REQUIRED)
|
||||
# ============================================================
|
||||
# Oracle Database connection settings
|
||||
DB_HOST=10.1.1.58
|
||||
DB_PORT=1521
|
||||
DB_SERVICE=DWDB
|
||||
DB_USER=your_username
|
||||
DB_PASSWORD=your_password
|
||||
|
||||
# Flask Configuration (optional)
|
||||
# Database Pool Settings (optional, has defaults)
|
||||
# Adjust based on expected load
|
||||
DB_POOL_SIZE=5 # Default: 5 (dev: 2, prod: 10)
|
||||
DB_MAX_OVERFLOW=10 # Default: 10 (dev: 3, prod: 20)
|
||||
|
||||
# ============================================================
|
||||
# Flask Configuration
|
||||
# ============================================================
|
||||
# Environment mode: development | production | testing
|
||||
FLASK_ENV=development
|
||||
FLASK_DEBUG=1
|
||||
|
||||
# Debug mode: 0 for production, 1 for development
|
||||
FLASK_DEBUG=0
|
||||
|
||||
# Session Security (REQUIRED for production!)
|
||||
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
SECRET_KEY=your-secret-key-change-in-production
|
||||
|
||||
# Session timeout in seconds (default: 28800 = 8 hours)
|
||||
SESSION_LIFETIME=28800
|
||||
|
||||
# ============================================================
|
||||
# Authentication Configuration
|
||||
# ============================================================
|
||||
# LDAP API endpoint for user authentication
|
||||
LDAP_API_URL=https://adapi.panjit.com.tw
|
||||
|
||||
# Admin email addresses (comma-separated for multiple)
|
||||
ADMIN_EMAILS=ymirliu@panjit.com.tw
|
||||
|
||||
# ============================================================
|
||||
# Gunicorn Configuration
|
||||
# ============================================================
|
||||
# Server bind address and port
|
||||
GUNICORN_BIND=0.0.0.0:8080
|
||||
|
||||
# Number of worker processes (recommend: 2 * CPU cores + 1)
|
||||
GUNICORN_WORKERS=2
|
||||
|
||||
# Threads per worker
|
||||
GUNICORN_THREADS=4
|
||||
|
||||
40
data/page_status.json
Normal file
40
data/page_status.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"pages": [
|
||||
{
|
||||
"route": "/",
|
||||
"name": "首頁",
|
||||
"status": "released"
|
||||
},
|
||||
{
|
||||
"route": "/wip-overview",
|
||||
"name": "WIP 即時概況",
|
||||
"status": "released"
|
||||
},
|
||||
{
|
||||
"route": "/wip-detail",
|
||||
"name": "WIP 明細",
|
||||
"status": "released"
|
||||
},
|
||||
{
|
||||
"route": "/hold-detail",
|
||||
"name": "Hold 明細",
|
||||
"status": "released"
|
||||
},
|
||||
{
|
||||
"route": "/tables",
|
||||
"name": "表格總覽",
|
||||
"status": "dev"
|
||||
},
|
||||
{
|
||||
"route": "/resource",
|
||||
"name": "機台狀態",
|
||||
"status": "dev"
|
||||
},
|
||||
{
|
||||
"route": "/excel-query",
|
||||
"name": "Excel 批次查詢",
|
||||
"status": "dev"
|
||||
}
|
||||
],
|
||||
"api_public": true
|
||||
}
|
||||
478
openspec/changes/archive/2026-01-28-admin-auth/design.md
Normal file
478
openspec/changes/archive/2026-01-28-admin-auth/design.md
Normal file
@@ -0,0 +1,478 @@
|
||||
# Admin Auth 技術設計
|
||||
|
||||
## 架構概述
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Flask App │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ before_request │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ check_page_access() │ │
|
||||
│ │ - 檢查 request.endpoint │ │
|
||||
│ │ - 查詢 PageRegistry 頁面狀態 │ │
|
||||
│ │ - 若為 dev 且非管理員 → 403 │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Routes │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ auth_routes │ │ admin_routes │ │ wip_routes │ ... │
|
||||
│ │ /admin/login │ │ /admin/pages │ │ /wip-overview│ │
|
||||
│ │ /admin/logout│ │ /admin/api/* │ │ /wip-detail │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Services │
|
||||
│ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ auth_service │ │ page_registry│ │
|
||||
│ │ - LDAP 驗證 │ │ - 頁面狀態 │ │
|
||||
│ │ - 管理員檢查 │ │ - JSON 儲存 │ │
|
||||
│ └──────────────┘ └──────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 資料結構
|
||||
|
||||
### Session 資料
|
||||
|
||||
```python
|
||||
# 管理員登入後存入 session
|
||||
session['admin'] = {
|
||||
'username': '92367',
|
||||
'displayName': 'ymirliu 劉念萱',
|
||||
'mail': 'ymirliu@panjit.com.tw',
|
||||
'department': 'MBU1_AssEng Div 封裝工程處',
|
||||
'login_time': '2026-01-28T14:00:00'
|
||||
}
|
||||
```
|
||||
|
||||
### 頁面狀態設定檔 (`data/page_status.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"pages": [
|
||||
{
|
||||
"route": "/",
|
||||
"name": "首頁",
|
||||
"status": "released"
|
||||
},
|
||||
{
|
||||
"route": "/wip-overview",
|
||||
"name": "WIP 即時概況",
|
||||
"status": "released"
|
||||
},
|
||||
{
|
||||
"route": "/wip-detail",
|
||||
"name": "WIP 明細",
|
||||
"status": "released"
|
||||
},
|
||||
{
|
||||
"route": "/hold-detail",
|
||||
"name": "Hold 明細",
|
||||
"status": "released"
|
||||
},
|
||||
{
|
||||
"route": "/tables",
|
||||
"name": "表格總覽",
|
||||
"status": "released"
|
||||
},
|
||||
{
|
||||
"route": "/resource",
|
||||
"name": "機台狀態",
|
||||
"status": "released"
|
||||
},
|
||||
{
|
||||
"route": "/excel-query",
|
||||
"name": "Excel 批次查詢",
|
||||
"status": "released"
|
||||
}
|
||||
],
|
||||
"api_public": true
|
||||
}
|
||||
```
|
||||
|
||||
## 模組設計
|
||||
|
||||
### 1. auth_service.py - LDAP 認證服務
|
||||
|
||||
```python
|
||||
# src/mes_dashboard/services/auth_service.py
|
||||
|
||||
LDAP_API_BASE = "https://adapi.panjit.com.tw"
|
||||
ADMIN_EMAILS = ["ymirliu@panjit.com.tw"] # 可從 config 讀取
|
||||
|
||||
def authenticate(username: str, password: str, domain: str = "PANJIT") -> dict | None:
|
||||
"""
|
||||
呼叫 LDAP API 驗證使用者。
|
||||
|
||||
Returns:
|
||||
成功: {'username': ..., 'displayName': ..., 'mail': ..., 'department': ...}
|
||||
失敗: None
|
||||
"""
|
||||
response = requests.post(
|
||||
f"{LDAP_API_BASE}/api/v1/ldap/auth",
|
||||
json={"username": username, "password": password, "domain": domain},
|
||||
timeout=10
|
||||
)
|
||||
data = response.json()
|
||||
if data.get("success"):
|
||||
return data["user"]
|
||||
return None
|
||||
|
||||
|
||||
def is_admin(user: dict) -> bool:
|
||||
"""檢查使用者是否為管理員。"""
|
||||
return user.get("mail", "").lower() in [e.lower() for e in ADMIN_EMAILS]
|
||||
```
|
||||
|
||||
### 2. page_registry.py - 頁面狀態管理
|
||||
|
||||
```python
|
||||
# src/mes_dashboard/services/page_registry.py
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
|
||||
DATA_FILE = Path("data/page_status.json")
|
||||
_lock = Lock()
|
||||
_cache = None
|
||||
|
||||
def _load() -> dict:
|
||||
"""載入頁面狀態設定。"""
|
||||
global _cache
|
||||
if _cache is None:
|
||||
if DATA_FILE.exists():
|
||||
_cache = json.loads(DATA_FILE.read_text(encoding="utf-8"))
|
||||
else:
|
||||
_cache = {"pages": [], "api_public": True}
|
||||
return _cache
|
||||
|
||||
|
||||
def _save(data: dict) -> None:
|
||||
"""儲存頁面狀態設定。"""
|
||||
global _cache
|
||||
DATA_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
DATA_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
_cache = data
|
||||
|
||||
|
||||
def get_page_status(route: str) -> str:
|
||||
"""取得頁面狀態 ('released' | 'dev'),預設為 'dev'。"""
|
||||
with _lock:
|
||||
data = _load()
|
||||
for page in data.get("pages", []):
|
||||
if page["route"] == route:
|
||||
return page.get("status", "dev")
|
||||
return "dev" # 未註冊的頁面預設為 dev
|
||||
|
||||
|
||||
def set_page_status(route: str, status: str, name: str = None) -> None:
|
||||
"""設定頁面狀態。"""
|
||||
with _lock:
|
||||
data = _load()
|
||||
for page in data.get("pages", []):
|
||||
if page["route"] == route:
|
||||
page["status"] = status
|
||||
if name:
|
||||
page["name"] = name
|
||||
_save(data)
|
||||
return
|
||||
# 新增頁面
|
||||
data.setdefault("pages", []).append({
|
||||
"route": route,
|
||||
"name": name or route,
|
||||
"status": status
|
||||
})
|
||||
_save(data)
|
||||
|
||||
|
||||
def get_all_pages() -> list:
|
||||
"""取得所有頁面設定。"""
|
||||
with _lock:
|
||||
return _load().get("pages", [])
|
||||
|
||||
|
||||
def is_api_public() -> bool:
|
||||
"""API 端點是否公開(不受權限控制)。"""
|
||||
with _lock:
|
||||
return _load().get("api_public", True)
|
||||
```
|
||||
|
||||
### 3. permissions.py - 權限檢查
|
||||
|
||||
```python
|
||||
# src/mes_dashboard/core/permissions.py
|
||||
|
||||
from functools import wraps
|
||||
from flask import session, abort, redirect, url_for, request
|
||||
|
||||
def is_admin_logged_in() -> bool:
|
||||
"""檢查管理員是否已登入。"""
|
||||
return "admin" in session
|
||||
|
||||
|
||||
def get_current_admin() -> dict | None:
|
||||
"""取得目前登入的管理員資訊。"""
|
||||
return session.get("admin")
|
||||
|
||||
|
||||
def admin_required(f):
|
||||
"""裝飾器:需要管理員登入。"""
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
if not is_admin_logged_in():
|
||||
return redirect(url_for("auth.login", next=request.url))
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
```
|
||||
|
||||
### 4. auth_routes.py - 認證路由
|
||||
|
||||
```python
|
||||
# src/mes_dashboard/routes/auth_routes.py
|
||||
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, session, flash
|
||||
from mes_dashboard.services.auth_service import authenticate, is_admin
|
||||
|
||||
auth_bp = Blueprint("auth", __name__, url_prefix="/admin")
|
||||
|
||||
|
||||
@auth_bp.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
"""管理員登入頁面。"""
|
||||
error = None
|
||||
|
||||
if request.method == "POST":
|
||||
username = request.form.get("username", "").strip()
|
||||
password = request.form.get("password", "")
|
||||
|
||||
if not username or not password:
|
||||
error = "請輸入帳號和密碼"
|
||||
else:
|
||||
user = authenticate(username, password)
|
||||
if user is None:
|
||||
error = "帳號或密碼錯誤"
|
||||
elif not is_admin(user):
|
||||
error = "您不是管理員,無法登入後台"
|
||||
else:
|
||||
# 登入成功
|
||||
session["admin"] = {
|
||||
"username": user["username"],
|
||||
"displayName": user["displayName"],
|
||||
"mail": user["mail"],
|
||||
"department": user["department"],
|
||||
"login_time": datetime.now().isoformat()
|
||||
}
|
||||
next_url = request.args.get("next", url_for("portal_index"))
|
||||
return redirect(next_url)
|
||||
|
||||
return render_template("login.html", error=error)
|
||||
|
||||
|
||||
@auth_bp.route("/logout")
|
||||
def logout():
|
||||
"""登出。"""
|
||||
session.pop("admin", None)
|
||||
return redirect(url_for("portal_index"))
|
||||
```
|
||||
|
||||
### 5. admin_routes.py - 管理員路由
|
||||
|
||||
```python
|
||||
# src/mes_dashboard/routes/admin_routes.py
|
||||
|
||||
from flask import Blueprint, render_template, jsonify, request
|
||||
from mes_dashboard.core.permissions import admin_required
|
||||
from mes_dashboard.services.page_registry import get_all_pages, set_page_status
|
||||
|
||||
admin_bp = Blueprint("admin", __name__, url_prefix="/admin")
|
||||
|
||||
|
||||
@admin_bp.route("/pages")
|
||||
@admin_required
|
||||
def pages():
|
||||
"""頁面管理介面。"""
|
||||
return render_template("admin/pages.html")
|
||||
|
||||
|
||||
@admin_bp.route("/api/pages", methods=["GET"])
|
||||
@admin_required
|
||||
def api_get_pages():
|
||||
"""API: 取得所有頁面。"""
|
||||
return jsonify({"success": True, "pages": get_all_pages()})
|
||||
|
||||
|
||||
@admin_bp.route("/api/pages/<path:route>", methods=["PUT"])
|
||||
@admin_required
|
||||
def api_update_page(route):
|
||||
"""API: 更新頁面狀態。"""
|
||||
data = request.get_json()
|
||||
status = data.get("status")
|
||||
name = data.get("name")
|
||||
|
||||
if status not in ("released", "dev"):
|
||||
return jsonify({"success": False, "error": "Invalid status"}), 400
|
||||
|
||||
route = "/" + route if not route.startswith("/") else route
|
||||
set_page_status(route, status, name)
|
||||
return jsonify({"success": True})
|
||||
```
|
||||
|
||||
### 6. app.py 修改 - 加入權限檢查
|
||||
|
||||
```python
|
||||
# 在 create_app() 中加入
|
||||
|
||||
from mes_dashboard.routes.auth_routes import auth_bp
|
||||
from mes_dashboard.routes.admin_routes import admin_bp
|
||||
from mes_dashboard.services.page_registry import get_page_status, is_api_public
|
||||
from mes_dashboard.core.permissions import is_admin_logged_in
|
||||
|
||||
def create_app(config_name: str | None = None) -> Flask:
|
||||
app = Flask(__name__, template_folder="templates")
|
||||
|
||||
# ... 現有設定 ...
|
||||
|
||||
# Session 設定
|
||||
app.secret_key = os.environ.get("SECRET_KEY", "dev-secret-key-change-in-prod")
|
||||
|
||||
# 註冊認證路由
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(admin_bp)
|
||||
|
||||
# 權限檢查中介層
|
||||
@app.before_request
|
||||
def check_page_access():
|
||||
# 跳過靜態檔案
|
||||
if request.endpoint == "static":
|
||||
return
|
||||
|
||||
# API 端點檢查
|
||||
if request.path.startswith("/api/"):
|
||||
if is_api_public():
|
||||
return # API 公開
|
||||
# 若 API 不公開,檢查管理員
|
||||
if not is_admin_logged_in():
|
||||
return jsonify({"error": "Unauthorized"}), 401
|
||||
return
|
||||
|
||||
# 跳過認證相關頁面
|
||||
if request.path.startswith("/admin/login") or request.path.startswith("/admin/logout"):
|
||||
return
|
||||
|
||||
# 管理員頁面需要登入
|
||||
if request.path.startswith("/admin/"):
|
||||
if not is_admin_logged_in():
|
||||
return redirect(url_for("auth.login", next=request.url))
|
||||
return
|
||||
|
||||
# 檢查頁面狀態
|
||||
page_status = get_page_status(request.path)
|
||||
if page_status == "dev" and not is_admin_logged_in():
|
||||
return render_template("403.html"), 403
|
||||
|
||||
# 注入模板變數
|
||||
@app.context_processor
|
||||
def inject_admin():
|
||||
return {
|
||||
"is_admin": is_admin_logged_in(),
|
||||
"admin_user": session.get("admin")
|
||||
}
|
||||
|
||||
# ... 現有路由 ...
|
||||
```
|
||||
|
||||
## UI 設計
|
||||
|
||||
### 登入頁面 (`login.html`)
|
||||
|
||||
簡潔的登入表單:
|
||||
- 標題:管理員登入
|
||||
- 帳號輸入框(支援工號或 email)
|
||||
- 密碼輸入框
|
||||
- 登入按鈕
|
||||
- 錯誤訊息顯示區
|
||||
|
||||
### 頁面管理介面 (`admin/pages.html`)
|
||||
|
||||
表格形式:
|
||||
| 路由 | 名稱 | 狀態 | 操作 |
|
||||
|------|------|------|------|
|
||||
| / | 首頁 | Released | [切換] |
|
||||
| /wip-overview | WIP 即時概況 | Released | [切換] |
|
||||
| /new-feature | 新功能 | Dev | [切換] |
|
||||
|
||||
功能:
|
||||
- 點擊狀態切換 released/dev
|
||||
- 批次操作按鈕
|
||||
- 即時儲存(不需重整)
|
||||
|
||||
### 導航列調整
|
||||
|
||||
在 `_base.html` 或各頁面模板中加入:
|
||||
|
||||
```html
|
||||
<!-- 右上角登入狀態 -->
|
||||
<div class="admin-status">
|
||||
{% if is_admin %}
|
||||
<span>{{ admin_user.displayName }}</span>
|
||||
<a href="{{ url_for('admin.pages') }}">頁面管理</a>
|
||||
<a href="{{ url_for('auth.logout') }}">登出</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('auth.login') }}">管理員登入</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
```
|
||||
|
||||
### 403 頁面 (`403.html`)
|
||||
|
||||
當存取 dev 頁面時顯示:
|
||||
- 標題:頁面開發中
|
||||
- 說明:此頁面尚未發布,僅管理員可存取
|
||||
- 返回首頁連結
|
||||
|
||||
## 設定
|
||||
|
||||
### config/settings.py 新增
|
||||
|
||||
```python
|
||||
class Config:
|
||||
# ... 現有設定 ...
|
||||
|
||||
# Auth 設定
|
||||
LDAP_API_URL = "https://adapi.panjit.com.tw"
|
||||
ADMIN_EMAILS = ["ymirliu@panjit.com.tw"]
|
||||
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-key")
|
||||
|
||||
# Session 設定
|
||||
SESSION_TYPE = "filesystem" # 或 redis
|
||||
PERMANENT_SESSION_LIFETIME = 28800 # 8 小時
|
||||
```
|
||||
|
||||
## 安全考量
|
||||
|
||||
1. **Secret Key**: 生產環境必須設定環境變數 `SECRET_KEY`
|
||||
2. **HTTPS**: LDAP API 已使用 HTTPS
|
||||
3. **Session**: JWT Token 儲存於 server-side session,不暴露給前端
|
||||
4. **管理員清單**: 透過設定檔控制,可隨時更新
|
||||
|
||||
## 測試計畫
|
||||
|
||||
1. **auth_service 測試**
|
||||
- 正確帳密驗證成功
|
||||
- 錯誤帳密驗證失敗
|
||||
- 管理員判斷正確
|
||||
|
||||
2. **page_registry 測試**
|
||||
- 讀取/寫入頁面狀態
|
||||
- 未註冊頁面預設為 dev
|
||||
- 並發存取安全
|
||||
|
||||
3. **權限中介層測試**
|
||||
- Released 頁面所有人可存取
|
||||
- Dev 頁面非管理員返回 403
|
||||
- 管理員可存取所有頁面
|
||||
|
||||
4. **路由測試**
|
||||
- 登入/登出流程
|
||||
- 頁面管理 API
|
||||
109
openspec/changes/archive/2026-01-28-admin-auth/proposal.md
Normal file
109
openspec/changes/archive/2026-01-28-admin-auth/proposal.md
Normal file
@@ -0,0 +1,109 @@
|
||||
## Why
|
||||
|
||||
此專案是一個開放的查詢平台,所有人皆可存取已發布的頁面。但隨著功能逐漸增加,需要區分:
|
||||
- **已發布頁面 (Released)**:所有人皆可存取(如 WIP Overview、WIP Detail、Hold Detail)
|
||||
- **開發中頁面 (Dev)**:僅管理員可存取,用於測試新功能
|
||||
|
||||
缺乏權限控制的問題:
|
||||
- 無法保護開發中的功能不被外部使用者看到
|
||||
- 無法動態調整頁面的發布狀態
|
||||
- 沒有管理介面來控制頁面狀態
|
||||
|
||||
## What Changes
|
||||
|
||||
### 1. 管理員認證系統
|
||||
|
||||
使用公司 LDAP API(`https://adapi.panjit.com.tw`)進行身份驗證。
|
||||
|
||||
**管理員登入頁面** (`/admin/login`)
|
||||
- 支援工號或 email 登入
|
||||
- 驗證通過後檢查是否為管理員
|
||||
- 若非管理員,顯示「您不是管理員」訊息
|
||||
- 管理員登入後將 JWT Token 存入 Flask Session
|
||||
|
||||
**登出功能** (`/admin/logout`)
|
||||
- 清除 Session 中的認證資訊
|
||||
- 重導向至首頁
|
||||
|
||||
### 2. 權限控制機制
|
||||
|
||||
**頁面狀態定義**
|
||||
- `released`: 所有人可存取(公開查詢平台)
|
||||
- `dev`: 僅管理員可存取(開發中功能)
|
||||
|
||||
**存取規則**
|
||||
- 所有人(無論登入與否):可存取 `released` 狀態的頁面
|
||||
- 管理員:可存取所有頁面(包含 `dev`)+ 管理介面
|
||||
|
||||
**初始已發布頁面**
|
||||
- `/` - 首頁
|
||||
- `/wip-overview` - WIP Overview
|
||||
- `/wip-detail` - WIP Detail
|
||||
- `/hold-detail` - Hold Detail
|
||||
- `/tables` - 表格總覽
|
||||
- 所有 `/api/*` 端點
|
||||
|
||||
### 3. 管理員功能
|
||||
|
||||
**管理員帳號**
|
||||
- `ymirliu@panjit.com.tw` 為管理員
|
||||
- 可透過設定檔擴充管理員清單
|
||||
|
||||
**管理員頁面** (`/admin/pages`)
|
||||
- 顯示所有頁面路由清單
|
||||
- 每個頁面可設定為 `released` 或 `dev`
|
||||
- 即時生效,不需重啟服務
|
||||
- 頁面狀態儲存於 JSON 檔案或資料庫
|
||||
|
||||
**管理介面功能**
|
||||
- 頁面列表:顯示路由、名稱、目前狀態
|
||||
- 狀態切換:點擊即可切換 released/dev
|
||||
- 批次操作:可一次將多個頁面設為 released 或 dev
|
||||
- 新增頁面:當新增路由時自動偵測或手動新增
|
||||
|
||||
### 4. 導航列調整
|
||||
|
||||
**一般使用者**(未登入或非管理員)
|
||||
- 僅顯示已發布頁面的導航項目
|
||||
- 頁面右上角顯示「管理員登入」連結
|
||||
|
||||
**管理員狀態**(已登入且為管理員)
|
||||
- 顯示管理員名稱
|
||||
- 顯示「登出」按鈕
|
||||
- 顯示所有頁面(包含開發中)
|
||||
- 開發中頁面標示 [DEV] 標籤
|
||||
- 顯示「頁面管理」連結
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `admin-login`: 管理員登入頁面,使用 LDAP API 驗證,僅允許管理員登入
|
||||
- `admin-pages`: 管理員頁面管理介面,設定頁面 released/dev 狀態
|
||||
- `auth-middleware`: 權限檢查中介層,dev 頁面僅管理員可存取
|
||||
- `page-registry`: 頁面註冊系統,管理所有頁面的發布狀態
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `base-template`: 導航欄根據管理員狀態顯示不同內容
|
||||
|
||||
## Impact
|
||||
|
||||
**新增檔案**
|
||||
- `src/mes_dashboard/routes/auth_routes.py` - 認證路由(登入/登出)
|
||||
- `src/mes_dashboard/routes/admin_routes.py` - 管理員路由(頁面管理)
|
||||
- `src/mes_dashboard/services/auth_service.py` - LDAP API 客戶端
|
||||
- `src/mes_dashboard/services/page_registry.py` - 頁面狀態管理
|
||||
- `src/mes_dashboard/templates/login.html` - 登入頁面
|
||||
- `src/mes_dashboard/templates/admin/pages.html` - 頁面管理介面
|
||||
- `src/mes_dashboard/core/permissions.py` - 權限定義與檢查裝飾器
|
||||
- `data/page_status.json` - 頁面狀態設定檔
|
||||
|
||||
**修改檔案**
|
||||
- `src/mes_dashboard/templates/base.html` - 導航欄登入狀態顯示
|
||||
- `src/mes_dashboard/app.py` - 註冊認證路由與 before_request 中介層
|
||||
- `config.py` - 新增認證設定(LDAP API URL、管理員清單、Session 設定)
|
||||
|
||||
**向後相容**
|
||||
- 已發布頁面不受影響,未登入仍可正常存取
|
||||
- 現有功能不需任何修改
|
||||
85
openspec/changes/archive/2026-01-28-admin-auth/tasks.md
Normal file
85
openspec/changes/archive/2026-01-28-admin-auth/tasks.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# Admin Auth 實作任務
|
||||
|
||||
## 後端任務
|
||||
|
||||
### Task 1: 新增認證服務
|
||||
- [x] 建立 `src/mes_dashboard/services/auth_service.py`
|
||||
- [x] 實作 `authenticate(username, password, domain)` 函數
|
||||
- [x] 實作 `is_admin(user)` 函數
|
||||
- [x] 新增 LDAP API 錯誤處理(timeout、連線失敗)
|
||||
|
||||
### Task 2: 新增頁面狀態管理服務
|
||||
- [x] 建立 `src/mes_dashboard/services/page_registry.py`
|
||||
- [x] 實作 `get_page_status(route)` 函數
|
||||
- [x] 實作 `set_page_status(route, status, name)` 函數
|
||||
- [x] 實作 `get_all_pages()` 函數
|
||||
- [x] 建立 `data/page_status.json` 初始設定檔(現有頁面設為 released)
|
||||
|
||||
### Task 3: 新增權限檢查模組
|
||||
- [x] 建立 `src/mes_dashboard/core/permissions.py`
|
||||
- [x] 實作 `is_admin_logged_in()` 函數
|
||||
- [x] 實作 `get_current_admin()` 函數
|
||||
- [x] 實作 `@admin_required` 裝飾器
|
||||
|
||||
### Task 4: 新增認證路由
|
||||
- [x] 建立 `src/mes_dashboard/routes/auth_routes.py`
|
||||
- [x] 實作 `GET /admin/login` 登入頁面
|
||||
- [x] 實作 `POST /admin/login` 登入處理
|
||||
- [x] 實作 `GET /admin/logout` 登出
|
||||
- [x] 在 `routes/__init__.py` 註冊 `auth_bp`
|
||||
|
||||
### Task 5: 新增管理員路由
|
||||
- [x] 建立 `src/mes_dashboard/routes/admin_routes.py`
|
||||
- [x] 實作 `GET /admin/pages` 頁面管理介面
|
||||
- [x] 實作 `GET /admin/api/pages` 取得所有頁面
|
||||
- [x] 實作 `PUT /admin/api/pages/<route>` 更新頁面狀態
|
||||
- [x] 在 `routes/__init__.py` 註冊 `admin_bp`
|
||||
|
||||
### Task 6: 修改 app.py
|
||||
- [x] 新增 Flask session 設定(SECRET_KEY)
|
||||
- [x] 新增 `before_request` 權限檢查中介層
|
||||
- [x] 新增 `context_processor` 注入 `is_admin`、`admin_user` 和 `can_view_page`
|
||||
- [x] 註冊 auth_bp 和 admin_bp
|
||||
|
||||
### Task 7: 更新設定
|
||||
- [x] 在 `config/settings.py` 新增 `LDAP_API_URL` 設定
|
||||
- [x] 在 `config/settings.py` 新增 `ADMIN_EMAILS` 設定
|
||||
- [x] 在 `config/settings.py` 新增 `SECRET_KEY` 設定
|
||||
- [x] 在 `requirements.txt` 新增 `requests` 依賴
|
||||
|
||||
## 前端任務
|
||||
|
||||
### Task 8: 建立登入頁面
|
||||
- [x] 建立 `templates/login.html`
|
||||
- [x] 實作帳號/密碼輸入表單
|
||||
- [x] 實作錯誤訊息顯示
|
||||
- [x] 套用現有樣式(與 portal.html 一致)
|
||||
|
||||
### Task 9: 建立頁面管理介面
|
||||
- [x] 建立 `templates/admin/pages.html`
|
||||
- [x] 實作頁面列表表格(路由、名稱、狀態)
|
||||
- [x] 實作狀態切換功能(點擊切換 released/dev)
|
||||
- [x] 實作即時儲存(API 呼叫)
|
||||
- [x] 實作 Toast 通知
|
||||
|
||||
### Task 10: 建立 403 頁面
|
||||
- [x] 建立 `templates/403.html`
|
||||
- [x] 顯示「頁面開發中」訊息
|
||||
- [x] 提供返回首頁連結
|
||||
|
||||
### Task 11: 修改導航列
|
||||
- [x] 在 portal.html 右上角加入管理員登入/登出連結
|
||||
- [x] 管理員登入後顯示名稱和「頁面管理」連結
|
||||
- [x] Dev 頁面 tabs 對非管理員隱藏(使用 `can_view_page` 條件渲染)
|
||||
|
||||
## 測試任務(延後)
|
||||
|
||||
### Task 12-14: 單元測試與整合測試
|
||||
- [ ] 待後續補充
|
||||
|
||||
## 部署任務
|
||||
|
||||
### Task 15: 環境設定
|
||||
- [x] 建立初始 page_status.json(現有頁面設為 released)
|
||||
- [ ] 設定生產環境 SECRET_KEY 環境變數(部署時處理)
|
||||
- [x] 確認 LDAP API 連線正常(手動測試通過)
|
||||
203
openspec/changes/archive/2026-01-28-deployment-config/design.md
Normal file
203
openspec/changes/archive/2026-01-28-deployment-config/design.md
Normal file
@@ -0,0 +1,203 @@
|
||||
# Deployment Configuration - Technical Design
|
||||
|
||||
## Overview
|
||||
|
||||
本設計涵蓋三個部分:環境變數配置、啟動腳本修改、部署腳本建立。
|
||||
|
||||
## 1. Environment Configuration (env-config)
|
||||
|
||||
### 完整環境變數清單
|
||||
|
||||
根據 `settings.py`、`gunicorn.conf.py` 和 `auth_service.py` 分析,需要以下環境變數:
|
||||
|
||||
```bash
|
||||
# ============================================================
|
||||
# Database Configuration
|
||||
# ============================================================
|
||||
DB_HOST=10.1.1.58
|
||||
DB_PORT=1521
|
||||
DB_SERVICE=DWDB
|
||||
DB_USER=your_username
|
||||
DB_PASSWORD=your_password
|
||||
|
||||
# Database Pool Settings (optional)
|
||||
DB_POOL_SIZE=5 # Default: 5 (dev: 2, prod: 10)
|
||||
DB_MAX_OVERFLOW=10 # Default: 10 (dev: 3, prod: 20)
|
||||
|
||||
# ============================================================
|
||||
# Flask Configuration
|
||||
# ============================================================
|
||||
FLASK_ENV=development # development | production | testing
|
||||
FLASK_DEBUG=0 # 0 for production, 1 for development
|
||||
|
||||
# Session Security (REQUIRED for production)
|
||||
SECRET_KEY=your-secret-key-change-in-production
|
||||
|
||||
# Session timeout in seconds (default: 28800 = 8 hours)
|
||||
SESSION_LIFETIME=28800
|
||||
|
||||
# ============================================================
|
||||
# Authentication Configuration
|
||||
# ============================================================
|
||||
LDAP_API_URL=https://adapi.panjit.com.tw
|
||||
ADMIN_EMAILS=ymirliu@panjit.com.tw
|
||||
|
||||
# ============================================================
|
||||
# Gunicorn Configuration
|
||||
# ============================================================
|
||||
GUNICORN_BIND=0.0.0.0:8080
|
||||
GUNICORN_WORKERS=2 # Recommend: 2 * CPU cores + 1
|
||||
GUNICORN_THREADS=4 # Threads per worker
|
||||
```
|
||||
|
||||
### 變數分類
|
||||
|
||||
| Category | Required | Variables |
|
||||
|----------|----------|-----------|
|
||||
| Database | Yes | DB_HOST, DB_PORT, DB_SERVICE, DB_USER, DB_PASSWORD |
|
||||
| Flask | Yes | FLASK_ENV, SECRET_KEY |
|
||||
| Auth | No (has defaults) | LDAP_API_URL, ADMIN_EMAILS |
|
||||
| Gunicorn | No (has defaults) | GUNICORN_BIND, GUNICORN_WORKERS, GUNICORN_THREADS |
|
||||
| Tuning | No (has defaults) | DB_POOL_SIZE, DB_MAX_OVERFLOW, SESSION_LIFETIME |
|
||||
|
||||
## 2. Startup Script Modification (startup-script)
|
||||
|
||||
### 修改位置
|
||||
|
||||
`scripts/start_server.sh` 的 `do_start()` 函數中。
|
||||
|
||||
### 實作方式
|
||||
|
||||
在啟動 gunicorn 之前,加入 `.env` 檔案載入:
|
||||
|
||||
```bash
|
||||
# Load .env file if exists
|
||||
load_env() {
|
||||
if [ -f "${ROOT}/.env" ]; then
|
||||
log_info "Loading environment from .env"
|
||||
set -a # Mark all variables for export
|
||||
source "${ROOT}/.env"
|
||||
set +a
|
||||
fi
|
||||
}
|
||||
```
|
||||
|
||||
### 調用位置
|
||||
|
||||
在 `do_start()` 函數中,`conda activate` 之後、`gunicorn` 啟動之前:
|
||||
|
||||
```bash
|
||||
do_start() {
|
||||
# ... existing checks ...
|
||||
|
||||
conda activate "$CONDA_ENV"
|
||||
load_env # <-- Add here
|
||||
export PYTHONPATH="${ROOT}/src:${PYTHONPATH:-}"
|
||||
|
||||
# ... gunicorn startup ...
|
||||
}
|
||||
```
|
||||
|
||||
### 注意事項
|
||||
|
||||
- 使用 `set -a` / `set +a` 確保變數被 export
|
||||
- `.env` 檔案不存在時不報錯(開發環境可能直接使用 shell 變數)
|
||||
- 不覆蓋已設定的環境變數(優先順序:shell > .env)
|
||||
|
||||
## 3. Deployment Script (deploy-script)
|
||||
|
||||
### 功能需求
|
||||
|
||||
1. 檢查 Python/Conda 環境
|
||||
2. 建立 Conda 環境(如不存在)
|
||||
3. 安裝依賴
|
||||
4. 複製 `.env.example` 到 `.env`(如不存在)
|
||||
5. 提示使用者編輯 `.env`
|
||||
6. 驗證資料庫連線
|
||||
7. 啟動服務
|
||||
|
||||
### 腳本結構
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# scripts/deploy.sh - MES Dashboard Deployment Script
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
CONDA_ENV="mes-dashboard"
|
||||
PYTHON_VERSION="3.11"
|
||||
|
||||
# Functions:
|
||||
# - check_prerequisites() - Check conda, git
|
||||
# - setup_conda_env() - Create/update conda environment
|
||||
# - install_dependencies() - pip install -r requirements.txt
|
||||
# - setup_env_file() - Copy .env.example if needed
|
||||
# - verify_database() - Test database connection
|
||||
# - show_next_steps() - Print post-deployment instructions
|
||||
|
||||
main() {
|
||||
check_prerequisites
|
||||
setup_conda_env
|
||||
install_dependencies
|
||||
setup_env_file
|
||||
verify_database
|
||||
show_next_steps
|
||||
}
|
||||
```
|
||||
|
||||
### 互動流程
|
||||
|
||||
```
|
||||
$ ./scripts/deploy.sh
|
||||
|
||||
[INFO] MES Dashboard Deployment
|
||||
[INFO] Checking prerequisites...
|
||||
[OK] Conda found
|
||||
[OK] Git found
|
||||
|
||||
[INFO] Setting up conda environment...
|
||||
[OK] Environment 'mes-dashboard' ready
|
||||
|
||||
[INFO] Installing dependencies...
|
||||
[OK] Dependencies installed
|
||||
|
||||
[INFO] Setting up configuration...
|
||||
[WARN] .env file not found
|
||||
[INFO] Copying .env.example to .env
|
||||
[IMPORTANT] Please edit .env with your database credentials:
|
||||
nano /path/to/.env
|
||||
|
||||
[INFO] Press Enter after editing .env to continue...
|
||||
|
||||
[INFO] Verifying database connection...
|
||||
[OK] Database connection successful
|
||||
|
||||
========================================
|
||||
Deployment Complete!
|
||||
========================================
|
||||
|
||||
Start the server:
|
||||
./scripts/start_server.sh start
|
||||
|
||||
View logs:
|
||||
./scripts/start_server.sh logs follow
|
||||
|
||||
Access URL:
|
||||
http://localhost:8080
|
||||
```
|
||||
|
||||
## File Changes Summary
|
||||
|
||||
| File | Action | Description |
|
||||
|------|--------|-------------|
|
||||
| `.env.example` | Modify | Add all environment variables with documentation |
|
||||
| `scripts/start_server.sh` | Modify | Add `load_env()` function |
|
||||
| `scripts/deploy.sh` | Create | New deployment automation script |
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
此變更為向後相容:
|
||||
- 現有 `.env` 檔案不受影響
|
||||
- 環境變數有預設值,不會因缺少新變數而失敗
|
||||
- `start_server.sh` 即使沒有 `.env` 也能正常運作
|
||||
@@ -0,0 +1,54 @@
|
||||
# Deployment Configuration Enhancement
|
||||
|
||||
## Problem Statement
|
||||
|
||||
目前專案的環境變數設定不完整,缺少以下關鍵配置:
|
||||
- `.env.example` 未包含所有必要的環境變數(如 SECRET_KEY、LDAP_API_URL、PORT 設定等)
|
||||
- 啟動腳本 `start_server.sh` 未明確載入 `.env` 檔案
|
||||
- 缺少生產環境部署的完整指引
|
||||
|
||||
這導致部署時需要手動設定多個環境變數,容易遺漏且不易維護。
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
1. **更新 `.env.example`**:加入所有必要的環境變數及說明
|
||||
2. **修改啟動腳本**:確保從 `.env` 檔案讀取環境變數
|
||||
3. **建立部署腳本**:自動化部署流程(環境檢查、依賴安裝、服務啟動)
|
||||
|
||||
## Goals
|
||||
|
||||
- 所有環境變數集中在 `.env` 檔案管理
|
||||
- 新部署只需複製 `.env.example`、填入值即可運行
|
||||
- 啟動腳本自動載入 `.env` 檔案
|
||||
- 提供 production-ready 的部署指引
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- 不建立 Docker 容器化部署(可作為未來功能)
|
||||
- 不建立 CI/CD 自動部署流程
|
||||
- 不變更現有的 Gunicorn 配置邏輯
|
||||
|
||||
## Capabilities
|
||||
|
||||
1. **env-config**: 更新 `.env.example` 包含完整環境變數
|
||||
2. **startup-script**: 修改啟動腳本載入 `.env` 檔案
|
||||
3. **deploy-script**: 建立部署腳本自動化初始設定
|
||||
|
||||
## Impact
|
||||
|
||||
### Files to Modify
|
||||
- `.env.example` - 新增缺少的環境變數
|
||||
- `scripts/start_server.sh` - 加入 `.env` 載入邏輯
|
||||
|
||||
### Files to Create
|
||||
- `scripts/deploy.sh` - 部署腳本
|
||||
|
||||
### Dependencies
|
||||
- 無新增依賴(`python-dotenv` 已在 requirements.txt)
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] `.env.example` 包含所有環境變數並有清楚註解
|
||||
- [ ] `start_server.sh` 啟動時自動載入 `.env`
|
||||
- [ ] 新機器可透過 `deploy.sh` 完成部署
|
||||
- [ ] 現有服務重啟後正常運作
|
||||
@@ -0,0 +1,34 @@
|
||||
# Deployment Configuration 實作任務
|
||||
|
||||
## Task 1: 更新 .env.example
|
||||
|
||||
- [x] 加入 Database Configuration 區塊(DB_HOST, DB_PORT, DB_SERVICE, DB_USER, DB_PASSWORD)
|
||||
- [x] 加入 Database Pool Settings 區塊(DB_POOL_SIZE, DB_MAX_OVERFLOW)
|
||||
- [x] 加入 Flask Configuration 區塊(FLASK_ENV, FLASK_DEBUG, SECRET_KEY, SESSION_LIFETIME)
|
||||
- [x] 加入 Authentication Configuration 區塊(LDAP_API_URL, ADMIN_EMAILS)
|
||||
- [x] 加入 Gunicorn Configuration 區塊(GUNICORN_BIND, GUNICORN_WORKERS, GUNICORN_THREADS)
|
||||
- [x] 每個變數加入說明註解
|
||||
|
||||
## Task 2: 修改 start_server.sh
|
||||
|
||||
- [x] 新增 `load_env()` 函數
|
||||
- [x] 在 `do_start()` 中呼叫 `load_env()`(conda activate 之後)
|
||||
- [x] 測試:設定 `.env` 中的 `GUNICORN_BIND=0.0.0.0:9000`,確認服務監聽 9000 port
|
||||
|
||||
## Task 3: 建立 deploy.sh
|
||||
|
||||
- [x] 建立 `scripts/deploy.sh` 腳本
|
||||
- [x] 實作 `check_prerequisites()` - 檢查 conda
|
||||
- [x] 實作 `setup_conda_env()` - 建立/更新 conda 環境
|
||||
- [x] 實作 `install_dependencies()` - pip install requirements.txt
|
||||
- [x] 實作 `setup_env_file()` - 複製 .env.example 並提示編輯
|
||||
- [x] 實作 `verify_database()` - 測試資料庫連線
|
||||
- [x] 實作 `show_next_steps()` - 顯示後續步驟
|
||||
- [x] 設定執行權限 `chmod +x scripts/deploy.sh`
|
||||
|
||||
## Task 4: 驗證與測試
|
||||
|
||||
- [x] 在新環境測試 `deploy.sh` 完整流程(手動驗證)
|
||||
- [x] 確認 `start_server.sh` 正確載入 `.env`(手動驗證)
|
||||
- [x] 確認現有 `.env` 的服務重啟正常(手動驗證)
|
||||
- [x] 確認 SECRET_KEY 變更後 session 仍正常運作(手動驗證)
|
||||
@@ -6,3 +6,4 @@ openpyxl>=3.0.0
|
||||
python-dotenv>=1.0.0
|
||||
gunicorn>=21.2.0
|
||||
waitress>=2.1.2; platform_system=="Windows"
|
||||
requests>=2.28.0
|
||||
|
||||
217
scripts/deploy.sh
Normal file
217
scripts/deploy.sh
Normal file
@@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# MES Dashboard Deployment Script
|
||||
# Usage: ./deploy.sh [--skip-db-check]
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
# ============================================================
|
||||
# Configuration
|
||||
# ============================================================
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
CONDA_ENV="mes-dashboard"
|
||||
PYTHON_VERSION="3.11"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# ============================================================
|
||||
# Helper Functions
|
||||
# ============================================================
|
||||
log_info() {
|
||||
echo -e "${BLUE}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}[OK]${NC} $1"
|
||||
}
|
||||
|
||||
log_warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
log_important() {
|
||||
echo -e "${YELLOW}[IMPORTANT]${NC} $1"
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# Deployment Functions
|
||||
# ============================================================
|
||||
|
||||
check_prerequisites() {
|
||||
log_info "Checking prerequisites..."
|
||||
|
||||
# Check conda
|
||||
if ! command -v conda &> /dev/null; then
|
||||
log_error "Conda not found. Please install Miniconda/Anaconda first."
|
||||
log_info "Download from: https://docs.conda.io/en/latest/miniconda.html"
|
||||
exit 1
|
||||
fi
|
||||
log_success "Conda found"
|
||||
|
||||
# Source conda
|
||||
source "$(conda info --base)/etc/profile.d/conda.sh"
|
||||
}
|
||||
|
||||
setup_conda_env() {
|
||||
log_info "Setting up conda environment..."
|
||||
|
||||
# Check if environment exists
|
||||
if conda env list | grep -q "^${CONDA_ENV} "; then
|
||||
log_success "Environment '${CONDA_ENV}' already exists"
|
||||
else
|
||||
log_info "Creating conda environment '${CONDA_ENV}' with Python ${PYTHON_VERSION}..."
|
||||
conda create -n "$CONDA_ENV" python="$PYTHON_VERSION" -y
|
||||
log_success "Environment '${CONDA_ENV}' created"
|
||||
fi
|
||||
|
||||
# Activate environment
|
||||
conda activate "$CONDA_ENV"
|
||||
log_success "Environment '${CONDA_ENV}' activated"
|
||||
}
|
||||
|
||||
install_dependencies() {
|
||||
log_info "Installing dependencies..."
|
||||
|
||||
if [ -f "${ROOT}/requirements.txt" ]; then
|
||||
pip install -r "${ROOT}/requirements.txt" --quiet
|
||||
log_success "Dependencies installed"
|
||||
else
|
||||
log_error "requirements.txt not found"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
setup_env_file() {
|
||||
log_info "Setting up configuration..."
|
||||
|
||||
if [ -f "${ROOT}/.env" ]; then
|
||||
log_success ".env file already exists"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ ! -f "${ROOT}/.env.example" ]; then
|
||||
log_error ".env.example not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_warn ".env file not found"
|
||||
log_info "Copying .env.example to .env"
|
||||
cp "${ROOT}/.env.example" "${ROOT}/.env"
|
||||
|
||||
echo ""
|
||||
log_important "Please edit .env with your database credentials:"
|
||||
echo " nano ${ROOT}/.env"
|
||||
echo ""
|
||||
echo "Required settings:"
|
||||
echo " - DB_USER: Your database username"
|
||||
echo " - DB_PASSWORD: Your database password"
|
||||
echo " - SECRET_KEY: A secure random key for production"
|
||||
echo ""
|
||||
|
||||
read -p "Press Enter after editing .env to continue..."
|
||||
echo ""
|
||||
}
|
||||
|
||||
verify_database() {
|
||||
local skip_db="${1:-false}"
|
||||
|
||||
if [ "$skip_db" = "true" ]; then
|
||||
log_warn "Skipping database verification"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_info "Verifying database connection..."
|
||||
|
||||
# Load .env
|
||||
if [ -f "${ROOT}/.env" ]; then
|
||||
set -a
|
||||
source "${ROOT}/.env"
|
||||
set +a
|
||||
fi
|
||||
|
||||
export PYTHONPATH="${ROOT}/src:${PYTHONPATH:-}"
|
||||
|
||||
if python -c "
|
||||
from sqlalchemy import text
|
||||
from mes_dashboard.core.database import get_engine
|
||||
engine = get_engine()
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text('SELECT 1 FROM DUAL'))
|
||||
" 2>/dev/null; then
|
||||
log_success "Database connection successful"
|
||||
else
|
||||
log_warn "Database connection failed"
|
||||
log_info "You can still proceed, but the application may not work correctly"
|
||||
log_info "Please check your DB_* settings in .env"
|
||||
fi
|
||||
}
|
||||
|
||||
show_next_steps() {
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " Deployment Complete!"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "Start the server:"
|
||||
echo " ./scripts/start_server.sh start"
|
||||
echo ""
|
||||
echo "View logs:"
|
||||
echo " ./scripts/start_server.sh logs follow"
|
||||
echo ""
|
||||
echo "Check status:"
|
||||
echo " ./scripts/start_server.sh status"
|
||||
echo ""
|
||||
echo "Access URL:"
|
||||
local port=$(grep -E "^GUNICORN_BIND=" "${ROOT}/.env" 2>/dev/null | cut -d: -f2 || echo "8080")
|
||||
echo " http://localhost:${port:-8080}"
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# Main
|
||||
# ============================================================
|
||||
main() {
|
||||
local skip_db=false
|
||||
|
||||
# Parse arguments
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--skip-db-check)
|
||||
skip_db=true
|
||||
;;
|
||||
--help|-h)
|
||||
echo "Usage: $0 [--skip-db-check]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --skip-db-check Skip database connection verification"
|
||||
echo " --help, -h Show this help message"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " MES Dashboard Deployment"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
check_prerequisites
|
||||
setup_conda_env
|
||||
install_dependencies
|
||||
setup_env_file
|
||||
verify_database "$skip_db"
|
||||
show_next_steps
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -49,6 +49,16 @@ timestamp() {
|
||||
date '+%Y-%m-%d %H:%M:%S'
|
||||
}
|
||||
|
||||
# Load .env file if exists
|
||||
load_env() {
|
||||
if [ -f "${ROOT}/.env" ]; then
|
||||
log_info "Loading environment from .env"
|
||||
set -a # Mark all variables for export
|
||||
source "${ROOT}/.env"
|
||||
set +a
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# Environment Check Functions
|
||||
# ============================================================
|
||||
@@ -232,6 +242,9 @@ do_start() {
|
||||
ensure_dirs
|
||||
rotate_logs # Archive old logs before starting new session
|
||||
conda activate "$CONDA_ENV"
|
||||
load_env # Load environment variables from .env file
|
||||
# Re-evaluate port after loading .env (GUNICORN_BIND may have changed)
|
||||
PORT=$(echo "${GUNICORN_BIND:-0.0.0.0:8080}" | cut -d: -f2)
|
||||
export PYTHONPATH="${ROOT}/src:${PYTHONPATH:-}"
|
||||
cd "$ROOT"
|
||||
|
||||
|
||||
@@ -4,15 +4,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
from flask import Flask, jsonify, render_template, request
|
||||
from flask import Flask, jsonify, redirect, render_template, request, session, url_for
|
||||
|
||||
from mes_dashboard.config.tables import TABLES_CONFIG
|
||||
from mes_dashboard.config.settings import get_config
|
||||
from mes_dashboard.core.cache import NoOpCache
|
||||
from mes_dashboard.core.database import get_table_data, get_table_columns, get_engine, init_db, start_keepalive
|
||||
from mes_dashboard.core.permissions import is_admin_logged_in
|
||||
from mes_dashboard.routes import register_routes
|
||||
from mes_dashboard.routes.auth_routes import auth_bp
|
||||
from mes_dashboard.routes.admin_routes import admin_bp
|
||||
from mes_dashboard.services.page_registry import get_page_status, is_api_public
|
||||
|
||||
|
||||
def _configure_logging(app: Flask) -> None:
|
||||
@@ -50,6 +55,9 @@ def create_app(config_name: str | None = None) -> Flask:
|
||||
config_class = get_config(config_name)
|
||||
app.config.from_object(config_class)
|
||||
|
||||
# Session configuration
|
||||
app.secret_key = os.environ.get("SECRET_KEY", "dev-secret-key-change-in-prod")
|
||||
|
||||
# Configure logging first
|
||||
_configure_logging(app)
|
||||
|
||||
@@ -65,6 +73,74 @@ def create_app(config_name: str | None = None) -> Flask:
|
||||
# Register API routes
|
||||
register_routes(app)
|
||||
|
||||
# Register auth and admin routes
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(admin_bp)
|
||||
|
||||
# ========================================================
|
||||
# Permission Middleware
|
||||
# ========================================================
|
||||
|
||||
@app.before_request
|
||||
def check_page_access():
|
||||
"""Check page access permissions before each request."""
|
||||
# Skip static files
|
||||
if request.endpoint == "static":
|
||||
return None
|
||||
|
||||
# API endpoints check
|
||||
if request.path.startswith("/api/"):
|
||||
if is_api_public():
|
||||
return None
|
||||
if not is_admin_logged_in():
|
||||
return jsonify({"error": "Unauthorized"}), 401
|
||||
return None
|
||||
|
||||
# Skip auth-related pages (login/logout)
|
||||
if request.path.startswith("/admin/login") or request.path.startswith("/admin/logout"):
|
||||
return None
|
||||
|
||||
# Admin pages require login
|
||||
if request.path.startswith("/admin/"):
|
||||
if not is_admin_logged_in():
|
||||
return redirect(url_for("auth.login", next=request.url))
|
||||
return None
|
||||
|
||||
# Check page status for registered pages only
|
||||
# Unregistered pages pass through to Flask routing (may return 404)
|
||||
page_status = get_page_status(request.path)
|
||||
if page_status == "dev" and not is_admin_logged_in():
|
||||
return render_template("403.html"), 403
|
||||
|
||||
return None
|
||||
|
||||
# ========================================================
|
||||
# Template Context Processor
|
||||
# ========================================================
|
||||
|
||||
@app.context_processor
|
||||
def inject_admin():
|
||||
"""Inject admin info into all templates."""
|
||||
admin = is_admin_logged_in()
|
||||
|
||||
def can_view_page(route: str) -> bool:
|
||||
"""Check if current user can view a page."""
|
||||
status = get_page_status(route)
|
||||
# Unregistered pages (None) are viewable
|
||||
if status is None:
|
||||
return True
|
||||
# Released pages are viewable by all
|
||||
if status == "released":
|
||||
return True
|
||||
# Dev pages only viewable by admin
|
||||
return admin
|
||||
|
||||
return {
|
||||
"is_admin": admin,
|
||||
"admin_user": session.get("admin"),
|
||||
"can_view_page": can_view_page,
|
||||
}
|
||||
|
||||
# ========================================================
|
||||
# Page Routes
|
||||
# ========================================================
|
||||
|
||||
@@ -24,6 +24,14 @@ class Config:
|
||||
DB_POOL_SIZE = _int_env("DB_POOL_SIZE", 5)
|
||||
DB_MAX_OVERFLOW = _int_env("DB_MAX_OVERFLOW", 10)
|
||||
|
||||
# Auth configuration
|
||||
LDAP_API_URL = os.getenv("LDAP_API_URL", "https://adapi.panjit.com.tw")
|
||||
ADMIN_EMAILS = os.getenv("ADMIN_EMAILS", "ymirliu@panjit.com.tw")
|
||||
SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-key-change-in-prod")
|
||||
|
||||
# Session configuration
|
||||
PERMANENT_SESSION_LIFETIME = _int_env("SESSION_LIFETIME", 28800) # 8 hours
|
||||
|
||||
|
||||
class DevelopmentConfig(Config):
|
||||
"""Development configuration."""
|
||||
|
||||
43
src/mes_dashboard/core/permissions.py
Normal file
43
src/mes_dashboard/core/permissions.py
Normal file
@@ -0,0 +1,43 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Permission checking utilities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING, Callable
|
||||
|
||||
from flask import redirect, request, session, url_for
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any
|
||||
|
||||
|
||||
def is_admin_logged_in() -> bool:
|
||||
"""Check if an admin is currently logged in.
|
||||
|
||||
Returns:
|
||||
True if 'admin' key exists in session
|
||||
"""
|
||||
return "admin" in session
|
||||
|
||||
|
||||
def get_current_admin() -> dict | None:
|
||||
"""Get current logged-in admin info.
|
||||
|
||||
Returns:
|
||||
Admin info dict or None if not logged in
|
||||
"""
|
||||
return session.get("admin")
|
||||
|
||||
|
||||
def admin_required(f: Callable) -> Callable:
|
||||
"""Decorator to require admin login for a route.
|
||||
|
||||
Redirects to login page if not logged in.
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated(*args: Any, **kwargs: Any) -> Any:
|
||||
if not is_admin_logged_in():
|
||||
return redirect(url_for("auth.login", next=request.url))
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
@@ -9,6 +9,8 @@ from .resource_routes import resource_bp
|
||||
from .dashboard_routes import dashboard_bp
|
||||
from .excel_query_routes import excel_query_bp
|
||||
from .hold_routes import hold_bp
|
||||
from .auth_routes import auth_bp
|
||||
from .admin_routes import admin_bp
|
||||
|
||||
|
||||
def register_routes(app) -> None:
|
||||
@@ -25,5 +27,7 @@ __all__ = [
|
||||
'dashboard_bp',
|
||||
'excel_query_bp',
|
||||
'hold_bp',
|
||||
'auth_bp',
|
||||
'admin_bp',
|
||||
'register_routes',
|
||||
]
|
||||
|
||||
47
src/mes_dashboard/routes/admin_routes.py
Normal file
47
src/mes_dashboard/routes/admin_routes.py
Normal file
@@ -0,0 +1,47 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Admin routes for page management."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, jsonify, render_template, request
|
||||
|
||||
from mes_dashboard.core.permissions import admin_required
|
||||
from mes_dashboard.services.page_registry import get_all_pages, set_page_status
|
||||
|
||||
admin_bp = Blueprint("admin", __name__, url_prefix="/admin")
|
||||
|
||||
|
||||
@admin_bp.route("/pages")
|
||||
@admin_required
|
||||
def pages():
|
||||
"""Page management interface."""
|
||||
return render_template("admin/pages.html")
|
||||
|
||||
|
||||
@admin_bp.route("/api/pages", methods=["GET"])
|
||||
@admin_required
|
||||
def api_get_pages():
|
||||
"""API: Get all page configurations."""
|
||||
return jsonify({"success": True, "pages": get_all_pages()})
|
||||
|
||||
|
||||
@admin_bp.route("/api/pages/<path:route>", methods=["PUT"])
|
||||
@admin_required
|
||||
def api_update_page(route: str):
|
||||
"""API: Update page status."""
|
||||
data = request.get_json()
|
||||
status = data.get("status")
|
||||
name = data.get("name")
|
||||
|
||||
if status not in ("released", "dev"):
|
||||
return jsonify({"success": False, "error": "Invalid status"}), 400
|
||||
|
||||
# Ensure route starts with /
|
||||
if not route.startswith("/"):
|
||||
route = "/" + route
|
||||
|
||||
try:
|
||||
set_page_status(route, status, name)
|
||||
return jsonify({"success": True})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
51
src/mes_dashboard/routes/auth_routes.py
Normal file
51
src/mes_dashboard/routes/auth_routes.py
Normal file
@@ -0,0 +1,51 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Authentication routes for admin login/logout."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Blueprint, flash, redirect, render_template, request, session, url_for
|
||||
|
||||
from mes_dashboard.services.auth_service import authenticate, is_admin
|
||||
|
||||
auth_bp = Blueprint("auth", __name__, url_prefix="/admin")
|
||||
|
||||
|
||||
@auth_bp.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
"""Admin login page."""
|
||||
error = None
|
||||
|
||||
if request.method == "POST":
|
||||
username = request.form.get("username", "").strip()
|
||||
password = request.form.get("password", "")
|
||||
|
||||
if not username or not password:
|
||||
error = "請輸入帳號和密碼"
|
||||
else:
|
||||
user = authenticate(username, password)
|
||||
if user is None:
|
||||
error = "帳號或密碼錯誤"
|
||||
elif not is_admin(user):
|
||||
error = "您不是管理員,無法登入後台"
|
||||
else:
|
||||
# Login successful
|
||||
session["admin"] = {
|
||||
"username": user.get("username"),
|
||||
"displayName": user.get("displayName"),
|
||||
"mail": user.get("mail"),
|
||||
"department": user.get("department"),
|
||||
"login_time": datetime.now().isoformat(),
|
||||
}
|
||||
next_url = request.args.get("next", url_for("portal_index"))
|
||||
return redirect(next_url)
|
||||
|
||||
return render_template("login.html", error=error)
|
||||
|
||||
|
||||
@auth_bp.route("/logout")
|
||||
def logout():
|
||||
"""Admin logout."""
|
||||
session.pop("admin", None)
|
||||
return redirect(url_for("portal_index"))
|
||||
72
src/mes_dashboard/services/auth_service.py
Normal file
72
src/mes_dashboard/services/auth_service.py
Normal file
@@ -0,0 +1,72 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Authentication service using LDAP API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Configuration
|
||||
LDAP_API_BASE = os.environ.get("LDAP_API_URL", "https://adapi.panjit.com.tw")
|
||||
ADMIN_EMAILS = os.environ.get(
|
||||
"ADMIN_EMAILS", "ymirliu@panjit.com.tw"
|
||||
).lower().split(",")
|
||||
|
||||
# Timeout for LDAP API requests
|
||||
LDAP_TIMEOUT = 10
|
||||
|
||||
|
||||
def authenticate(username: str, password: str, domain: str = "PANJIT") -> dict | None:
|
||||
"""Authenticate user via LDAP API.
|
||||
|
||||
Args:
|
||||
username: Employee ID or email
|
||||
password: User password
|
||||
domain: Domain name (default: PANJIT)
|
||||
|
||||
Returns:
|
||||
User info dict on success: {username, displayName, mail, department}
|
||||
None on failure
|
||||
"""
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{LDAP_API_BASE}/api/v1/ldap/auth",
|
||||
json={"username": username, "password": password, "domain": domain},
|
||||
timeout=LDAP_TIMEOUT,
|
||||
)
|
||||
data = response.json()
|
||||
|
||||
if data.get("success"):
|
||||
user = data.get("user", {})
|
||||
logger.info("LDAP auth success for user: %s", user.get("username"))
|
||||
return user
|
||||
|
||||
logger.warning("LDAP auth failed for user: %s", username)
|
||||
return None
|
||||
|
||||
except requests.Timeout:
|
||||
logger.error("LDAP API timeout for user: %s", username)
|
||||
return None
|
||||
except requests.RequestException as e:
|
||||
logger.error("LDAP API error for user %s: %s", username, e)
|
||||
return None
|
||||
except (ValueError, KeyError) as e:
|
||||
logger.error("LDAP API response parse error: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def is_admin(user: dict) -> bool:
|
||||
"""Check if user is an admin.
|
||||
|
||||
Args:
|
||||
user: User info dict with 'mail' field
|
||||
|
||||
Returns:
|
||||
True if user email is in ADMIN_EMAILS list
|
||||
"""
|
||||
user_mail = user.get("mail", "").lower().strip()
|
||||
return user_mail in [e.strip() for e in ADMIN_EMAILS]
|
||||
143
src/mes_dashboard/services/page_registry.py
Normal file
143
src/mes_dashboard/services/page_registry.py
Normal file
@@ -0,0 +1,143 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Page registry service for managing page access status."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Data file path (relative to project root)
|
||||
# Path: src/mes_dashboard/services/page_registry.py -> project root/data/
|
||||
DATA_FILE = Path(__file__).parent.parent.parent.parent / "data" / "page_status.json"
|
||||
_lock = Lock()
|
||||
_cache: dict | None = None
|
||||
|
||||
|
||||
def _load() -> dict:
|
||||
"""Load page status configuration."""
|
||||
global _cache
|
||||
if _cache is None:
|
||||
if DATA_FILE.exists():
|
||||
try:
|
||||
_cache = json.loads(DATA_FILE.read_text(encoding="utf-8"))
|
||||
logger.debug("Loaded page status from %s", DATA_FILE)
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.warning("Failed to load page status: %s", e)
|
||||
_cache = {"pages": [], "api_public": True}
|
||||
else:
|
||||
logger.info("Page status file not found, using defaults")
|
||||
_cache = {"pages": [], "api_public": True}
|
||||
return _cache
|
||||
|
||||
|
||||
def _save(data: dict) -> None:
|
||||
"""Save page status configuration."""
|
||||
global _cache
|
||||
try:
|
||||
DATA_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
DATA_FILE.write_text(
|
||||
json.dumps(data, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8"
|
||||
)
|
||||
_cache = data
|
||||
logger.debug("Saved page status to %s", DATA_FILE)
|
||||
except OSError as e:
|
||||
logger.error("Failed to save page status: %s", e)
|
||||
raise
|
||||
|
||||
|
||||
def get_page_status(route: str) -> str | None:
|
||||
"""Get page status ('released' or 'dev').
|
||||
|
||||
Args:
|
||||
route: Page route path (e.g., '/wip-overview')
|
||||
|
||||
Returns:
|
||||
'released', 'dev', or None if page is not registered.
|
||||
"""
|
||||
with _lock:
|
||||
data = _load()
|
||||
for page in data.get("pages", []):
|
||||
if page["route"] == route:
|
||||
return page.get("status", "dev")
|
||||
return None # Not registered - let Flask handle it
|
||||
|
||||
|
||||
def is_page_registered(route: str) -> bool:
|
||||
"""Check if a page is registered in the page registry.
|
||||
|
||||
Args:
|
||||
route: Page route path (e.g., '/wip-overview')
|
||||
|
||||
Returns:
|
||||
True if page is registered, False otherwise.
|
||||
"""
|
||||
return get_page_status(route) is not None
|
||||
|
||||
|
||||
def set_page_status(route: str, status: str, name: str | None = None) -> None:
|
||||
"""Set page status.
|
||||
|
||||
Args:
|
||||
route: Page route path
|
||||
status: 'released' or 'dev'
|
||||
name: Optional page display name
|
||||
"""
|
||||
if status not in ("released", "dev"):
|
||||
raise ValueError(f"Invalid status: {status}")
|
||||
|
||||
with _lock:
|
||||
data = _load()
|
||||
pages = data.setdefault("pages", [])
|
||||
|
||||
# Update existing page
|
||||
for page in pages:
|
||||
if page["route"] == route:
|
||||
page["status"] = status
|
||||
if name:
|
||||
page["name"] = name
|
||||
_save(data)
|
||||
logger.info("Updated page status: %s -> %s", route, status)
|
||||
return
|
||||
|
||||
# Add new page
|
||||
pages.append({
|
||||
"route": route,
|
||||
"name": name or route,
|
||||
"status": status
|
||||
})
|
||||
_save(data)
|
||||
logger.info("Added new page: %s (%s)", route, status)
|
||||
|
||||
|
||||
def get_all_pages() -> list[dict]:
|
||||
"""Get all page configurations.
|
||||
|
||||
Returns:
|
||||
List of page dicts: [{route, name, status}, ...]
|
||||
"""
|
||||
with _lock:
|
||||
return _load().get("pages", [])
|
||||
|
||||
|
||||
def is_api_public() -> bool:
|
||||
"""Check if API endpoints are publicly accessible.
|
||||
|
||||
Returns:
|
||||
True if API endpoints bypass permission checks
|
||||
"""
|
||||
with _lock:
|
||||
return _load().get("api_public", True)
|
||||
|
||||
|
||||
def reload_cache() -> None:
|
||||
"""Force reload of page status from disk."""
|
||||
global _cache
|
||||
with _lock:
|
||||
_cache = None
|
||||
_load()
|
||||
logger.info("Reloaded page status cache")
|
||||
87
src/mes_dashboard/templates/403.html
Normal file
87
src/mes_dashboard/templates/403.html
Normal file
@@ -0,0 +1,87 @@
|
||||
{% extends "_base.html" %}
|
||||
|
||||
{% block title %}頁面開發中 - MES Dashboard{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Microsoft JhengHei', Arial, sans-serif;
|
||||
background: #f5f7fa;
|
||||
color: #222;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.error-container {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 80px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 28px;
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
margin-bottom: 30px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.home-btn {
|
||||
display: inline-block;
|
||||
padding: 12px 24px;
|
||||
font-size: 16px;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.home-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.admin-link {
|
||||
display: block;
|
||||
margin-top: 20px;
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.admin-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="error-container">
|
||||
<div class="error-icon">🚧</div>
|
||||
<h1 class="error-title">頁面開發中</h1>
|
||||
<p class="error-message">
|
||||
此頁面尚未發布,目前僅供管理員存取。<br>
|
||||
如需查看,請聯繫系統管理員。
|
||||
</p>
|
||||
<a href="{{ url_for('portal_index') }}" class="home-btn">返回首頁</a>
|
||||
<a href="{{ url_for('auth.login') }}" class="admin-link">管理員登入</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
296
src/mes_dashboard/templates/admin/pages.html
Normal file
296
src/mes_dashboard/templates/admin/pages.html
Normal file
@@ -0,0 +1,296 @@
|
||||
{% extends "_base.html" %}
|
||||
|
||||
{% block title %}頁面管理 - MES Dashboard{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Microsoft JhengHei', Arial, sans-serif;
|
||||
background: #f5f7fa;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.shell {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 24px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-left h1 {
|
||||
font-size: 24px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.header-left p {
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.admin-info {
|
||||
text-align: right;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.admin-info .name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #eee;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.panel-header h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 14px 20px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #f8f9fa;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
td {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.route-cell {
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.status-released {
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.status-dev {
|
||||
background: #fff3e0;
|
||||
color: #e65100;
|
||||
}
|
||||
|
||||
.status-badge:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
margin-top: 16px;
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="shell">
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<h1>頁面管理</h1>
|
||||
<p>設定頁面存取權限:Released(所有人可見)/ Dev(僅管理員可見)</p>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
{% if admin_user %}
|
||||
<div class="admin-info">
|
||||
<div class="name">{{ admin_user.displayName }}</div>
|
||||
<div>{{ admin_user.mail }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('auth.logout') }}" class="logout-btn">登出</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>所有頁面</h2>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>路由</th>
|
||||
<th>名稱</th>
|
||||
<th>狀態</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="pages-tbody">
|
||||
<tr>
|
||||
<td colspan="3" class="loading">載入中...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="{{ url_for('portal_index') }}" class="back-link">返回首頁</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const tbody = document.getElementById('pages-tbody');
|
||||
|
||||
async function loadPages() {
|
||||
try {
|
||||
const response = await fetch('/admin/api/pages');
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Failed to load pages');
|
||||
}
|
||||
|
||||
renderPages(data.pages);
|
||||
} catch (error) {
|
||||
console.error('Error loading pages:', error);
|
||||
tbody.innerHTML = `<tr><td colspan="3" class="loading">載入失敗: ${error.message}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderPages(pages) {
|
||||
if (pages.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="3" class="loading">尚無頁面設定</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = pages.map(page => `
|
||||
<tr>
|
||||
<td class="route-cell">${escapeHtml(page.route)}</td>
|
||||
<td>${escapeHtml(page.name)}</td>
|
||||
<td>
|
||||
<span class="status-badge status-${page.status}"
|
||||
onclick="toggleStatus('${escapeHtml(page.route)}', '${page.status}')">
|
||||
${page.status === 'released' ? 'Released' : 'Dev'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function toggleStatus(route, currentStatus) {
|
||||
const newStatus = currentStatus === 'released' ? 'dev' : 'released';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/admin/api/pages${route}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: newStatus })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Failed to update status');
|
||||
}
|
||||
|
||||
window.MES?.toast?.success?.(`已更新: ${route} → ${newStatus}`);
|
||||
loadPages();
|
||||
} catch (error) {
|
||||
console.error('Error updating status:', error);
|
||||
window.MES?.toast?.error?.(`更新失敗: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Load pages on page load
|
||||
loadPages();
|
||||
</script>
|
||||
{% endblock %}
|
||||
150
src/mes_dashboard/templates/login.html
Normal file
150
src/mes_dashboard/templates/login.html
Normal file
@@ -0,0 +1,150 @@
|
||||
{% extends "_base.html" %}
|
||||
|
||||
{% block title %}管理員登入 - MES Dashboard{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Microsoft JhengHei', Arial, sans-serif;
|
||||
background: #f5f7fa;
|
||||
color: #222;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.login-header h1 {
|
||||
font-size: 24px;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.login-header p {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
color: #555;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
font-size: 14px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: #fff2f2;
|
||||
border: 1px solid #ffcdd2;
|
||||
color: #d32f2f;
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.login-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.login-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="login-container">
|
||||
<div class="login-header">
|
||||
<h1>管理員登入</h1>
|
||||
<p>使用公司帳號登入後台管理</p>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST">
|
||||
<div class="form-group">
|
||||
<label for="username">帳號</label>
|
||||
<input type="text" id="username" name="username" placeholder="工號或 Email" required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">密碼</label>
|
||||
<input type="password" id="password" name="password" placeholder="密碼" required>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="login-btn">登入</button>
|
||||
</form>
|
||||
|
||||
<a href="{{ url_for('portal_index') }}" class="back-link">返回首頁</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -29,18 +29,51 @@
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
.header-left h1 {
|
||||
font-size: 26px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.header p {
|
||||
.header-left p {
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-status {
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-status a {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.admin-status a:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.admin-name {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
@@ -91,23 +124,52 @@
|
||||
{% block content %}
|
||||
<div class="shell">
|
||||
<div class="header">
|
||||
<h1>MES 報表入口</h1>
|
||||
<p>統一入口:WIP 即時看板、機台狀態報表與數據表查詢工具</p>
|
||||
<div class="header-left">
|
||||
<h1>MES 報表入口</h1>
|
||||
<p>統一入口:WIP 即時看板、機台狀態報表與數據表查詢工具</p>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="admin-status">
|
||||
{% if is_admin %}
|
||||
<span class="admin-name">{{ admin_user.displayName }}</span>
|
||||
<a href="{{ url_for('admin.pages') }}">頁面管理</a>
|
||||
<a href="{{ url_for('auth.logout') }}">登出</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('auth.login') }}">管理員登入</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab active" data-target="wipOverviewFrame">WIP 即時概況</button>
|
||||
{% if can_view_page('/wip-overview') %}
|
||||
<button class="tab" data-target="wipOverviewFrame">WIP 即時概況</button>
|
||||
{% endif %}
|
||||
{% if can_view_page('/resource') %}
|
||||
<button class="tab" data-target="resourceFrame">機台狀態報表</button>
|
||||
{% endif %}
|
||||
{% if can_view_page('/tables') %}
|
||||
<button class="tab" data-target="tableFrame">數據表查詢工具</button>
|
||||
{% endif %}
|
||||
{% if can_view_page('/excel-query') %}
|
||||
<button class="tab" data-target="excelQueryFrame">Excel 批次查詢</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<!-- Lazy load: only first iframe has src, others load on tab click -->
|
||||
<iframe id="wipOverviewFrame" class="active" src="/wip-overview" title="WIP 即時概況"></iframe>
|
||||
<!-- Lazy load: iframes load on tab activation -->
|
||||
{% if can_view_page('/wip-overview') %}
|
||||
<iframe id="wipOverviewFrame" data-src="/wip-overview" title="WIP 即時概況"></iframe>
|
||||
{% endif %}
|
||||
{% if can_view_page('/resource') %}
|
||||
<iframe id="resourceFrame" data-src="/resource" title="機台狀態報表"></iframe>
|
||||
{% endif %}
|
||||
{% if can_view_page('/tables') %}
|
||||
<iframe id="tableFrame" data-src="/tables" title="數據表查詢工具"></iframe>
|
||||
{% endif %}
|
||||
{% if can_view_page('/excel-query') %}
|
||||
<iframe id="excelQueryFrame" data-src="/excel-query" title="Excel 批次查詢"></iframe>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -130,14 +192,17 @@
|
||||
function activateTab(targetId) {
|
||||
tabs.forEach(tab => tab.classList.remove('active'));
|
||||
frames.forEach(frame => frame.classList.remove('active'));
|
||||
document.querySelector(`[data-target="${targetId}"]`).classList.add('active');
|
||||
|
||||
const tabBtn = document.querySelector(`[data-target="${targetId}"]`);
|
||||
const targetFrame = document.getElementById(targetId);
|
||||
targetFrame.classList.add('active');
|
||||
|
||||
// Lazy load: load iframe src on first activation
|
||||
if (targetFrame.dataset.src && !targetFrame.src) {
|
||||
targetFrame.src = targetFrame.dataset.src;
|
||||
if (tabBtn) tabBtn.classList.add('active');
|
||||
if (targetFrame) {
|
||||
targetFrame.classList.add('active');
|
||||
// Lazy load: load iframe src on first activation
|
||||
if (targetFrame.dataset.src && !targetFrame.src) {
|
||||
targetFrame.src = targetFrame.dataset.src;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +210,16 @@
|
||||
tab.addEventListener('click', () => activateTab(tab.dataset.target));
|
||||
});
|
||||
|
||||
// Auto-activate first available tab
|
||||
if (tabs.length > 0) {
|
||||
const firstTab = tabs[0];
|
||||
// Clear any pre-set active states
|
||||
tabs.forEach(tab => tab.classList.remove('active'));
|
||||
frames.forEach(frame => frame.classList.remove('active'));
|
||||
// Activate first tab
|
||||
activateTab(firstTab.dataset.target);
|
||||
}
|
||||
|
||||
window.addEventListener('resize', setFrameHeight);
|
||||
setFrameHeight();
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user