From 2b563b0fa87b46fea972875f376fab159212ea07 Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Tue, 14 Jul 2026 15:32:42 +0800 Subject: [PATCH 1/4] =?UTF-8?q?portal-auth=20P4=EF=BC=9A=E6=9C=80=E5=BE=8C?= =?UTF-8?q?=E4=B8=80=E5=80=8B=20active=20admin=20=E9=8E=96=E6=AD=BB?= =?UTF-8?q?=E4=BF=9D=E8=AD=B7=EF=BC=88=E5=81=9C=E7=94=A8/=E9=99=8D?= =?UTF-8?q?=E7=B4=9A=20=E2=86=92=20409=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 系統至少保留一個 active admin——bootstrap 只能跑一次(已有 admin 409), 若最後一個 admin 被停用/降級,帳號管理即永久鎖死。只在「目標是 active admin 且 patch 會使其失格」時多打一次 list(平時零成本)。 Co-Authored-By: Claude Fable 5 --- cypher-executor/src/routes/portal.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/cypher-executor/src/routes/portal.ts b/cypher-executor/src/routes/portal.ts index 93f935e..e485bb4 100644 --- a/cypher-executor/src/routes/portal.ts +++ b/cypher-executor/src/routes/portal.ts @@ -631,6 +631,21 @@ portalRouter.patch('/portal/admin/users/:id', (c) => patch.libraries = JSON.stringify(body.libraries); } if (Object.keys(patch).length === 0) return c.json({ error: '沒有可更新的欄位(status/role/libraries)' }, 400); + + // 鎖死保護(P4,總管派工明定):**不可停用/降級最後一個 active admin**—— + // 否則系統再無人能管帳號(bootstrap 只能跑一次,409),變成鎖死狀態。 + // 只在「目標現在是 active admin 且 patch 會使它不再是」時才多打一次 list(平時零成本)。 + const isActiveAdmin = (rec.values.role ?? '') === 'admin' && (rec.values.status ?? '') === 'active'; + const wouldLoseAdmin = patch.status === 'disabled' || patch.role === 'user'; + if (isActiveAdmin && wouldLoseAdmin) { + const all = await listRecordsByTemplate(c.env, USER_TEMPLATE); + const otherActiveAdmins = all.filter( + (u) => u.record_id !== recordId && (u.values.role ?? '') === 'admin' && (u.values.status ?? '') === 'active', + ); + if (otherActiveAdmins.length === 0) { + return c.json({ error: '不可停用或降級最後一個管理員——系統至少要保留一個 active admin' }, 409); + } + } patch.updated_at = new Date().toISOString(); const updated = await patchRecordValues(c.env, recordId, patch); -- 2.52.0 From fa64e71b584cd4febd584800a91b15fd1c14d6df Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Tue, 14 Jul 2026 15:32:42 +0800 Subject: [PATCH 2/4] =?UTF-8?q?portal-auth=20P4=EF=BC=9A/portal=20?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E9=A0=81=20UI=EF=BC=88=E5=B8=B3=E8=99=9F?= =?UTF-8?q?=E7=AE=A1=E7=90=86=EF=BC=8B=E5=BA=AB=E7=9B=AE=E9=8C=84=E7=AE=A1?= =?UTF-8?q?=E7=90=86=EF=BC=8Cadmin-only=20nav=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 帳號管理:同仁列表(email/顯示名/角色/狀態/可查庫)+新增(一次性密碼只顯示 一次,server 不留明碼)+停用/啟用+重設密碼+每帳號勾選可查庫(含 ["*"] 全庫 選項,勾選連動停用個別庫) - 庫目錄管理:登記/停用庫+graph_source 圖譜來源標記(D-4) - nav 只對 admin 顯示(/portal/session role);前端藏只是 UX,真閘在 /portal/admin/* role 閘 403(P3 guard 精神) - 工作流頁 P3 已交付(v-workflows+D-8 閘),本次不重做 Co-Authored-By: Claude Fable 5 --- cypher-executor/src/routes/portal-ui.ts | 334 +++++++++++++++++++++++- 1 file changed, 330 insertions(+), 4 deletions(-) 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; -- 2.52.0 From 605204511fb9c4e17e4f93dca78289cb7db910e0 Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Tue, 14 Jul 2026 15:32:42 +0800 Subject: [PATCH 3/4] =?UTF-8?q?portal-auth=20P4=EF=BC=9Aadmin=20=E6=B8=AC?= =?UTF-8?q?=E8=A9=A6=2014=20=E9=A0=85=EF=BC=88=E9=8E=96=E6=AD=BB=E4=BF=9D?= =?UTF-8?q?=E8=AD=B7/=E4=B8=80=E6=AC=A1=E6=80=A7=E5=AF=86=E7=A2=BC?= =?UTF-8?q?=E4=B8=8D=E8=90=BD=E5=BA=AB/=E5=BA=AB=E7=9B=AE=E9=8C=84/HTML=20?= =?UTF-8?q?=E7=B4=85=E7=B7=9A=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit last-admin 409(含另一 admin 是 disabled 不算數、停用一般 user 不多打 list); generated_password/reset-password 一次性回傳且明碼不進 KBDB body;libraries ["*"] 與壞庫名 400;庫目錄子 namespace+graph_source;/portal HTML 殼加 admin 頁後仍零租戶字串/零 /kbdb//零 X-Arcrun-API-Key/零 Mira。 Co-Authored-By: Claude Fable 5 --- cypher-executor/tests/portal-admin.test.ts | 397 +++++++++++++++++++++ 1 file changed, 397 insertions(+) create mode 100644 cypher-executor/tests/portal-admin.test.ts diff --git a/cypher-executor/tests/portal-admin.test.ts b/cypher-executor/tests/portal-admin.test.ts new file mode 100644 index 0000000..37da814 --- /dev/null +++ b/cypher-executor/tests/portal-admin.test.ts @@ -0,0 +1,397 @@ +/** + * portal-auth P4 測試(design §5/§6,Gitea #24/#25) + * + * 覆蓋(=tasks.md P4+總管派工驗收重點): + * 1. **最後一個 active admin 鎖死保護**:停用 → 409;降級 role=user → 409; + * 「還有另一個 active admin」才放行;另一個 admin 是 disabled 不算數。 + * 2. 一次性密碼:新增未帶密碼 → generated_password 只在回應出現一次、明碼不落 KBDB + * (庫裡只有 pbkdf2 hash);自帶密碼 → 回應無 generated_password。 + * 3. reset-password:回一次性新密碼;PATCH 進 KBDB 的是 hash 非明碼。 + * 4. 庫權限:PATCH libraries=["*"](全庫)合法;空陣列/壞庫名 → 400。 + * 5. 庫目錄:POST 建庫寫 {tenant}::portal 子 namespace;PATCH graph_source boolean。 + * 6. /portal HTML 殼(P4 admin 頁):admin view 存在;**仍零租戶字串、零 /kbdb/、 + * 零 X-Arcrun-API-Key**(P3 紅線在新增 admin UI 後不得回退)。 + * + * KBDB 打 fetchMock 假 host(wrangler.test.toml KBDB_BASE_URL=https://kbdb.test)+ + * disableNetConnect——絕不外連。UI 全流程由本機隔離雙 worker 端到端 curl 驗證(PR 證據表)。 + */ +import { SELF, env, fetchMock } from 'cloudflare:test'; +import { beforeAll, afterEach, describe, it, expect } from 'vitest'; +import { hashPassword, PBKDF2_ITERATIONS } from '../src/lib/portal-auth'; + +const KBDB = 'https://kbdb.test'; +const NS = 'leo::portal'; // wrangler.test.toml CONSOLE_TENANT=leo → 子 namespace + +let storedHash: string; + +beforeAll(async () => { + fetchMock.activate(); + fetchMock.disableNetConnect(); + storedHash = await hashPassword('unit-test-pw-1', 10_000); +}); +afterEach(() => fetchMock.assertNoPendingInterceptors()); + +function json(method: string, path: string, body?: unknown, headers: Record = {}) { + return SELF.fetch(`http://localhost${path}`, { + method, + headers: { 'Content-Type': 'application/json', ...headers }, + body: body === undefined ? undefined : JSON.stringify(body), + }); +} + +function mockHeadLookup(email: string, recordId: string | null) { + const needle = new URLSearchParams({ page_name: email }).toString(); + fetchMock + .get(KBDB) + .intercept({ + path: (p: string) => + p.startsWith('/entries?') && p.includes(needle) && p.includes(encodeURIComponent(NS)), + method: 'GET', + }) + .reply(200, { success: true, entries: recordId ? [{ content: recordId }] : [], count: recordId ? 1 : 0 }); +} + +function mockGetRecord(recordId: string, values: Record) { + fetchMock + .get(KBDB) + .intercept({ path: `/records/${recordId}`, method: 'GET' }) + .reply(200, { success: true, record: { record_id: recordId, template_id: 'tpl_pu', values } }); +} + +function mockListByTemplate(template: string, records: { record_id: string; values: Record }[]) { + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith(`/records/by-template/${template}`), method: 'GET' }) + .reply(200, { success: true, records: records.map((r) => ({ ...r, template_id: 'tpl' })), count: records.length }); +} + +function mockTemplatesExist() { + for (const name of ['portal_user', 'portal_library']) { + fetchMock + .get(KBDB) + .intercept({ path: `/templates/${name}`, method: 'GET' }) + .reply(200, { success: true, template: { id: `tpl-${name}`, name } }); + } +} + +function adminValues(overrides: Record = {}): Record { + return { + email: 'admin@example.com', + display_name: '管理員', + status: 'active', + role: 'admin', + password_hash: storedHash, + libraries: '["*"]', + created_at: '2026-07-14T00:00:00.000Z', + updated_at: '2026-07-14T00:00:00.000Z', + ...overrides, + }; +} + +function userValues(overrides: Record = {}): Record { + return adminValues({ email: 'user@example.com', display_name: '同仁', role: 'user', libraries: '["general"]', ...overrides }); +} + +async function seedAdminSession(token = 'tok-admin', recordId = 'rec_admin') { + await env.SESSIONS_KV.put(`portal_sess:${token}`, JSON.stringify({ record_id: recordId })); +} + +/** PATCH /portal/admin/users/:id 的共通 mock 前奏:admin session 回讀+目標 record 成員驗證。 */ +function mockPatchPrelude(targetId: string, targetValues: Record) { + mockGetRecord('rec_admin', adminValues()); // requirePortalAdmin 回讀 + mockGetRecord(targetId, targetValues); // assertPortalUserRecord 回讀目標 + mockHeadLookup(targetValues.email, targetId); // head 指回同 record → 成員資格成立 +} + +// ═══════════════ 1. 最後一個 active admin 鎖死保護 ═══════════════ + +describe('last-admin 鎖死保護(PATCH /portal/admin/users/:id)', () => { + it('停用最後一個 active admin → 409,不發 PATCH', async () => { + await seedAdminSession(); + mockPatchPrelude('rec_admin2', adminValues({ email: 'admin2@example.com' })); + // guard 查全列表:只有目標自己是 active admin(另一人是一般 user) + mockListByTemplate('portal_user', [ + { record_id: 'rec_admin2', values: adminValues({ email: 'admin2@example.com' }) }, + { record_id: 'rec_u1', values: userValues() }, + ]); + const res = await json('PATCH', '/portal/admin/users/rec_admin2', { status: 'disabled' }, { Authorization: 'Bearer tok-admin' }); + expect(res.status).toBe(409); + const data = (await res.json()) as { error: string }; + expect(data.error).toContain('最後一個管理員'); + }); + + it('降級最後一個 active admin(role=user)→ 409', async () => { + await seedAdminSession(); + mockPatchPrelude('rec_admin2', adminValues({ email: 'admin2@example.com' })); + mockListByTemplate('portal_user', [ + { record_id: 'rec_admin2', values: adminValues({ email: 'admin2@example.com' }) }, + ]); + const res = await json('PATCH', '/portal/admin/users/rec_admin2', { role: 'user' }, { Authorization: 'Bearer tok-admin' }); + expect(res.status).toBe(409); + }); + + it('「另一個 admin 是 disabled」不算數 → 仍 409', async () => { + await seedAdminSession(); + mockPatchPrelude('rec_admin2', adminValues({ email: 'admin2@example.com' })); + mockListByTemplate('portal_user', [ + { record_id: 'rec_admin2', values: adminValues({ email: 'admin2@example.com' }) }, + { record_id: 'rec_admin3', values: adminValues({ email: 'admin3@example.com', status: 'disabled' }) }, + ]); + const res = await json('PATCH', '/portal/admin/users/rec_admin2', { status: 'disabled' }, { Authorization: 'Bearer tok-admin' }); + expect(res.status).toBe(409); + }); + + it('還有另一個 active admin → 停用放行(200)', async () => { + await seedAdminSession(); + mockPatchPrelude('rec_admin2', adminValues({ email: 'admin2@example.com' })); + mockListByTemplate('portal_user', [ + { record_id: 'rec_admin2', values: adminValues({ email: 'admin2@example.com' }) }, + { record_id: 'rec_admin', values: adminValues() }, // 操作者自己也是 active admin + ]); + fetchMock + .get(KBDB) + .intercept({ path: '/records/rec_admin2', method: 'PATCH' }) + .reply(200, { + success: true, + record: { record_id: 'rec_admin2', template_id: 'tpl_pu', values: adminValues({ email: 'admin2@example.com', status: 'disabled' }) }, + }); + const res = await json('PATCH', '/portal/admin/users/rec_admin2', { status: 'disabled' }, { Authorization: 'Bearer tok-admin' }); + expect(res.status).toBe(200); + }); + + it('停用一般 user 不觸發 admin 列表檢查(無 by-template mock 也過=機械證明沒多打)', async () => { + await seedAdminSession(); + mockPatchPrelude('rec_u1', userValues()); + fetchMock + .get(KBDB) + .intercept({ path: '/records/rec_u1', method: 'PATCH' }) + .reply(200, { + success: true, + record: { record_id: 'rec_u1', template_id: 'tpl_pu', values: userValues({ status: 'disabled' }) }, + }); + const res = await json('PATCH', '/portal/admin/users/rec_u1', { status: 'disabled' }, { Authorization: 'Bearer tok-admin' }); + expect(res.status).toBe(200); // afterEach assertNoPendingInterceptors = 沒有殘留 list mock + }); +}); + +// ═══════════════ 2. 一次性密碼(新增帳號)═══════════════ + +describe('POST /portal/admin/users(一次性密碼)', () => { + it('未帶 password → generated_password 回一次(16 碼);KBDB 落的是 hash 非明碼', async () => { + await seedAdminSession(); + mockGetRecord('rec_admin', adminValues()); + mockHeadLookup('new@example.com', null); // email 未占用 + let recordBody = ''; + fetchMock + .get(KBDB) + .intercept({ path: '/records', method: 'POST' }) + .reply(200, (opts) => { + recordBody = String(opts.body); + return { success: true, record: { record_id: 'rec_new', template_id: 'tpl_pu', values: {} } }; + }); + fetchMock + .get(KBDB) + .intercept({ path: '/entries', method: 'POST' }) + .reply(200, { success: true, entry: { id: 'e_head' } }); + mockGetRecord('rec_new', userValues({ email: 'new@example.com' })); // 回應用的回讀 + const res = await json( + 'POST', + '/portal/admin/users', + { email: 'new@example.com', display_name: '新同仁', libraries: ['general'] }, + { Authorization: 'Bearer tok-admin' }, + ); + expect(res.status).toBe(200); + const data = (await res.json()) as { generated_password?: string; user: Record }; + expect(typeof data.generated_password).toBe('string'); + expect(data.generated_password!.length).toBe(16); + expect('password_hash' in data.user).toBe(false); + // 一次性密碼不落庫:KBDB 收到的 record body 只有 hash、無明碼 + expect(recordBody).not.toContain(data.generated_password!); + const rec = JSON.parse(recordBody) as { owner_id: string; values: Record }; + expect(rec.owner_id).toBe(NS); + expect(rec.values.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true); + }); + + it('自帶 password → 回應**無** generated_password', async () => { + await seedAdminSession(); + mockGetRecord('rec_admin', adminValues()); + mockHeadLookup('own@example.com', null); + fetchMock + .get(KBDB) + .intercept({ path: '/records', method: 'POST' }) + .reply(200, { success: true, record: { record_id: 'rec_own', template_id: 'tpl_pu', values: {} } }); + fetchMock + .get(KBDB) + .intercept({ path: '/entries', method: 'POST' }) + .reply(200, { success: true, entry: { id: 'e_head2' } }); + mockGetRecord('rec_own', userValues({ email: 'own@example.com' })); + const res = await json( + 'POST', + '/portal/admin/users', + { email: 'own@example.com', password: 'self-chosen-pw-1' }, + { Authorization: 'Bearer tok-admin' }, + ); + expect(res.status).toBe(200); + const data = (await res.json()) as Record; + expect('generated_password' in data).toBe(false); + }); +}); + +// ═══════════════ 3. reset-password ═══════════════ + +describe('POST /portal/admin/users/:id/reset-password', () => { + it('回一次性新密碼;PATCH 落 KBDB 的是新 hash 非明碼', async () => { + await seedAdminSession(); + mockPatchPrelude('rec_u1', userValues()); + let patched = ''; + fetchMock + .get(KBDB) + .intercept({ path: '/records/rec_u1', method: 'PATCH' }) + .reply(200, (opts) => { + patched = String(opts.body); + return { success: true, record: { record_id: 'rec_u1', template_id: 'tpl_pu', values: userValues() } }; + }); + const res = await json('POST', '/portal/admin/users/rec_u1/reset-password', undefined, { Authorization: 'Bearer tok-admin' }); + expect(res.status).toBe(200); + const data = (await res.json()) as { password: string }; + expect(typeof data.password).toBe('string'); + expect(data.password.length).toBe(16); + expect(patched).not.toContain(data.password); // 明碼不落 KBDB + const sent = JSON.parse(patched) as { values: Record }; + expect(sent.values.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true); + expect(sent.values.password_hash).not.toBe(storedHash); // 真的換了 + }); +}); + +// ═══════════════ 4. 庫權限勾選(libraries PATCH)═══════════════ + +describe('PATCH libraries(每帳號可查庫)', () => { + it('["*"](全庫選項)合法,存成 JSON 字串', async () => { + await seedAdminSession(); + mockPatchPrelude('rec_u1', userValues()); + let patched = ''; + fetchMock + .get(KBDB) + .intercept({ path: '/records/rec_u1', method: 'PATCH' }) + .reply(200, (opts) => { + patched = String(opts.body); + return { success: true, record: { record_id: 'rec_u1', template_id: 'tpl_pu', values: userValues({ libraries: '["*"]' }) } }; + }); + const res = await json('PATCH', '/portal/admin/users/rec_u1', { libraries: ['*'] }, { Authorization: 'Bearer tok-admin' }); + expect(res.status).toBe(200); + expect((JSON.parse(patched) as { values: Record }).values.libraries).toBe('["*"]'); + }); + + it('空陣列 / 壞庫名(含逗號)→ 400 不 PATCH', async () => { + await seedAdminSession(); + mockPatchPrelude('rec_u1', userValues()); + const res = await json('PATCH', '/portal/admin/users/rec_u1', { libraries: [] }, { Authorization: 'Bearer tok-admin' }); + expect(res.status).toBe(400); + + await seedAdminSession('tok-admin2'); + mockGetRecord('rec_admin', adminValues()); + mockGetRecord('rec_u1', userValues()); + mockHeadLookup('user@example.com', 'rec_u1'); + const res2 = await json('PATCH', '/portal/admin/users/rec_u1', { libraries: ['a,b'] }, { Authorization: 'Bearer tok-admin2' }); + expect(res2.status).toBe(400); + }); +}); + +// ═══════════════ 5. 庫目錄管理 ═══════════════ + +describe('/portal/admin/libraries', () => { + it('POST 建庫:寫 {tenant}::portal 子 namespace;重複登記 → 409', async () => { + await seedAdminSession(); + mockGetRecord('rec_admin', adminValues()); + mockTemplatesExist(); + mockListByTemplate('portal_library', []); + let recordBody = ''; + fetchMock + .get(KBDB) + .intercept({ path: '/records', method: 'POST' }) + .reply(200, (opts) => { + recordBody = String(opts.body); + return { + success: true, + record: { record_id: 'rec_lib1', template_id: 'tpl_pl', values: { name: 'finance', display_name: '財務庫', status: 'active' } }, + }; + }); + const res = await json( + 'POST', + '/portal/admin/libraries', + { name: 'finance', display_name: '財務庫' }, + { Authorization: 'Bearer tok-admin' }, + ); + expect(res.status).toBe(200); + const rec = JSON.parse(recordBody) as { owner_id: string; template: string }; + expect(rec.owner_id).toBe(NS); + expect(rec.template).toBe('portal_library'); + + // 重複登記 + await seedAdminSession('tok-admin3'); + mockGetRecord('rec_admin', adminValues()); + mockTemplatesExist(); + mockListByTemplate('portal_library', [ + { record_id: 'rec_lib1', values: { name: 'finance', display_name: '財務庫', status: 'active' } }, + ]); + const dup = await json('POST', '/portal/admin/libraries', { name: 'finance' }, { Authorization: 'Bearer tok-admin3' }); + expect(dup.status).toBe(409); + }); + + it('PATCH graph_source:boolean 進、slot 存字串;非 boolean → 400', async () => { + await seedAdminSession(); + mockGetRecord('rec_admin', adminValues()); + mockListByTemplate('portal_library', [ + { record_id: 'rec_lib1', values: { name: 'finance', display_name: '財務庫', status: 'active' } }, + ]); + let patched = ''; + fetchMock + .get(KBDB) + .intercept({ path: '/records/rec_lib1', method: 'PATCH' }) + .reply(200, (opts) => { + patched = String(opts.body); + return { + success: true, + record: { record_id: 'rec_lib1', template_id: 'tpl_pl', values: { name: 'finance', status: 'active', graph_source: 'true' } }, + }; + }); + const res = await json('PATCH', '/portal/admin/libraries/rec_lib1', { graph_source: true }, { Authorization: 'Bearer tok-admin' }); + expect(res.status).toBe(200); + expect((JSON.parse(patched) as { values: Record }).values.graph_source).toBe('true'); + const data = (await res.json()) as { library: { graph_source: boolean } }; + expect(data.library.graph_source).toBe(true); + + await seedAdminSession('tok-admin4'); + mockGetRecord('rec_admin', adminValues()); + mockListByTemplate('portal_library', [ + { record_id: 'rec_lib1', values: { name: 'finance', status: 'active' } }, + ]); + const bad = await json('PATCH', '/portal/admin/libraries/rec_lib1', { graph_source: 'yes' }, { Authorization: 'Bearer tok-admin4' }); + expect(bad.status).toBe(400); + }); + + it('一般 user 打庫目錄 → 403(role 閘)', async () => { + await seedAdminSession('tok-user', 'rec_u1'); + mockGetRecord('rec_u1', userValues()); + const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-user' }); + expect(res.status).toBe(403); + }); +}); + +// ═══════════════ 6. /portal HTML 殼(P4 admin 頁後紅線不回退)═══════════════ + +describe('GET /portal(P4 admin 頁 HTML 殼)', () => { + it('admin view 存在;仍零租戶字串、零 /kbdb/、零 X-Arcrun-API-Key、零 Mira', async () => { + const res = await SELF.fetch('http://localhost/portal'); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain('v-admin'); // P4 管理頁 view + expect(html).toContain('/portal/admin/users'); // 帳號管理走 admin API + expect(html).toContain('/portal/admin/libraries'); // 庫目錄管理 + // P3 紅線(design §3.3)在加了 admin UI 後不得回退 + expect(html).not.toMatch(/['"]leo['"]/); + expect(html).not.toContain('/kbdb/'); + expect(html).not.toContain('X-Arcrun-API-Key'); + expect(html).not.toContain('Mira'); + }); +}); -- 2.52.0 From fd94340eba9fbbf065a64f33f393b75fb51bdd9c Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Tue, 14 Jul 2026 15:38:46 +0800 Subject: [PATCH 4/4] =?UTF-8?q?portal-auth=20P4=20=E6=94=B6=E5=B0=BE?= =?UTF-8?q?=EF=BC=9Atasks.md=20=E5=85=A8=20[x]=EF=BC=8Bwiki=20status=20P4?= =?UTF-8?q?=20=E6=AE=B5=EF=BC=88SDD=20P1-P4=20=E5=85=A8=E5=AE=8C=E6=88=90?= =?UTF-8?q?=EF=BC=8C=E5=BE=85=20T6-cloud=20=E6=8E=92=E7=B7=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- system-dev/docs/3-specs/portal-auth/tasks.md | 26 +++++++++++++++++--- system-dev/wiki/status.md | 20 ++++++++++++++- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/system-dev/docs/3-specs/portal-auth/tasks.md b/system-dev/docs/3-specs/portal-auth/tasks.md index e7b66fe..c1a6d6d 100644 --- a/system-dev/docs/3-specs/portal-auth/tasks.md +++ b/system-dev/docs/3-specs/portal-auth/tasks.md @@ -102,10 +102,30 @@ ## P4 — admin 頁+工作流顯示(design §6)|觸碰:`cypher-executor/` -- [ ] 帳號管理頁(admin-only nav):列表/新增/停用/重設密碼+每帳號庫權限勾選 -- [ ] 庫目錄管理(登記/停用庫) -- [ ] 工作流頁(唯讀 list+最近執行;`PORTAL_SHOW_WORKFLOWS` 預設 admin;不開 trigger) +- [x] 帳號管理頁(admin-only nav):列表/新增/停用/重設密碼+每帳號庫權限勾選 + - 實作註(2026-07-14):`portal-ui.ts` 加「管理」頁(v-admin,nav 只對 role=admin 顯示, + 真閘=/portal/admin/* role 閘 403)。新增未帶密碼 → server `generatePassword` 一次性 + 密碼**只在畫面顯示一次**(otpbox,關閉即逝;server 只存 hash);每帳號庫勾選含 + `["*"]` 全庫選項(勾選連動停用個別庫框);可勾清單=庫目錄啟用中 ∪ general ∪ 該帳號 + 既有授權(目錄外舊授權不憑空消失)。admin API P2 已交付不重做;本波補 + **last-admin 鎖死保護**(總管派工明定,design 未明寫——PATCH 停用/降級「最後一個 + active admin」→ 409;只在目標是 active admin 且 patch 會使其失格時多打一次 list)。 +- [x] 庫目錄管理(登記/停用庫) + - 同頁第二區:登記(name/display_name/description)+停用/啟用+ `graph_source` + 圖譜來源標記(D-4,P3 已有 API,本波補 UI)。 +- [x] 工作流頁(唯讀 list+最近執行;`PORTAL_SHOW_WORKFLOWS` 預設 admin;不開 trigger) + - **P3 已交付**(`/portal/data/workflows` route+v-workflows 頁+D-8 閘),P4 沿用零改動。 - **驗收**:admin 全流程「發帳號→授庫→同仁登入查詢→停用」在 UI 走通;一般用戶看不到 admin 頁與(預設下)工作流頁 + - 單元:`tests/portal-admin.test.ts` 14 項(鎖死保護含「另一 admin 是 disabled 不算數」 + 「停用一般 user 不多打 list」;一次性密碼/reset 明碼不進 KBDB body;HTML 殼加 admin 頁後 + 仍零租戶字串/零 /kbdb//零 API key/零 Mira)。cypher 全套 168/169(唯一失敗=executor + 「不存在的零件」pre-existing);kbdb 20/20;兩包 tsc 0;dry-run 打包過;inline JS + node --check 過(2 段)。 + - 端到端:本機隔離雙 worker(9790/9791,demo 8787/8788 不碰)**41/41 全綠**——bootstrap→ + admin 建帳號拿一次性密碼→新 user 登入→勾庫後**同一 session 搜尋範圍即時變化**(1 筆→2 筆、 + 越庫 404→200)→停用 session 立即失效→重設密碼舊密 401 新密 200→last-admin 409(含降級 + 即時生效:原 admin session 打 admin API 變 403)→一般 user 打 admin API 403。 + - leo21c 線上面=部署後驗(gated wrangler 直推,B 類流程)。 - **工程量**:中(約 1 個 CC 工作天) --- diff --git a/system-dev/wiki/status.md b/system-dev/wiki/status.md index 002ebd3..95f3d7b 100644 --- a/system-dev/wiki/status.md +++ b/system-dev/wiki/status.md @@ -15,7 +15,25 @@ metadata: ## 📍 當前位置 -> **2026-07-14 本 session(portal-auth P3:Portal UI+查詢 enforce——本 SDD 最大件,PR 待總管審不 merge)**: +> **2026-07-14 本 session(portal-auth P4:admin 帳號+庫管理頁——本 SDD 最後一棒,PR 待總管審不 merge)**: +> - **實作(分支 `portal-auth-p4-admin-ui`,3+1 小步 commit)**:① `portal.ts` 補 **last-admin +> 鎖死保護**(總管派工明定、design 未明寫)——PATCH 停用/降級「最後一個 active admin」→ 409 +> (另一 admin 是 disabled 不算數;只在目標是 active admin 且會失格時才多打一次 list,平時零成本)。 +> admin API 其餘 P2 已交付(users CRUD/reset-password/libraries 含 graph_source)**不重做**。 +> ② `portal-ui.ts` 加「管理」頁(admin-only nav,前端藏=UX、真閘=/portal/admin/* role 403): +> 帳號管理(列表/新增/停用啟用/重設密碼+每帳號勾選可查庫含 `["*"]` 全庫連動)+庫目錄管理 +> (登記/停用/graph_source 標記)。**一次性密碼只在畫面顯示一次**(otpbox 關閉即逝,server 只存 +> hash、明碼不落庫不進 log)。③ 工作流頁 P4 項=P3 已交付,零改動沿用。 +> - **驗證**:新增 `tests/portal-admin.test.ts` 14 項;cypher 168/169(唯一失敗=executor「不存在的 +> 零件」pre-existing);kbdb 20/20;兩包 tsc 0;dry-run 打包過;inline JS node --check 過。 +> **端到端隔離雙 worker(9790/9791,demo 8787/8788 不碰)41/41 全綠**:bootstrap→建帳號拿一次性 +> 密碼→新 user 登入→勾庫**同一 session 搜尋即時變化**(1→2 筆、越庫 404→200)→停用 session 立即 +> 失效→重設密碼舊密 401 新密 200→last-admin 409+降級即時生效(原 admin session 變 403)→一般 +> user 打 admin API 403→HTML 殼加 admin 頁後仍零租戶字串/零 /kbdb//零 API key/零 Mira。 +> - **portal-auth SDD P1-P4 全完成,待 T6-cloud 排練上真環境**(部署清單沿 P1:Vectorize `library` +> metadata index+reindex backfill;gated leo21c wrangler 直推,B 類流程)。不 merge 待總管審。 +> +> **2026-07-14 上一輪(portal-auth P3:Portal UI+查詢 enforce——本 SDD 最大件,PR #52 待總管審不 merge)**: > - **實作(分支 `portal-auth-p3-portal-ui`)**:① `routes/portal-data.ts`(安全核心)——`/portal/data/*` > server-side enforce:session→回讀 user record→server 注入 `owner_id=CONSOLE_TENANT`+`library=<集合>` > 轉發 KBDB;**前端絕不下發租戶字串**(vs console 把 tenant 交給前端直打 /kbdb/*);caller 自帶 -- 2.52.0