fix(identity): staging fixed-code sign-in skips the first-password step (BUG-1152)
Independent Staging Quality Gate / validate (push) Successful in 12m25s
Independent Staging Quality Gate / publish (push) Successful in 3m56s

On the staging test-OTP channel, 验证码登录 with a fresh mailbox no longer
stops at 设置登录密码; it records consent and enters the app. 注册账号 still
sets a password, second factor still comes first, and production (no
IDENTITY_TEST_OTP) is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
This commit is contained in:
Jesse_Chen
2026-10-01 22:43:34 +08:00
co-authored by Claude Opus 5.5
parent 279650a684
commit 2d620fb4ad
7 changed files with 98 additions and 3 deletions
+5
View File
@@ -1,5 +1,10 @@
# 印度占星 Skill 更新日志
## 2026-10-01 — staging 用固定验证码登录新邮箱,不再要求设置密码
- 测试环境(staging)选「验证码登录」,输入固定验证码后直接进入首页,不再停在「设置登录密码」(BUG-1152)。「注册账号」仍会设置密码。
- 生产环境不变。Skill 版本不变;不改数据库结构。
## 2026-10-01 — 交付时盘型结论不再连说两遍
- 修复:答完最后一道题出交付卡时,「D1 上升…用了 N 道选择题」连出两条。上一版修好收尾话的保存后带出的重复,现在已说过就不再补写(BUG-1153)。Skill 版本不变;不改数据库结构。
+16
View File
@@ -15498,3 +15498,19 @@
- 复发自:BUG-596(同一症状:交付连出两条;当时的 60 秒守卫只管无用户消息的二次运行,不管补写轮次)。
- 修复版本:`codex/rectification-post-adopt-verify-20261001`。
## BUG-1152 | staging 新邮箱用固定验证码登录后被要求设置密码,测试者以为必须用密码登录
- 状态:resolved(2026-10-01 Claude 直接执行;产品 10-01 选「测试通道跳过设置密码」)
- 首次发现 / 最近更新:2026-10-01 / 2026-10-01
- 影响面:`frontend/src/components/email-otp-login.tsx::verifyOtp`(新增 `offersFirstPasswordStep`)。只在服务端配置了 `IDENTITY_TEST_OTP` 的环境(staging)生效;生产不配置,行为不变。
- 用户现象:打开 staging 登录页只看到「验证码登录 / 密码登录」,没有任何固定验证码的提示;用新邮箱输入固定码后又被拦在「设置登录密码」,没有跳过入口。产品以为 staging 改成要密码才能登录。
- 触发条件:staging 测试通道 + 「验证码登录」+ 该邮箱账户没有密码(新邮箱必然如此)。
- 根因:`verifyOtp` 在 `hasPassword()` 为 false 时一律进入 `set-password` 步骤(07-27 `ab2944f7` 起),不区分测试通道;验证码通过时会话已经建立,这一步只是界面步骤。BUG-1121/1122(`3e0c615f`)把登录卡上常驻的固定码提示改成点「发送验证码」后 10 秒的浮层,进页面时看不到固定码,问题更显眼。固定码通道本身一直开着(线上 `/login` 带 `testOtp`,发码接口 200)。
- 决策记录:产品 10-01 只选「测试通道验证码登录跳过设置密码」;不恢复常驻提示(BUG-1121/1122 的浮层设计不变)。「注册账号」模式仍设置密码;二步验证仍优先。
- 修复:`offersFirstPasswordStep(testMode, mode)` 在测试通道 + `otp` 模式下为 false,`verifyOtp` 此时不查 `hasPassword`、直接 `enterApp()`(记录协议同意后进入首页)。
- 验证:`frontend/tests/identity-login-provider.test.ts`「staging fixed-code sign-in skips the first-password step」:四种组合(测试/真实邮件 × 验证码登录/注册),以及二步验证分支仍在密码判断之前。修复前该函数不存在,测试红。
- 防复发:测试通道的便利只能挂在 `testMode` 上,不得改服务端鉴权或让生产受影响。
- 相关记录:BUG-1121、BUG-1122。
- 复发自:无
- 修复版本:`codex/staging-otp-skip-password-20261001`。
@@ -0,0 +1,23 @@
# PROGRESS · staging 固定验证码登录跳过「设置密码」(2026-10-01)
执行方:Claude(产品 10-01「请你执行」,直接执行模式)。任务书:`TASK-staging-otp-skip-password-20261001.md`。基线 `origin/staging` = `279650a6`。
| 任务 | 结果 | 证据 |
| --- | --- | --- |
| T1 跳过设置密码 | 完成 | `email-otp-login.tsx` 新增导出 `offersFirstPasswordStep(testMode, mode)`;`verifyOtp` 在其为 false 时不调用 `hasPassword()`,落到原有的 `await enterApp()`。选了「省掉调用」:测试通道下这次请求没有用处。二步验证分支位置不变;`enterApp` 调用次数仍为 5,原有计数断言不改 |
| T2 回归测试 | 完成(纯函数行为 + 源码顺序断言) | `identity-login-provider.test.ts` 新增 1 条:四种组合 + 二步验证在前 + `set-password` 仍由该判断守住。全组件渲染需要模拟 `selfHostedAuthActions`/`next/image`,收益不抵成本,按让步顺序取纯函数 |
| T3 记录 | 完成 | `docs/BUG_HISTORY.md` BUG-1152;`CHANGELOG.md`;`docs/testing/staging-otp-skip-password-20261001.md` |
## 验收数字(Node 22.x,本机无 Docker)
| 项 | 结果 |
| --- | --- |
| `tsc --noEmit` | 0 错 |
| `npm run lint` | 0 error(126 warning,与改动文件无关;改动文件单独 eslint 无输出) |
| `npm test` | 基线 4867 / pass 4802 / fail 24 → 改后 4868 / 4803 / 24;失败清单与基线逐条 `diff` 一致(全是需 Docker/PostgreSQL 的套件) |
| `npm run build` | 成功;`/` 仍 `○ Static`,`/login` 仍 `ƒ` |
| 首屏 gzip | 未测:改动只在 `/login` 组件内加 3 行判断,`/` 首屏不加载该组件 |
## 环境缺口
- 真机走查(新邮箱 → 固定码 → 直接进首页)留给产品,见 `docs/testing/` 清单。
+1 -1
View File
@@ -402,4 +402,4 @@
| `TASK-consult-plain-answer-20261001.md` | `PROGRESS-consult-plain-answer-20261001.md` | 普通对话「还是废话」:09-17 四步开场形状(格局名 → 谁推谁修 → 扮演哪个象)逼出谜语,问父母时爸妈被揉成一段;产品授权推翻该形状,改成先答 + 按问题里的对象分段 + 人话自检 + 空宫不单独下结论 + 父母卡标 mother/father(BUG-1132~1134) | **已实现,待 Claude 验收**(fork 子代理直接执行);未推 staging、未部署;模型对比为环境缺口,真机清单 `docs/testing/consult-plain-answer-20261001.md` | 分支 `codex/consult-plain-answer-20261001` |
| `TASK-consult-answer-clock-20261001.md` | `PROGRESS-consult-answer-clock-20261001.md` | 普通对话答题时钟 70 s 容不下推理模型(v4-pro 77 s),无出生分钟路线只受 110 s 工具钟管;产品定 5 分钟防卡死(BUG-1142) | **已实现,待 Claude 验收**(直接执行) | 分支 `codex/consult-answer-clock-20261001` |
| `TASK-consult-timeline-no-plan-20261001.md` | `PROGRESS-consult-timeline-no-plan-20261001.md` | 普通对话进度栏提前打勾:回答提纲在算盘前就作为四行步骤显示并全部打勾(BUG-1145);产品选 B 去掉提纲行,只显示真实步骤 | **实现完成,待 Claude 验收**(直接执行) | 分支 `codex/consult-timeline-no-plan-20261001` |
| `TASK-staging-otp-skip-password-20261001.md` | `PROGRESS-staging-otp-skip-password-20261001.md` | staging 新邮箱用固定验证码登录后被强制设置密码,测试者误以为要密码登录;产品选「测试通道验证码登录跳过设置密码」,生产不变(BUG-1152) | **待领取** | 分支 `codex/staging-otp-skip-password-20261001` |
| `TASK-staging-otp-skip-password-20261001.md` | `PROGRESS-staging-otp-skip-password-20261001.md` | staging 新邮箱用固定验证码登录后被强制设置密码,测试者误以为要密码登录;产品选「测试通道验证码登录跳过设置密码」,生产不变(BUG-1152) | **已实现推 staging**(Claude 直接执行),真机清单 `docs/testing/staging-otp-skip-password-20261001.md` | 分支 `codex/staging-otp-skip-password-20261001` |
@@ -0,0 +1,14 @@
# staging 固定验证码登录跳过设置密码 · 真机清单(2026-10-01)
前提:staging 已部署含 BUG-1152 的提交(`/api/health` 的 `deployment.gitCommit` 与之一致)。用一个从没在 staging 登录过的邮箱(可以虚构,不会真的发邮件)。
1. 打开 `https://staging.jyotisha.chat/login`,停在「验证码登录」,勾选协议,填新邮箱,点「发送验证码」。
- 预期:顶部浮层写「测试环境:验证码固定为 xxxxxx,不会发送真实邮件。」
2. 输入浮层里的验证码,提交。
- 预期:直接进入首页;**不出现**「设置登录密码」。
3. 退出登录,再用同一邮箱走一遍第 1–2 步。
- 预期:同样直接进入首页。
4. 退出登录,点「注册账号」,换另一个新邮箱,发送验证码并输入固定码。
- 预期:出现「设置密码 / 确认密码」,这是有意保留的。
5. 生产 `https://jyotisha.chat/login`(只看不登录):
- 预期:发送验证码后的提示是「验证码已发送至 …」,不是固定码提示。
+13 -2
View File
@@ -20,6 +20,15 @@ const AUTH_ERROR = "auth-error";
/** The staging fixed code stays up long enough to type it. */
const TEST_OTP_NOTICE_MS = 10_000;
/**
* Whether a code sign-in still asks for a first password. The staging fixed-code
* channel lets testers in with a fresh mailbox straight away (BUG-1152); choosing
* 注册账号 is asking for a password, so that path keeps the step.
*/
export function offersFirstPasswordStep(testMode: boolean, mode: AuthMode): boolean {
return !(testMode && mode === "otp");
}
function clearAuthNotices() {
dismissNotice(AUTH_NOTICE);
dismissNotice(AUTH_ERROR);
@@ -171,8 +180,10 @@ export function EmailOtpLogin({
notifyInfo("请输入验证器动态码,或使用一枚未使用的恢复码", { id: AUTH_NOTICE, duration: NOTICE_DURATION_MS.error });
return;
}
const hasPassword = await selfHostedAuthActions.hasPassword();
if (!hasPassword) {
if (
offersFirstPasswordStep(testMode, mode)
&& !(await selfHostedAuthActions.hasPassword())
) {
setStep("set-password");
notifySuccess("邮箱验证成功,请设置登录密码", { id: AUTH_NOTICE });
return;
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { offersFirstPasswordStep } from "../src/components/email-otp-login.tsx";
import { createSelfHostedAuthActions } from "../src/modules/identity/client.ts";
test("login page routes the two self-hosted surfaces explicitly", () => {
@@ -284,3 +285,28 @@ test("login UI preserves accessible OTP, password, registration, and reset input
assert.match(route, /provider_id = 'credential'/);
assert.doesNotMatch(route, /update\s+identity\.accounts/i);
});
test("staging fixed-code sign-in skips the first-password step (BUG-1152)", () => {
// Fixed-code channel + 验证码登录: a fresh mailbox goes straight in.
assert.equal(offersFirstPasswordStep(true, "otp"), false);
// 注册账号 asks for a password, fixed code or not.
assert.equal(offersFirstPasswordStep(true, "register"), true);
// Real mail (production): unchanged.
assert.equal(offersFirstPasswordStep(false, "otp"), true);
assert.equal(offersFirstPasswordStep(false, "register"), true);
const component = readFileSync(
new URL("../src/components/email-otp-login.tsx", import.meta.url),
"utf8",
);
const verifyOtp = component.slice(
component.indexOf("async function verifyOtp"),
component.indexOf("async function signInWithPassword"),
);
// Second factor still comes first; the password lookup is only made when the step is offered.
assert.ok(verifyOtp.indexOf("result.twoFactorRequired") < verifyOtp.indexOf("offersFirstPasswordStep"));
assert.match(
verifyOtp,
/offersFirstPasswordStep\(testMode, mode\)\s*&& !\(await selfHostedAuthActions\.hasPassword\(\)\)\s*\) \{\s*setStep\("set-password"\)/,
);
});