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:
184
frontend/admin-portal/js/api.js
Normal file
184
frontend/admin-portal/js/api.js
Normal file
@@ -0,0 +1,184 @@
|
||||
/* 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);
|
||||
}
|
||||
Reference in New Issue
Block a user