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>
325 lines
14 KiB
HTML
325 lines
14 KiB
HTML
<!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>
|