diff --git a/cypher-executor/src/routes/portal-ui.ts b/cypher-executor/src/routes/portal-ui.ts index a6f8687..654f1d5 100644 --- a/cypher-executor/src/routes/portal-ui.ts +++ b/cypher-executor/src/routes/portal-ui.ts @@ -12,6 +12,9 @@ * - 設定頁:改自己密碼、看自己角色與可查庫、主題切換 * - 工作流頁:D-8 定案 admin 可見(PORTAL_SHOW_WORKFLOWS 可調 all/off);唯讀、不開 trigger * - graph 模式按 D-4 粗閘顯示(/portal/session 的 graph_allowed) + * - 管理頁(P4,admin-only nav):帳號管理(列表/新增/停用/重設密碼+每帳號勾選可查庫 + * 含 ["*"] 全庫)+庫目錄管理(登記/停用/graph_source 標記)。一次性密碼只在畫面顯示 + * 一次(server 不留明碼);一般用戶 nav 不顯示、直打 /portal/admin/* 也是 403(role 閘)。 * - Mira 專屬頁(駕駛艙/分流台/憑證/專案)一律不進 * * 安全(design §3.3 關鍵差異 vs console): @@ -161,6 +164,21 @@ function renderPortalHtml(brand: string): string { .gcenter span { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 13.5px; font-weight: 600; line-height: 1.35; color: #241804; word-break: break-word; } .nbrow { display: flex; align-items: center; gap: 11px; padding: 12px 14px; border-radius: 10px; cursor: pointer; background: rgba(var(--ink-rgb),.045); border: 1px solid rgba(var(--ink-rgb),.1); font-size: 15.5px; } + /* ── 管理頁(P4)── */ + .sechead { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 19px; letter-spacing: .14em; color: var(--amber); margin: 26px 0 12px; padding-bottom: 8px; border-bottom: 1px solid rgba(var(--amber-rgb),.3); } + .formrow { display: flex; gap: 10px; flex-wrap: wrap; } + .formrow .txt { flex: 1; min-width: 180px; } + .formrow select { padding: 12px 14px; font-size: 15.5px; border-radius: 10px; border: 1px solid rgba(var(--ink-rgb),.2); background: rgba(var(--ink-rgb),.05); color: var(--ink); } + .btnrow { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; } + .btn2 { padding: 9px 14px; font-size: 14px; border-radius: 9px; border: 1px solid rgba(var(--amber-rgb),.45); background: rgba(var(--amber-rgb),.08); color: var(--amber); cursor: pointer; } + .btn2.warn { border-color: rgba(var(--err-rgb),.45); background: rgba(var(--err-rgb),.06); color: var(--err); } + .btn2:disabled { opacity: .45; cursor: default; } + .libgrid { display: flex; gap: 8px 14px; flex-wrap: wrap; margin-top: 10px; } + .libgrid label { display: flex; align-items: center; gap: 7px; font-size: 14.5px; cursor: pointer; padding: 6px 10px; border-radius: 8px; background: rgba(var(--ink-rgb),.05); border: 1px solid rgba(var(--ink-rgb),.12); } + .libgrid input[type=checkbox] { accent-color: var(--amber); width: 16px; height: 16px; } + .otpbox { margin-top: 14px; padding: 18px; border-radius: 13px; border: 2px solid rgba(var(--amber-rgb),.55); background: rgba(var(--amber-rgb),.08); } + .otpbox .pw { font-family: ui-monospace, Menlo, monospace; font-size: 22px; letter-spacing: .12em; color: var(--amber); word-break: break-all; user-select: all; margin: 10px 0; } + /* ── 清單 / 設定 ── */ .listcol { display: flex; flex-direction: column; gap: 10px; padding: 16px 0 8px; } .card-item { border-radius: 13px; background: rgba(var(--ink-rgb),.045); border: 1px solid rgba(var(--ink-rgb),.1); padding: 16px 18px; } @@ -198,6 +216,7 @@ function renderPortalHtml(brand: string): string { +
@@ -277,6 +296,42 @@ function renderPortalHtml(brand: string): string { + +
+
管理帳號與知識庫授權
+ +
帳號管理
+
+
新增同仁帳號
+
建立後會產生一組一次性密碼——只顯示這一次,請當場轉交同仁(同仁可在「設定」自行改密)。
+
+ + + + +
+
+
+
+
載入中…
+ +
庫目錄管理
+
+ 這裡是「庫」的登記簿——條目歸哪個庫由資料導入(ingest)時蓋章決定;未蓋章的舊資料一律視同 general。標了「圖譜來源」的庫決定誰能用圖譜模式(全都沒標=預設 general)。 +
+
+
登記新庫
+
+ + + + +
+
+
+
載入中…
+
+ @@ -284,6 +339,7 @@ function renderPortalHtml(brand: string): string {
搜尋
工作流
+
管理
設定
@@ -412,11 +468,16 @@ function renderPortalHtml(brand: string): string { } // ── 路由 ── - var VIEWS = ['search', 'card', 'workflows', 'settings']; + var VIEWS = ['search', 'card', 'workflows', 'admin', 'settings']; var HOME = 'search'; function allowedViews() { - // 工作流頁按 session 能力顯示(真閘在 server:/portal/data/workflows 403/404) - return (S.profile && S.profile.workflows_visible) ? VIEWS : VIEWS.filter(function (v) { return v !== 'workflows'; }); + // 工作流/管理頁按 session 能力顯示(真閘在 server:/portal/data/workflows 403/404、 + // /portal/admin/* role 閘 403——前端藏只是 UX,curl 直打也繞不過) + return VIEWS.filter(function (v) { + if (v === 'workflows') return !!(S.profile && S.profile.workflows_visible); + if (v === 'admin') return !!(S.profile && S.profile.role === 'admin'); + return true; + }); } function currentRoute() { var h = location.hash.replace(/^#\\/?/, ''); @@ -435,7 +496,7 @@ function renderPortalHtml(brand: string): string { }); window.scrollTo(0, 0); } - var LOADERS = { card: loadCard, workflows: loadWorkflows, settings: loadSettings }; + var LOADERS = { card: loadCard, workflows: loadWorkflows, admin: loadAdmin, settings: loadSettings }; function route() { var r = currentRoute(); if (r.raw && r.raw !== r.view) { location.hash = '#/' + r.view; return; } @@ -463,6 +524,8 @@ function renderPortalHtml(brand: string): string { $('side-foot').innerHTML = esc(p.display_name || '') + '
' + esc(location.host); $('nav-workflows').classList.toggle('hide', !p.workflows_visible); $('tab-workflows').classList.toggle('hide', !p.workflows_visible); + $('nav-admin').classList.toggle('hide', p.role !== 'admin'); + $('tab-admin').classList.toggle('hide', p.role !== 'admin'); $('mode-graph').classList.toggle('hide', !p.graph_allowed); var libs = p.libraries || []; $('se-scope').textContent = libs.indexOf('*') >= 0 ? '範圍:全部知識庫' : ('範圍:' + libs.join('・')); @@ -686,6 +749,269 @@ function renderPortalHtml(brand: string): string { .catch(function (e) { $('wf-list').innerHTML = '
請求失敗:' + esc(friendlyErr(e)) + '
'; }); } + // ── 管理頁(P4:帳號管理+庫目錄管理。全走 /portal/admin/*,前端零業務邏輯; + // 一般用戶 nav 不顯示,且 server role 閘 403——藏只是 UX,真閘在後端)── + var adminLibs = []; // 庫目錄快取(渲染每帳號的庫勾選用) + + function adminApi(method, path, body) { + var opt = { method: method, headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()) }; + if (body !== undefined) opt.body = JSON.stringify(body); + return fetch(path, opt).then(function (r) { + return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); + }); + } + + function loadAdmin() { + $('ad-users').innerHTML = '
載入中…
'; + $('ad-libs').innerHTML = '
載入中…
'; + adminApi('GET', '/portal/admin/libraries') + .then(function (x) { + if (guard401(x.status)) return; + if (!x.ok) { $('ad-libs').innerHTML = '
' + esc(x.d.error || ('讀取失敗(HTTP ' + x.status + ')')) + '
'; return; } + adminLibs = x.d.libraries || []; + renderAdminLibs(); + return adminApi('GET', '/portal/admin/users').then(function (y) { + if (guard401(y.status)) return; + if (!y.ok) { $('ad-users').innerHTML = '
' + esc(y.d.error || ('讀取失敗(HTTP ' + y.status + ')')) + '
'; return; } + renderAdminUsers(y.d.users || []); + }); + }) + .catch(function (e) { + $('ad-users').innerHTML = '
請求失敗:' + esc(friendlyErr(e)) + '
'; + $('ad-libs').innerHTML = ''; + }); + } + + // 帳號可勾選的庫=庫目錄啟用中的庫 ∪ general ∪ 該帳號已有的庫(目錄外的舊授權不憑空消失) + function checkableLibs(userLibs) { + var names = ['general']; + adminLibs.forEach(function (l) { if (l.status !== 'disabled' && l.name && names.indexOf(l.name) < 0) names.push(l.name); }); + (userLibs || []).forEach(function (n) { if (n !== '*' && names.indexOf(n) < 0) names.push(n); }); + return names; + } + function libDisplay(name) { + for (var i = 0; i < adminLibs.length; i++) { if (adminLibs[i].name === name) return adminLibs[i].display_name || name; } + return name; + } + + function renderAdminUsers(users) { + if (!users.length) { $('ad-users').innerHTML = '
還沒有任何同仁帳號——用上面的表單新增第一個。
'; return; } + $('ad-users').innerHTML = users.map(function (u) { + var star = (u.libraries || []).indexOf('*') >= 0; + var disabled = u.status === 'disabled'; + var libChips = star + ? '全部知識庫' + : ((u.libraries || []).length + ? (u.libraries || []).map(function (n) { return '' + esc(libDisplay(n)) + ''; }).join(' ') + : '尚未授權任何庫'); + var boxes = '' + + checkableLibs(u.libraries).map(function (n) { + var on = !star && (u.libraries || []).indexOf(n) >= 0; + return ''; + }).join(''); + return '
' + + '
' + + '' + esc(u.display_name || u.email) + '' + + '' + esc(u.email) + '' + + '' + (u.role === 'admin' ? '管理員' : '一般用戶') + '' + + '' + (disabled ? '已停用' : '啟用中') + '
' + + '
可查庫' + libChips + '
' + + '
' + + '' + + '' + + (disabled + ? '' + : '') + + '
' + + '' + + '
'; + }).join(''); + } + + function renderAdminLibs() { + if (!adminLibs.length) { $('ad-libs').innerHTML = '
還沒有登記任何庫。未登記時整個系統視同單一 general 庫。
'; return; } + $('ad-libs').innerHTML = adminLibs.map(function (l) { + var disabled = l.status === 'disabled'; + return '
' + + '
' + + '' + esc(l.display_name || l.name) + '' + + '' + esc(l.name) + '' + + '' + (disabled ? '已停用' : '啟用中') + '' + + (l.graph_source ? '圖譜來源' : '') + '
' + + (l.description ? '
' + esc(l.description) + '
' : '') + + '
' + + '' + + (disabled + ? '' + : '') + + '
'; + }).join(''); + } + + // 一次性密碼顯示(server 不留明碼——關掉就真的沒了,誠實跟 admin 說清楚) + function showOtp(email, password) { + $('ad-otp').innerHTML = '
' + + '
' + esc(email) + ' 的一次性密碼
' + + '
' + esc(password) + '
' + + '
只顯示這一次——關閉後再也調不出來(系統只存雜湊)。請當場轉交同仁,並提醒登入後到「設定」改成自己的密碼。
' + + '
'; + $('ad-otp').scrollIntoView({ block: 'nearest' }); + } + + // 新增帳號 + $('ad-nu-create').addEventListener('click', function () { + var email = $('ad-nu-email').value.trim(); + var name = $('ad-nu-name').value.trim(); + var role = $('ad-nu-role').value === 'admin' ? 'admin' : 'user'; + var st = $('ad-nu-status'); + if (!email) { st.textContent = '請填 Email'; return; } + st.textContent = ''; + $('ad-nu-create').disabled = true; + var body = { email: email, role: role }; + if (name) body.display_name = name; + adminApi('POST', '/portal/admin/users', body) + .then(function (x) { + $('ad-nu-create').disabled = false; + if (guard401(x.status)) return; + if (!x.ok) { st.textContent = x.d.error || ('建立失敗(HTTP ' + x.status + ')'); return; } + $('ad-nu-email').value = ''; $('ad-nu-name').value = ''; $('ad-nu-role').value = 'user'; + if (x.d.generated_password) showOtp(email, x.d.generated_password); + toast('帳號已建立'); + loadAdmin(); + }) + .catch(function (e) { $('ad-nu-create').disabled = false; st.textContent = friendlyErr(e); }); + }); + + // 登記新庫 + $('ad-nl-create').addEventListener('click', function () { + var name = $('ad-nl-name').value.trim(); + var st = $('ad-nl-status'); + if (!name) { st.textContent = '請填庫名(英數 _ -)'; return; } + st.textContent = ''; + $('ad-nl-create').disabled = true; + adminApi('POST', '/portal/admin/libraries', { + name: name, + display_name: $('ad-nl-display').value.trim(), + description: $('ad-nl-desc').value.trim() + }) + .then(function (x) { + $('ad-nl-create').disabled = false; + if (guard401(x.status)) return; + if (!x.ok) { st.textContent = x.d.error || ('登記失敗(HTTP ' + x.status + ')'); return; } + $('ad-nl-name').value = ''; $('ad-nl-display').value = ''; $('ad-nl-desc').value = ''; + toast('庫已登記'); + loadAdmin(); + }) + .catch(function (e) { $('ad-nl-create').disabled = false; st.textContent = friendlyErr(e); }); + }); + + // 「全部知識庫」勾選 → 其餘庫勾選框連動停用(存的就是 ["*"],個別勾選無意義) + document.addEventListener('change', function (ev) { + var t = ev.target; + if (!t || !t.hasAttribute || !t.hasAttribute('data-star')) return; + var uid = t.getAttribute('data-uid'); + document.querySelectorAll('#libed-' + uid + ' input[data-lib]').forEach(function (cb) { + cb.disabled = t.checked; + if (t.checked) cb.checked = false; + }); + }); + + // 管理頁動作(事件委派) + $('v-admin').addEventListener('click', function (ev) { + var t = ev.target.closest('[data-act]'); + if (!t) return; + var act = t.getAttribute('data-act'); + var uid = t.getAttribute('data-uid'); + + if (act === 'edit-libs') { + var ed = $('libed-' + uid); + ed.style.display = ed.style.display === 'none' ? '' : 'none'; + return; + } + if (act === 'save-libs') { + var star = document.querySelector('#libed-' + uid + ' input[data-star]'); + var libs; + if (star && star.checked) { libs = ['*']; } + else { + libs = []; + document.querySelectorAll('#libed-' + uid + ' input[data-lib]').forEach(function (cb) { + if (cb.checked) libs.push(cb.getAttribute('data-lib')); + }); + if (!libs.length) { toast('至少勾一個庫,或勾「全部知識庫」'); return; } + } + t.disabled = true; + adminApi('PATCH', '/portal/admin/users/' + encodeURIComponent(uid), { libraries: libs }) + .then(function (x) { + t.disabled = false; + if (guard401(x.status)) return; + if (!x.ok) { toast(x.d.error || ('儲存失敗(HTTP ' + x.status + ')')); return; } + toast('庫權限已更新,立即生效'); + loadAdmin(); + }) + .catch(function (e) { t.disabled = false; toast(friendlyErr(e)); }); + return; + } + if (act === 'set-status') { + var next = t.getAttribute('data-next'); + if (next === 'disabled' && !confirm('確定停用 ' + (t.getAttribute('data-email') || '這個帳號') + '?停用後其登入與既有 session 立即失效。')) return; + t.disabled = true; + adminApi('PATCH', '/portal/admin/users/' + encodeURIComponent(uid), { status: next }) + .then(function (x) { + t.disabled = false; + if (guard401(x.status)) return; + if (!x.ok) { toast(x.d.error || ('更新失敗(HTTP ' + x.status + ')')); return; } // 含最後一個 admin 的 409 訊息 + toast(next === 'disabled' ? '已停用' : '已啟用'); + loadAdmin(); + }) + .catch(function (e) { t.disabled = false; toast(friendlyErr(e)); }); + return; + } + if (act === 'reset-pw') { + var email = t.getAttribute('data-email') || ''; + if (!confirm('確定重設 ' + email + ' 的密碼?舊密碼將立即失效,會產生一組新的一次性密碼。')) return; + t.disabled = true; + adminApi('POST', '/portal/admin/users/' + encodeURIComponent(uid) + '/reset-password') + .then(function (x) { + t.disabled = false; + if (guard401(x.status)) return; + if (!x.ok) { toast(x.d.error || ('重設失敗(HTTP ' + x.status + ')')); return; } + showOtp(email, x.d.password); + toast('密碼已重設'); + }) + .catch(function (e) { t.disabled = false; toast(friendlyErr(e)); }); + return; + } + if (act === 'lib-status' || act === 'lib-graph') { + var lid = t.getAttribute('data-lid'); + var body2 = {}; + if (act === 'lib-status') { + var nextS = t.getAttribute('data-next'); + if (nextS === 'disabled' && !confirm('確定停用庫「' + (t.getAttribute('data-name') || '') + '」?停用後不再出現在庫勾選清單(既有授權不會被自動改)。')) return; + body2.status = nextS; + } else { + body2.graph_source = t.getAttribute('data-next') === 'true'; + } + t.disabled = true; + adminApi('PATCH', '/portal/admin/libraries/' + encodeURIComponent(lid), body2) + .then(function (x) { + t.disabled = false; + if (guard401(x.status)) return; + if (!x.ok) { toast(x.d.error || ('更新失敗(HTTP ' + x.status + ')')); return; } + toast('庫已更新'); + loadAdmin(); + }) + .catch(function (e) { t.disabled = false; toast(friendlyErr(e)); }); + return; + } + }); + // 一次性密碼關閉鈕(otpbox 動態生成,掛在容器上) + $('ad-otp').addEventListener('click', function (ev) { + if (ev.target && ev.target.id === 'ad-otp-close') $('ad-otp').innerHTML = ''; + }); + // ── 設定 ── function loadSettings() { var p = S.profile;