feat(vmis): 租戶自動開通完整流程 + Admin Portal SSO + NC 行事曆訂閱
Backend: - schedule_tenant: NC 新容器自動 pgsql 安裝 (_nc_db_check 全新容器處理) - schedule_tenant: NC 初始化加入 Redis + APCu memcache 設定 (修正 OIDC invalid_state) - schedule_tenant: 新租戶 KC realm 自動設定 accessCodeLifespan=600s (修正 authentication_expired) - schedule_account: NC Mail 帳號自動設定 (nc_mail_result/nc_mail_done_at) - schedule_account: NC 台灣國定假日行事曆自動訂閱 (CalDAV MKCALENDAR) - nextcloud_client: 新增 subscribe_calendar() CalDAV 訂閱方法 - settings: 新增系統設定 API (site_title/version/timezone/SSO/Keycloak) - models/result: 新增 nc_mail_result, nc_mail_done_at 欄位 - alembic: 遷移 002(system_settings) 003(keycloak_admin) 004(nc_mail_result) Frontend (Admin Portal): - 新增完整管理後台 (index/tenants/accounts/servers/schedules/logs/settings/system-status) - api.js: Keycloak JS Adapter SSO 整合 (PKCE/S256, fallback KC JS 來源, 自動 token 更新) - index.html: Promise.allSettled 取代 Promise.all,防止單一 API 失敗影響整頁 - 所有頁面加入 try/catch + toast 錯誤處理 - 新增品牌 LOGO 與 favicon Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
135
backend/app/api/v1/settings.py
Normal file
135
backend/app/api/v1/settings.py
Normal file
@@ -0,0 +1,135 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.utils import configure_timezone
|
||||
from app.models.settings import SystemSettings
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.account import Account
|
||||
from app.schemas.settings import SettingsUpdate, SettingsResponse
|
||||
from app.services.keycloak_client import KeycloakClient
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
|
||||
|
||||
def _get_or_create(db: Session) -> SystemSettings:
|
||||
s = db.query(SystemSettings).first()
|
||||
if not s:
|
||||
s = SystemSettings(id=1)
|
||||
db.add(s)
|
||||
db.commit()
|
||||
db.refresh(s)
|
||||
return s
|
||||
|
||||
|
||||
@router.get("", response_model=SettingsResponse)
|
||||
def get_settings(db: Session = Depends(get_db)):
|
||||
return _get_or_create(db)
|
||||
|
||||
|
||||
@router.put("", response_model=SettingsResponse)
|
||||
def update_settings(payload: SettingsUpdate, db: Session = Depends(get_db)):
|
||||
s = _get_or_create(db)
|
||||
|
||||
# 啟用 SSO 前置條件檢查
|
||||
if payload.sso_enabled is True:
|
||||
manager = (
|
||||
db.query(Tenant)
|
||||
.filter(Tenant.is_manager == True, Tenant.is_active == True)
|
||||
.first()
|
||||
)
|
||||
if not manager:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="啟用 SSO 前必須先建立 is_manager=true 的管理租戶"
|
||||
)
|
||||
has_account = (
|
||||
db.query(Account)
|
||||
.filter(Account.tenant_id == manager.id, Account.is_active == True)
|
||||
.first()
|
||||
)
|
||||
if not has_account:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="管理租戶必須至少有一個有效帳號才能啟用 SSO"
|
||||
)
|
||||
|
||||
for field, value in payload.model_dump(exclude_none=True).items():
|
||||
setattr(s, field, value)
|
||||
db.commit()
|
||||
db.refresh(s)
|
||||
configure_timezone(s.timezone)
|
||||
return s
|
||||
|
||||
|
||||
@router.post("/test-keycloak")
|
||||
def test_keycloak(db: Session = Depends(get_db)):
|
||||
"""測試 Keycloak master realm 管理帳密是否正確"""
|
||||
s = _get_or_create(db)
|
||||
if not s.keycloak_url or not s.keycloak_admin_user or not s.keycloak_admin_pass:
|
||||
raise HTTPException(status_code=400, detail="請先設定 Keycloak URL 及管理帳密")
|
||||
|
||||
kc = KeycloakClient(s.keycloak_url, s.keycloak_admin_user, s.keycloak_admin_pass)
|
||||
try:
|
||||
token = kc._get_admin_token()
|
||||
if token:
|
||||
return {"ok": True, "message": f"連線成功:{s.keycloak_url}"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Keycloak 連線失敗:{str(e)}")
|
||||
|
||||
|
||||
@router.post("/init-sso-realm")
|
||||
def init_sso_realm(db: Session = Depends(get_db)):
|
||||
"""
|
||||
建立 Admin Portal SSO 環境:
|
||||
1. 以管理租戶的 keycloak_realm 為準(無管理租戶時 fallback 至 system settings)
|
||||
2. 確認該 realm 存在(不存在則建立)
|
||||
3. 在該 realm 建立 vmis-portal Public Client
|
||||
4. 同步回寫 system_settings.keycloak_realm(前端 JS Adapter 使用)
|
||||
"""
|
||||
s = _get_or_create(db)
|
||||
if not s.keycloak_url or not s.keycloak_admin_user or not s.keycloak_admin_pass:
|
||||
raise HTTPException(status_code=400, detail="請先設定並儲存 Keycloak 連線資訊")
|
||||
|
||||
# 以管理租戶的 keycloak_realm 為主要來源
|
||||
manager = (
|
||||
db.query(Tenant)
|
||||
.filter(Tenant.is_manager == True, Tenant.is_active == True)
|
||||
.first()
|
||||
)
|
||||
if manager and manager.keycloak_realm:
|
||||
realm = manager.keycloak_realm
|
||||
else:
|
||||
realm = s.keycloak_realm or "vmis-admin"
|
||||
|
||||
client_id = s.keycloak_client or "vmis-portal"
|
||||
|
||||
kc = KeycloakClient(s.keycloak_url, s.keycloak_admin_user, s.keycloak_admin_pass)
|
||||
results = []
|
||||
|
||||
# 確認/建立 realm
|
||||
if kc.realm_exists(realm):
|
||||
results.append(f"✓ Realm '{realm}' 已存在")
|
||||
else:
|
||||
ok = kc.create_realm(realm, manager.name if manager else "VMIS Admin Portal")
|
||||
if ok:
|
||||
results.append(f"✓ Realm '{realm}' 建立成功")
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail=f"Realm '{realm}' 建立失敗")
|
||||
|
||||
# 建立 vmis-portal Public Client
|
||||
status = kc.create_public_client(realm, client_id)
|
||||
if status == "exists":
|
||||
results.append(f"✓ Client '{client_id}' 已存在")
|
||||
elif status == "created":
|
||||
results.append(f"✓ Client '{client_id}' 建立成功")
|
||||
else:
|
||||
results.append(f"✗ Client '{client_id}' 建立失敗")
|
||||
|
||||
# 同步回寫 system_settings.keycloak_realm(前端 Keycloak JS Adapter 使用)
|
||||
if s.keycloak_realm != realm:
|
||||
s.keycloak_realm = realm
|
||||
db.commit()
|
||||
results.append(f"✓ 系統設定 keycloak_realm 同步為 '{realm}'")
|
||||
|
||||
return {"ok": True, "realm": realm, "details": results}
|
||||
Reference in New Issue
Block a user