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>
185 lines
6.6 KiB
JavaScript
185 lines
6.6 KiB
JavaScript
/* VMIS Admin Portal - API utilities */
|
||
|
||
const API = 'http://localhost:10281/api/v1';
|
||
|
||
/* ── Auth state ── */
|
||
let _sysSettings = null;
|
||
let _kcInstance = null;
|
||
let _accessToken = null;
|
||
|
||
/* ── Keycloak SSO guard ── */
|
||
async function _loadKcScript(src) {
|
||
return new Promise((resolve) => {
|
||
const s = document.createElement('script');
|
||
s.src = src;
|
||
s.onload = () => resolve(true);
|
||
s.onerror = () => resolve(false);
|
||
document.head.appendChild(s);
|
||
});
|
||
}
|
||
|
||
async function _initKcAuth(settings) {
|
||
// 嘗試路徑順序:新版(Quarkus) → 舊版(WildFly /auth) → CDN
|
||
const candidates = [
|
||
`${settings.keycloak_url}/js/keycloak.js`,
|
||
`${settings.keycloak_url}/auth/js/keycloak.js`,
|
||
'https://cdn.jsdelivr.net/npm/keycloak-js/dist/keycloak.min.js',
|
||
];
|
||
let loaded = false;
|
||
for (const src of candidates) {
|
||
loaded = await _loadKcScript(src);
|
||
if (loaded) { console.info('KC JS 載入成功:', src); break; }
|
||
}
|
||
if (!loaded) { console.warn('KC JS 全部來源載入失敗,跳過 SSO 守衛'); return; }
|
||
|
||
try {
|
||
const kc = new Keycloak({
|
||
url: settings.keycloak_url,
|
||
realm: settings.keycloak_realm,
|
||
clientId: settings.keycloak_client || 'vmis-portal',
|
||
});
|
||
const authenticated = await kc.init({
|
||
onLoad: 'login-required',
|
||
pkceMethod: 'S256',
|
||
checkLoginIframe: false,
|
||
});
|
||
if (authenticated) {
|
||
_kcInstance = kc;
|
||
_accessToken = kc.token;
|
||
setInterval(() => {
|
||
kc.updateToken(60)
|
||
.then(refreshed => { if (refreshed) _accessToken = kc.token; })
|
||
.catch(() => kc.login());
|
||
}, 30000);
|
||
// 在 topbar 插入使用者資訊與登出按鈕
|
||
const topbar = document.getElementById('topbar');
|
||
if (topbar) {
|
||
const username = kc.tokenParsed?.preferred_username || kc.tokenParsed?.name || '';
|
||
const btn = document.createElement('div');
|
||
btn.className = 'ms-3 d-flex align-items-center gap-2';
|
||
btn.innerHTML = `
|
||
<span class="text-muted small"><i class="bi bi-person-circle me-1"></i>${username}</span>
|
||
<button class="btn btn-outline-secondary btn-sm" onclick="_kcInstance.logout()">
|
||
<i class="bi bi-box-arrow-right me-1"></i>登出
|
||
</button>`;
|
||
topbar.appendChild(btn);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error('KC 初始化失敗:', e);
|
||
}
|
||
}
|
||
|
||
/* ── Global settings (loaded on every page) ── */
|
||
async function loadSysSettings() {
|
||
try {
|
||
_sysSettings = await apiFetch('/settings');
|
||
// Apply site title: prepend page name if title contains " — "
|
||
if (_sysSettings.site_title) {
|
||
const cur = document.title;
|
||
const sep = cur.indexOf(' — ');
|
||
const pageName = sep >= 0 ? cur.substring(0, sep) : cur;
|
||
document.title = `${pageName} — ${_sysSettings.site_title}`;
|
||
}
|
||
// Inject version badge into topbar if element exists
|
||
const topbar = document.getElementById('topbar');
|
||
if (topbar && _sysSettings.version) {
|
||
const badge = document.createElement('span');
|
||
badge.className = 'badge bg-secondary-subtle text-secondary ms-2';
|
||
badge.textContent = `v${_sysSettings.version}`;
|
||
topbar.querySelector('.fw-semibold')?.after(badge);
|
||
}
|
||
// SSO guard:若已啟用,觸發 Keycloak 認證
|
||
if (_sysSettings.sso_enabled && _sysSettings.keycloak_url && _sysSettings.keycloak_realm) {
|
||
await _initKcAuth(_sysSettings);
|
||
}
|
||
} catch (e) {
|
||
// Silently ignore if settings not yet available
|
||
}
|
||
}
|
||
|
||
// Auto-load on every page
|
||
document.addEventListener('DOMContentLoaded', loadSysSettings);
|
||
|
||
async function apiFetch(path, options = {}) {
|
||
const headers = { 'Content-Type': 'application/json', ...options.headers };
|
||
if (_accessToken) headers['Authorization'] = `Bearer ${_accessToken}`;
|
||
const resp = await fetch(API + path, { headers, ...options });
|
||
if (!resp.ok) {
|
||
// Token 過期 → 重新登入
|
||
if (resp.status === 401 && _kcInstance) { _kcInstance.login(); return; }
|
||
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
|
||
throw Object.assign(new Error(err.detail || resp.statusText), { status: resp.status });
|
||
}
|
||
if (resp.status === 204) return null;
|
||
return resp.json();
|
||
}
|
||
|
||
/* ── light helper ── */
|
||
function lightHtml(val) {
|
||
if (val === null || val === undefined) return '<span class="light light-grey" title="無紀錄"></span>';
|
||
return val
|
||
? '<span class="light light-green" title="正常"></span>'
|
||
: '<span class="light light-red" title="異常"></span>';
|
||
}
|
||
|
||
/* ── availability color ── */
|
||
function availClass(pct) {
|
||
if (pct === null || pct === undefined) return '';
|
||
if (pct >= 99) return '';
|
||
if (pct >= 95) return 'warn';
|
||
return 'danger';
|
||
}
|
||
|
||
function availBar(pct) {
|
||
if (pct === null || pct === undefined) return '<span class="text-muted">—</span>';
|
||
const cls = availClass(pct);
|
||
return `
|
||
<div class="d-flex align-items-center gap-2">
|
||
<div class="avail-bar flex-grow-1">
|
||
<div class="avail-fill ${cls}" style="width:${pct}%"></div>
|
||
</div>
|
||
<small class="${cls === 'danger' ? 'text-danger' : cls === 'warn' ? 'text-warning' : 'text-success'}">${pct}%</small>
|
||
</div>`;
|
||
}
|
||
|
||
/* ── toast ── */
|
||
function toast(msg, type = 'success') {
|
||
const container = document.getElementById('toast-container');
|
||
if (!container) return;
|
||
const id = 'toast-' + Date.now();
|
||
const icon = type === 'success' ? '✓' : '✗';
|
||
const bg = type === 'success' ? 'text-bg-success' : 'text-bg-danger';
|
||
container.insertAdjacentHTML('beforeend', `
|
||
<div id="${id}" class="toast align-items-center ${bg} border-0 show" role="alert">
|
||
<div class="d-flex">
|
||
<div class="toast-body">${icon} ${msg}</div>
|
||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
||
</div>
|
||
</div>`);
|
||
setTimeout(() => document.getElementById(id)?.remove(), 3500);
|
||
}
|
||
|
||
/* ── confirm modal ── */
|
||
function confirm(msg) {
|
||
return new Promise(resolve => {
|
||
document.getElementById('confirm-body').textContent = msg;
|
||
const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('confirmModal'));
|
||
document.getElementById('confirm-ok').onclick = () => { modal.hide(); resolve(true); };
|
||
document.getElementById('confirm-cancel').onclick = () => { modal.hide(); resolve(false); };
|
||
modal.show();
|
||
});
|
||
}
|
||
|
||
/* ── format datetime ── */
|
||
// 後端已依設定時區儲存,直接顯示資料庫原始時間,不做轉換
|
||
function fmtDt(iso) {
|
||
if (!iso) return '—';
|
||
return iso.replace('T', ' ').substring(0, 19);
|
||
}
|
||
|
||
function fmtDate(iso) {
|
||
if (!iso) return '—';
|
||
return iso.substring(0, 10);
|
||
}
|