fix(portal): 管理員忘記密碼自救援出口(arcrun-rag#25)

唯一 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 驗證過)。
This commit is contained in:
uncle6me-web
2026-08-09 15:06:17 +08:00
parent 23d36b311a
commit 19c82df05f
4 changed files with 131 additions and 0 deletions
+36
View File
@@ -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 sessiondesign 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 () => {