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:
284
frontend/admin-portal/accounts.html
Normal file
284
frontend/admin-portal/accounts.html
Normal file
@@ -0,0 +1,284 @@
|
||||
<!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="https://cdn.datatables.net/2.0.3/css/dataTables.bootstrap5.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 active" 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" 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-people me-1"></i>帳號管理</span>
|
||||
<div class="ms-auto d-flex gap-2 align-items-center">
|
||||
<select class="form-select form-select-sm" id="tenant-filter" style="width:180px" onchange="loadTable()">
|
||||
<option value="">全部租戶</option>
|
||||
</select>
|
||||
<button class="btn btn-primary btn-sm" onclick="openCreate()">
|
||||
<i class="bi bi-plus-lg me-1"></i>新增帳號
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="content">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
<table id="tbl" class="table table-hover mb-0 w-100">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>帳號編碼</th>
|
||||
<th>租戶</th>
|
||||
<th>SSO 帳號</th>
|
||||
<th>系統郵件</th>
|
||||
<th>法定姓名</th>
|
||||
<th class="text-center">啟用</th>
|
||||
<th class="text-center">SSO</th>
|
||||
<th class="text-center">郵箱</th>
|
||||
<th class="text-center">Drive</th>
|
||||
<th class="text-center">Mail</th>
|
||||
<th class="text-center">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Modal -->
|
||||
<div class="modal fade" id="formModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="formTitle">新增帳號</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="f-id">
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label">租戶 <span class="text-danger">*</span></label>
|
||||
<select class="form-select" id="f-tenant"></select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">SSO 帳號 <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="f-sso" placeholder="alice">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">通知郵件 <span class="text-danger">*</span></label>
|
||||
<input type="email" class="form-control" id="f-notify-email" placeholder="alice@gmail.com">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">法定姓名</label>
|
||||
<input type="text" class="form-control" id="f-legal-name">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">英文姓名</label>
|
||||
<input type="text" class="form-control" id="f-eng-name">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">配額 (GB)</label>
|
||||
<input type="number" class="form-control" id="f-quota" value="20" min="1">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">預設密碼</label>
|
||||
<input type="text" class="form-control" id="f-password" placeholder="留空自動產生">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="f-active" checked>
|
||||
<label class="form-check-label" for="f-active">啟用</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveForm()">儲存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirm Modal -->
|
||||
<div class="modal fade" id="confirmModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header"><h6 class="modal-title">確認</h6></div>
|
||||
<div class="modal-body" id="confirm-body"></div>
|
||||
<div class="modal-footer">
|
||||
<button id="confirm-cancel" class="btn btn-secondary btn-sm">取消</button>
|
||||
<button id="confirm-ok" class="btn btn-danger btn-sm">確定</button>
|
||||
</div>
|
||||
</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="https://cdn.datatables.net/2.0.3/js/dataTables.min.js"></script>
|
||||
<script src="https://cdn.datatables.net/2.0.3/js/dataTables.bootstrap5.min.js"></script>
|
||||
<script src="js/api.js"></script>
|
||||
<script>
|
||||
let dt;
|
||||
let tenants = [];
|
||||
|
||||
async function init() {
|
||||
try { tenants = await apiFetch('/tenants'); } catch (e) { toast('無法載入租戶列表:' + e.message, 'error'); }
|
||||
const sel = document.getElementById('tenant-filter');
|
||||
const fSel = document.getElementById('f-tenant');
|
||||
tenants.forEach(t => {
|
||||
sel.insertAdjacentHTML('beforeend', `<option value="${t.id}">${t.name} (${t.code})</option>`);
|
||||
fSel.insertAdjacentHTML('beforeend', `<option value="${t.id}">${t.name} (${t.code})</option>`);
|
||||
});
|
||||
loadTable();
|
||||
}
|
||||
|
||||
async function loadTable() {
|
||||
const tid = document.getElementById('tenant-filter').value;
|
||||
const qs = tid ? `?tenant_id=${tid}` : '';
|
||||
let rows;
|
||||
try { rows = await apiFetch(`/accounts${qs}`); } catch (e) { toast('無法載入帳號資料:' + e.message, 'error'); return; }
|
||||
const data = rows.map(a => {
|
||||
const lights = a.lights || {};
|
||||
return [
|
||||
`<strong>${a.account_code}</strong>`,
|
||||
`<small>${a.tenant_name || ''}</small>`,
|
||||
a.sso_account,
|
||||
`<small class="text-muted">${a.email || '—'}</small>`,
|
||||
a.legal_name || '—',
|
||||
a.is_active
|
||||
? '<span class="badge bg-success-subtle text-success">啟用</span>'
|
||||
: '<span class="badge bg-secondary-subtle text-secondary">停用</span>',
|
||||
lightHtml(lights.sso_result),
|
||||
lightHtml(lights.mailbox_result),
|
||||
lightHtml(lights.nc_result),
|
||||
lightHtml(lights.nc_mail_result),
|
||||
`<button class="btn btn-outline-primary btn-sm me-1" onclick='editRow(${JSON.stringify(a)})'><i class="bi bi-pencil"></i></button>
|
||||
<button class="btn btn-outline-danger btn-sm" onclick="deleteRow(${a.id},'${a.sso_account}')"><i class="bi bi-trash"></i></button>`,
|
||||
];
|
||||
});
|
||||
|
||||
if (dt) {
|
||||
dt.clear().rows.add(data).draw();
|
||||
} else {
|
||||
dt = $('#tbl').DataTable({
|
||||
data,
|
||||
columns: [
|
||||
{}, {}, {}, {}, {},
|
||||
{ className: 'text-center' },
|
||||
{ className: 'text-center', orderable: false },
|
||||
{ className: 'text-center', orderable: false },
|
||||
{ className: 'text-center', orderable: false },
|
||||
{ className: 'text-center', orderable: false },
|
||||
{ className: 'text-center', orderable: false },
|
||||
],
|
||||
language: { url: 'https://cdn.datatables.net/plug-ins/2.0.3/i18n/zh-HANT.json' },
|
||||
pageLength: 25,
|
||||
order: [[0, 'asc']],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function clearForm() {
|
||||
['f-id','f-sso','f-notify-email','f-legal-name','f-eng-name','f-password'].forEach(id => {
|
||||
document.getElementById(id).value = '';
|
||||
});
|
||||
document.getElementById('f-quota').value = 20;
|
||||
document.getElementById('f-active').checked = true;
|
||||
if (tenants.length) document.getElementById('f-tenant').value = tenants[0].id;
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
clearForm();
|
||||
document.getElementById('formTitle').textContent = '新增帳號';
|
||||
document.getElementById('f-tenant').disabled = false;
|
||||
new bootstrap.Modal(document.getElementById('formModal')).show();
|
||||
}
|
||||
|
||||
function editRow(a) {
|
||||
clearForm();
|
||||
document.getElementById('formTitle').textContent = '編輯帳號';
|
||||
document.getElementById('f-id').value = a.id;
|
||||
document.getElementById('f-tenant').value = a.tenant_id;
|
||||
document.getElementById('f-tenant').disabled = true;
|
||||
document.getElementById('f-sso').value = a.sso_account;
|
||||
document.getElementById('f-notify-email').value = a.notification_email || '';
|
||||
document.getElementById('f-legal-name').value = a.legal_name || '';
|
||||
document.getElementById('f-eng-name').value = a.english_name || '';
|
||||
document.getElementById('f-quota').value = a.quota_limit;
|
||||
document.getElementById('f-active').checked = a.is_active;
|
||||
new bootstrap.Modal(document.getElementById('formModal')).show();
|
||||
}
|
||||
|
||||
async function saveForm() {
|
||||
const id = document.getElementById('f-id').value;
|
||||
const payload = {
|
||||
tenant_id: parseInt(document.getElementById('f-tenant').value),
|
||||
sso_account: document.getElementById('f-sso').value.trim(),
|
||||
notification_email: document.getElementById('f-notify-email').value.trim(),
|
||||
legal_name: document.getElementById('f-legal-name').value.trim() || null,
|
||||
english_name: document.getElementById('f-eng-name').value.trim() || null,
|
||||
quota_limit: parseInt(document.getElementById('f-quota').value),
|
||||
default_password: document.getElementById('f-password').value.trim() || null,
|
||||
is_active: document.getElementById('f-active').checked,
|
||||
};
|
||||
try {
|
||||
if (id) {
|
||||
const { tenant_id, sso_account, ...updatePayload } = payload;
|
||||
await apiFetch(`/accounts/${id}`, { method: 'PUT', body: JSON.stringify(updatePayload) });
|
||||
toast('帳號已更新');
|
||||
} else {
|
||||
await apiFetch('/accounts', { method: 'POST', body: JSON.stringify(payload) });
|
||||
toast('帳號已新增');
|
||||
}
|
||||
bootstrap.Modal.getInstance(document.getElementById('formModal')).hide();
|
||||
loadTable();
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRow(id, name) {
|
||||
const ok = await confirm(`確定要刪除帳號「${name}」?`);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiFetch(`/accounts/${id}`, { method: 'DELETE' });
|
||||
toast('帳號已刪除');
|
||||
loadTable();
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user