Files
vmis/backend/app/api/v1/settings.py
VMIS Developer 62baadb06f 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>
2026-03-15 15:31:37 +08:00

136 lines
4.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}