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,324 @@
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>系統設定 — VMIS Admin</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="css/style.css">
<link rel="icon" href="img/logo.png">
</head>
<body>
<!-- Sidebar -->
<nav id="sidebar">
<a class="brand" href="index.html"><img src="img/logo.png" alt="logo" style="height:28px;object-fit:contain;vertical-align:middle;" class="me-2">VMIS Admin</a>
<div class="pt-2">
<div class="nav-section">租戶服務</div>
<a class="nav-link" href="tenants.html"><i class="bi bi-building"></i> 租戶管理</a>
<a class="nav-link" href="accounts.html"><i class="bi bi-people"></i> 帳號管理</a>
<div class="nav-section">基礎設施</div>
<a class="nav-link" href="servers.html"><i class="bi bi-hdd-network"></i> 伺服器狀態</a>
<a class="nav-link" href="system-status.html"><i class="bi bi-activity"></i> 系統狀態</a>
<div class="nav-section">排程</div>
<a class="nav-link" href="schedules.html"><i class="bi bi-clock"></i> 排程管理</a>
<a class="nav-link" href="schedule-logs.html"><i class="bi bi-list-ul"></i> 執行紀錄</a>
<div class="nav-section">系統</div>
<a class="nav-link active" href="settings.html"><i class="bi bi-gear"></i> 系統設定</a>
</div>
</nav>
<!-- Main -->
<div id="main">
<div id="topbar">
<span class="fw-semibold text-secondary"><i class="bi bi-gear me-1"></i>系統設定</span>
<div class="ms-auto text-muted small" id="last-update"></div>
</div>
<div id="content">
<!-- 初始化提示 -->
<div id="init-alert" class="alert alert-warning d-flex align-items-center gap-2 mb-4" style="display:none!important">
<i class="bi bi-exclamation-triangle-fill fs-5"></i>
<div>
<strong>系統初始化提示:</strong>
請依序完成以下步驟以啟動 SSO 認證:<br>
<span class="badge bg-secondary me-1">1</span>設定 Keycloak 連線資訊並測試連線
<span class="badge bg-secondary mx-1">2</span>初始化 SSO Realm
<span class="badge bg-secondary mx-1">3</span>建立管理租戶 (is_manager=true) 及帳號
<span class="badge bg-secondary mx-1">4</span>啟用 SSO
</div>
</div>
<div class="row g-4">
<!-- 基本設定 -->
<div class="col-lg-6">
<div class="card shadow-sm">
<div class="card-header bg-white fw-semibold">
<i class="bi bi-sliders me-1 text-primary"></i>基本設定
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label fw-semibold">系統標題</label>
<input type="text" class="form-control" id="s-title" placeholder="VMIS Admin Portal">
<div class="form-text">瀏覽器 tab 顯示的標題文字</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">版本宣告</label>
<input type="text" class="form-control" id="s-version" placeholder="2.0.0">
<div class="form-text">顯示於頁面底部的系統版本號</div>
</div>
<div class="mb-0">
<label class="form-label fw-semibold">資料時區</label>
<select class="form-select" id="s-timezone">
<option value="Asia/Taipei">Asia/Taipei (UTC+8)</option>
<option value="Asia/Tokyo">Asia/Tokyo (UTC+9)</option>
<option value="Asia/Singapore">Asia/Singapore (UTC+8)</option>
<option value="Asia/Shanghai">Asia/Shanghai (UTC+8)</option>
<option value="UTC">UTC (UTC+0)</option>
<option value="America/New_York">America/New_York (UTC-5)</option>
<option value="Europe/London">Europe/London (UTC+0)</option>
</select>
<div class="form-text">資料庫紀錄儲存使用的時區,變更後需重啟後端才完全生效</div>
</div>
</div>
</div>
</div>
<!-- SSO 設定 -->
<div class="col-lg-6">
<div class="card shadow-sm">
<div class="card-header bg-white fw-semibold">
<i class="bi bi-shield-lock me-1 text-warning"></i>SSO 驗證設定
</div>
<div class="card-body">
<!-- SSO 啟用 -->
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="s-sso" role="switch">
<label class="form-check-label fw-semibold" for="s-sso">啟用 SSO 登入</label>
</div>
<div class="form-text mt-1">
啟用後,進入系統必須通過 Keycloak 登入驗證。<br>
<strong class="text-danger">前提:需有管理租戶 (is_manager=true) 及其帳號。</strong>
</div>
</div>
<hr>
<!-- Master Realm 管理帳號 -->
<p class="text-muted small mb-2"><i class="bi bi-database me-1"></i>Master Realm 管理帳號(後端操作租戶 realm 用)</p>
<div class="mb-3">
<label class="form-label fw-semibold">Keycloak URL</label>
<input type="text" class="form-control" id="s-kc-url" placeholder="https://auth.lab.taipei">
</div>
<div class="row g-2 mb-3">
<div class="col-6">
<label class="form-label fw-semibold">管理帳號</label>
<input type="text" class="form-control" id="s-kc-admin-user" placeholder="admin">
</div>
<div class="col-6">
<label class="form-label fw-semibold">管理密碼</label>
<div class="input-group">
<input type="password" class="form-control" id="s-kc-admin-pass" placeholder="••••••••">
<button class="btn btn-outline-secondary" type="button" onclick="togglePass('s-kc-admin-pass',this)">
<i class="bi bi-eye"></i>
</button>
</div>
</div>
</div>
<!-- 測試連線 -->
<div class="d-flex align-items-center gap-2 mb-3">
<button class="btn btn-outline-primary btn-sm" onclick="testKeycloak()">
<i class="bi bi-plug me-1"></i>測試連線
</button>
<span id="kc-test-result" class="small"></span>
</div>
<hr>
<!-- Admin Portal SSO -->
<p class="text-muted small mb-2"><i class="bi bi-browser-chrome me-1"></i>Admin Portal 前端登入設定</p>
<div class="mb-3">
<label class="form-label fw-semibold">前端 Client ID</label>
<input type="text" class="form-control" id="s-kc-client" placeholder="vmis-portal">
<div class="form-text">前端登入用的 Keycloak Public Client ID。SSO Realm 由<strong>管理租戶</strong>的 Keycloak Realm 決定,請先至<a href="tenants.html">租戶管理</a>設定 is_manager=true 的租戶。</div>
</div>
<!-- 初始化 SSO Realm -->
<div class="d-flex align-items-center gap-2">
<button class="btn btn-outline-warning btn-sm" onclick="initSsoRealm()">
<i class="bi bi-shield-plus me-1"></i>初始化 SSO Realm
</button>
<span id="kc-init-result" class="small"></span>
</div>
<div class="form-text mt-1">
在管理租戶的 Realm 中建立前端 Client ID依序儲存設定 → 測試連線 → 建立管理租戶 → 再執行)
</div>
</div>
</div>
</div>
<!-- 目前版本資訊(唯讀) -->
<div class="col-12">
<div class="card shadow-sm border-0 bg-light">
<div class="card-body d-flex align-items-center gap-4 py-3">
<div>
<small class="text-muted d-block">系統名稱</small>
<span class="fw-semibold" id="info-title"></span>
</div>
<div>
<small class="text-muted d-block">版本</small>
<span class="badge bg-primary" id="info-version"></span>
</div>
<div>
<small class="text-muted d-block">時區</small>
<span id="info-tz"></span>
</div>
<div>
<small class="text-muted d-block">SSO</small>
<span id="info-sso"></span>
</div>
<div>
<small class="text-muted d-block">Keycloak Realm</small>
<span id="info-realm" class="font-monospace small"></span>
</div>
<div>
<small class="text-muted d-block">管理租戶</small>
<span id="info-manager"></span>
</div>
<div>
<small class="text-muted d-block">最後更新</small>
<span id="info-updated"></span>
</div>
</div>
</div>
</div>
</div>
<div class="mt-4 d-flex gap-2">
<button class="btn btn-primary px-4" onclick="saveSettings()">
<i class="bi bi-check-lg me-1"></i>儲存設定
</button>
</div>
</div>
</div>
<div id="toast-container" class="position-fixed bottom-0 end-0 p-3" style="z-index:9999"></div>
<script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="js/api.js"></script>
<script>
function togglePass(id, btn) {
const el = document.getElementById(id);
const isPass = el.type === 'password';
el.type = isPass ? 'text' : 'password';
btn.innerHTML = isPass ? '<i class="bi bi-eye-slash"></i>' : '<i class="bi bi-eye"></i>';
}
async function loadSettings() {
const s = await apiFetch('/settings');
document.getElementById('s-title').value = s.site_title;
document.getElementById('s-version').value = s.version;
document.getElementById('s-timezone').value = s.timezone;
document.getElementById('s-sso').checked = s.sso_enabled;
document.getElementById('s-kc-url').value = s.keycloak_url;
document.getElementById('s-kc-admin-user').value = s.keycloak_admin_user;
document.getElementById('s-kc-admin-pass').value = s.keycloak_admin_pass;
document.getElementById('s-kc-client').value = s.keycloak_client;
// Info bar
document.getElementById('info-title').textContent = s.site_title;
document.getElementById('info-version').textContent = s.version;
document.getElementById('info-tz').textContent = s.timezone;
document.getElementById('info-sso').innerHTML = s.sso_enabled
? '<span class="badge bg-success">啟用</span>'
: '<span class="badge bg-secondary">停用</span>';
document.getElementById('info-realm').textContent = s.keycloak_realm || '—';
document.getElementById('info-updated').textContent = fmtDt(s.updated_at);
document.getElementById('last-update').textContent = '最後更新:' + fmtDt(s.updated_at);
if (s.site_title) document.title = `系統設定 — ${s.site_title}`;
// 載入管理租戶狀態
await loadManagerStatus();
}
async function loadManagerStatus() {
try {
const tenants = await apiFetch('/tenants');
const manager = tenants.find(t => t.is_manager && t.is_active);
const el = document.getElementById('info-manager');
if (manager) {
el.innerHTML = `<span class="badge bg-success">${manager.name}</span>`;
// 若沒有帳號也要提示
const accounts = await apiFetch(`/accounts?tenant_id=${manager.id}&is_active=true`);
if (accounts.length === 0) {
el.innerHTML += ' <span class="badge bg-warning text-dark">尚無帳號</span>';
} else {
el.innerHTML += ` <small class="text-muted">(${accounts.length} 帳號)</small>`;
}
} else {
el.innerHTML = '<span class="badge bg-danger">尚未建立</span>';
document.getElementById('init-alert').style.removeProperty('display');
}
} catch (e) {
document.getElementById('info-manager').textContent = '—';
}
}
async function saveSettings() {
const payload = {
site_title: document.getElementById('s-title').value.trim(),
version: document.getElementById('s-version').value.trim(),
timezone: document.getElementById('s-timezone').value,
sso_enabled: document.getElementById('s-sso').checked,
keycloak_url: document.getElementById('s-kc-url').value.trim(),
keycloak_admin_user: document.getElementById('s-kc-admin-user').value.trim(),
keycloak_admin_pass: document.getElementById('s-kc-admin-pass').value,
keycloak_client: document.getElementById('s-kc-client').value.trim(),
};
try {
await apiFetch('/settings', { method: 'PUT', body: JSON.stringify(payload) });
toast('設定已儲存');
loadSettings();
} catch (e) {
toast(e.message, 'error');
}
}
async function testKeycloak() {
const el = document.getElementById('kc-test-result');
el.innerHTML = '<span class="text-muted">測試中...</span>';
try {
const r = await apiFetch('/settings/test-keycloak', { method: 'POST' });
el.innerHTML = `<span class="text-success"><i class="bi bi-check-circle-fill me-1"></i>${r.message}</span>`;
} catch (e) {
el.innerHTML = `<span class="text-danger"><i class="bi bi-x-circle-fill me-1"></i>${e.message}</span>`;
}
}
async function initSsoRealm() {
const el = document.getElementById('kc-init-result');
el.innerHTML = '<span class="text-muted">初始化中...</span>';
try {
const r = await apiFetch('/settings/init-sso-realm', { method: 'POST' });
el.innerHTML = `<span class="text-success"><i class="bi bi-check-circle-fill me-1"></i>完成Realm: ${r.realm}</span>`;
toast('SSO Realm 初始化完成:' + r.details.join('、'));
loadSettings();
} catch (e) {
el.innerHTML = `<span class="text-danger"><i class="bi bi-x-circle-fill me-1"></i>${e.message}</span>`;
toast(e.message, 'error');
}
}
loadSettings();
</script>
</body>
</html>