From 19c82df05fa914bc873cc0bf792d7ae3540e4643 Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Sun, 9 Aug 2026 15:06:17 +0800 Subject: [PATCH] =?UTF-8?q?fix(portal):=20=E7=AE=A1=E7=90=86=E5=93=A1?= =?UTF-8?q?=E5=BF=98=E8=A8=98=E5=AF=86=E7=A2=BC=E8=87=AA=E6=95=91=E6=8F=B4?= =?UTF-8?q?=E5=87=BA=E5=8F=A3=EF=BC=88arcrun-rag#25=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 唯一 admin 忘記 portal 密碼就永久卡死:requirePortalAdmin 系列端點全部要 先有 portal session 才進得去,bootstrap 又只能跑一次——登入頁只會叫他 「聯絡管理員」,而他自己就是管理員,沒有下一步。 新增 POST /portal/admin/recover-password,複用 bootstrap 已在用的 console owner session 當人閘(與 portal 密碼完全獨立存放的另一組帳密)。畫面入口: /console → 設定 → 「Portal 帳號密碼救援」;/portal 登入頁加一行連結指過去。 本機真瀏覽器 E2E 驗證(wrangler dev 18787/18788 + 本機靜態伺服,真的走一輪 forgot-password 狀態):first-time setup 建帳號 → 故意打錯密碼確認鎖死 (email 或密碼錯誤)→ 點連結進 /console → 用 console 密碼登入 → 設定頁輸入 portal email → 產生新密碼 BPq2Rs4p7dBWMd6d → 回 /portal 用新密碼登入成功。 cypher-executor 4 個新測試 + 既有 59/60 綠(唯一失敗是既有 pre-existing /portal HTML 殼 404,與本次無關,git stash 驗證過)。 --- console-ui/public/console/index.html | 30 ++++++++++ console-ui/public/portal/index.html | 1 + cypher-executor/src/routes/portal.ts | 36 ++++++++++++ cypher-executor/tests/portal-admin.test.ts | 64 ++++++++++++++++++++++ 4 files changed, 131 insertions(+) diff --git a/console-ui/public/console/index.html b/console-ui/public/console/index.html index 223c119..f3e0235 100644 --- a/console-ui/public/console/index.html +++ b/console-ui/public/console/index.html @@ -445,6 +445,15 @@ window.ARCRUN_API_BASE = (window.ARCRUN_CONFIG && window.ARCRUN_CONFIG.apiBase) +
+
Portal 帳號密碼救援
+
忘記某個 Portal(RAG 搜尋頁)帳號的密碼,包含你自己那組管理員帳號——不需要先登進 Portal。輸入該帳號的 Email,會產生一組新密碼,只顯示這一次,請立刻抄下並拿去 Portal 登入頁使用。
+
+ + +
+
+
系統資訊
載入中…
@@ -1526,6 +1535,27 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { }) .catch(function (e) { st.innerHTML = '請求失敗:' + esc(friendlyErr(e)) + ''; }); }); + // arcrun-rag#25:portal admin 密碼救援——只吃 console owner session(S.token,本頁登入用的 + // 那把),不吃 portal session,所以就算忘記 portal 密碼、進不去 portal 也走得通。 + $('st-portal-recover-btn').addEventListener('click', function () { + var email = $('st-portal-recover-email').value.trim(); + var st = $('st-portal-recover-status'); + if (!email) { st.innerHTML = '請輸入 Email'; return; } + st.textContent = '處理中…'; + fetch(API_BASE + '/portal/admin/recover-password', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + S.token }, + body: JSON.stringify({ email: email }) + }) + .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); }) + .then(function (x) { + if (!x.ok) { st.innerHTML = '' + esc(x.d.error || '失敗') + ''; return; } + st.innerHTML = '新密碼:' + esc(x.d.password) + '(只顯示這一次,請立刻抄下)'; + $('st-portal-recover-email').value = ''; + toast('新密碼已產生,請立刻抄下'); + }) + .catch(function (e) { st.innerHTML = '請求失敗:' + esc(friendlyErr(e)) + ''; }); + }); // t36:原本這裡綁在那顆假開關上(點了只會 toast 一段 CLI 指示)。開關已移除, // 這個 handler 也必須一起拿掉——留著會讓 $('st-vec-switch') 回 null、addEventListener // 當場拋錯,把後面所有綁定(含登出)一起打斷。 diff --git a/console-ui/public/portal/index.html b/console-ui/public/portal/index.html index 348702a..6f92768 100644 --- a/console-ui/public/portal/index.html +++ b/console-ui/public/portal/index.html @@ -220,6 +220,7 @@ if (!window.ARCRUN_API_BASE) {
帳號由管理員發放。忘記密碼請聯絡管理員重設。
+
你自己就是管理員?用管理主控台密碼救援自己
diff --git a/cypher-executor/src/routes/portal.ts b/cypher-executor/src/routes/portal.ts index a375f51..a6cddeb 100644 --- a/cypher-executor/src/routes/portal.ts +++ b/cypher-executor/src/routes/portal.ts @@ -575,6 +575,42 @@ portalRouter.post('/portal/admin/bootstrap', (c) => }), ); +// POST /portal/admin/recover-password — 管理員自救援出口(arcrun-rag#25:唯一 admin 忘記 +// portal 密碼就進不去,登入頁只會叫他「聯絡管理員」=叫他聯絡自己,沒有下一步)。 +// +// 根因:`/portal/admin/users/:id/reset-password`(上面)與其他 admin 端點全部要求 +// `requirePortalAdmin`=**要先有一個有效的 portal admin session**——雞生蛋問題:admin +// 密碼忘了就進不去 portal,進不去 portal 就沒有 session 去重設密碼。`bootstrap` 能繞過這關 +// 是因為它吃的是**另一道獨立的閘**(console owner session,design D-7);但 bootstrap +// 只能跑一次(已有 admin 就 409),事後沒有對應的「用同一道閘做救援」端點。 +// +// 修法:開一個**只認 console owner session、不認 portal session**的救援端點,直接複用 +// bootstrap 已經在用的 `validateConsoleSession`。console 帳密(`/console/setup` 首次設定時 +// 建立)與 portal 帳密是完全分開存放的兩組(見 console-auth.ts),只要 console 密碼沒有一起忘記, +// 這條路就走得通——不必問人、不必讀原始碼,畫面上(/console → 設定 → Portal 帳號密碼救援) +// 就有完整入口。找不到該 email 的帳號 → 404(誠實,不誤導成別的錯誤)。 +portalRouter.post('/portal/admin/recover-password', (c) => + run(c, async () => { + const consoleOk = await validateConsoleSession(c.env, c.req.header('authorization')); + if (!consoleOk) return c.json({ error: '需要 console owner session(先登入 /console)' }, 401); + + const body = await c.req.json().catch(() => null); + const email = String(body?.email ?? '').trim().toLowerCase(); + if (!isValidEmail(email)) return c.json({ error: 'email 格式不正確' }, 400); + + const recordId = await findUserRecordId(c.env, email); + const rec = recordId ? await getRecordById(c.env, recordId) : null; + if (!recordId || !rec) return c.json({ error: `找不到 email=${email} 的 portal 帳號` }, 404); + + const password = generatePassword(); + await patchRecordValues(c.env, recordId, { + password_hash: await hashPassword(password), + updated_at: new Date().toISOString(), + }); + return c.json({ success: true, email, password }); // 一次性回傳,server 不留明碼 + }), +); + // GET /portal/admin/users — 同仁列表(role=admin 閘)。**回應剝除 password_hash**。 portalRouter.get('/portal/admin/users', (c) => run(c, async () => { diff --git a/cypher-executor/tests/portal-admin.test.ts b/cypher-executor/tests/portal-admin.test.ts index f0d4cfa..c3b2453 100644 --- a/cypher-executor/tests/portal-admin.test.ts +++ b/cypher-executor/tests/portal-admin.test.ts @@ -263,6 +263,70 @@ describe('POST /portal/admin/users/:id/reset-password', () => { }); }); +// ═══════════════ 3.5 recover-password(arcrun-rag#25:admin 忘記 portal 密碼自救)═══════════════ + +describe('POST /portal/admin/recover-password', () => { + it('無 console owner session → 401,不碰 KBDB', async () => { + const res = await json('POST', '/portal/admin/recover-password', { email: 'admin@example.com' }); + expect(res.status).toBe(401); + }); + + it('有 console session 但 email 格式不對 → 400,不碰 KBDB', async () => { + await env.SESSIONS_KV.put('console_sess:owner-token', JSON.stringify({ created_at: Date.now() })); + const res = await json( + 'POST', + '/portal/admin/recover-password', + { email: 'not-an-email' }, + { Authorization: 'Bearer owner-token' }, + ); + expect(res.status).toBe(400); + }); + + it('查無此 email 的 portal 帳號 → 404,不誤導成別種錯誤', async () => { + await env.SESSIONS_KV.put('console_sess:owner-token', JSON.stringify({ created_at: Date.now() })); + mockHeadLookup('ghost@example.com', null); + const res = await json( + 'POST', + '/portal/admin/recover-password', + { email: 'ghost@example.com' }, + { Authorization: 'Bearer owner-token' }, + ); + expect(res.status).toBe(404); + }); + + it('console session 有效+帳號存在 → 回一次性新密碼;PATCH 落 KBDB 的是新 hash 非明碼;**不需要任何 portal session**', async () => { + await env.SESSIONS_KV.put('console_sess:owner-token', JSON.stringify({ created_at: Date.now() })); + // 刻意不 seedAdminSession():這條路唯一該吃的是 console session,機械證明繞得過 + // 「忘記 portal 密碼 ⇒ 沒有 portal_sess ⇒ 打不進其他 admin 端點」這個死結。 + mockHeadLookup('admin@example.com', 'rec_admin'); + mockGetRecord('rec_admin', adminValues()); + let patched = ''; + fetchMock + .get(KBDB) + .intercept({ path: '/records/rec_admin', method: 'PATCH' }) + .reply(200, (opts) => { + patched = String(opts.body); + return { success: true, record: { record_id: 'rec_admin', template_id: 'tpl_pu', values: adminValues() } }; + }); + const res = await json( + 'POST', + '/portal/admin/recover-password', + { email: 'Admin@Example.com' }, // 混寫大小寫,驗證正規化成小寫再查 + { Authorization: 'Bearer owner-token' }, + ); + expect(res.status).toBe(200); + const data = (await res.json()) as { success: boolean; email: string; password: string }; + expect(data.success).toBe(true); + expect(data.email).toBe('admin@example.com'); + 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(每帳號可查庫)', () => {