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:
VMIS Developer
2026-03-15 15:31:37 +08:00
parent 42d1420f9c
commit 62baadb06f
53 changed files with 5638 additions and 195 deletions

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