Files
vmis/frontend/admin-portal/js/api.js
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

185 lines
6.6 KiB
JavaScript
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.
/* 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);
}