Skip to content

fix(console): a mobile hand-off sign-up's verification mail lands on "go back to the app", and the refusal time survives /verify-email-prompt - #30243

Draft
objectstack-fleet[bot] wants to merge 1 commit into
mainfrom
claude/project-thread-yz3rkx
Draft

objectstack-fleet[bot] wants to merge 1 commit into
mainfrom
claude/project-thread-yz3rkx

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Yibo Wang · project thread

Fixes #30157

维护者速读

  • 改了什么:移动端登录(hand-off)途中注册、服务器要求验证邮箱时,验证邮件的链接不再指向 /_console/api/v1/auth/one-time-token/handoff,而是落到 Console 的「邮箱已确认,请回到 App 登录」页(/verify-email?handoff=verified),在打开邮件的浏览器里不发登录令牌;同时 handoff_refused_at 在经过 /verify-email-prompt 的三跳上不再丢失。
  • 为什么改:实测(dev boot):打开邮件的浏览器已登录任何账号时落到「Page not found」;未登录时被 Console 的登录守卫碰巧带到带 hand-off 的登录页。邮件通常不在 App 等待的系统登录窗口里打开,常常在另一台设备上,所以落地页改为「回 App 登录」。落地页由用户在 2026-11-05 03:06Z 的决策卡上选定(「回 App 提示页」)。
  • 风险与回滚:只有 hand-off redirect 换落地页;邀请等其他 redirect 的邮件链接与三跳链接逐字节不变(对照测试钉住);服务端路由、令牌发放、hand-off 端点一行未动。回滚即 revert。
  • 先看:ui/apps/console/src/pages/auth/handoffRedirect.ts(新的 verificationCallbackURL 与规则 6)、VerifyEmailPage.tsx(无 token 的 handoff=verified 落地分支)、RegisterPage.tsx / VerifyEmailPromptPage.tsx 的两处 callbackURL。
  • 两分钟验证:pnpm --dir ui exec vitest run apps/console/src/pages/auth/__tests__/handoffVerificationMail-30157.test.tsx,预期 1 个文件、11 条全部通过。
  • 需要你做的:这是会话转移(auth)流程,请维护者审阅并合并本 PR。

Clause-②: no
Metadata-protocol: none

What changes

  • ui/apps/console/src/pages/auth/handoffRedirect.ts: a new rule 6 in the header ("not from the verification mail") and verificationCallbackURL(redirect): a hand-off redirect (the existing handoffNavigationTarget matcher, unchanged) returns to /verify-email?handoff=verified inside the Console mount; any other safe in-app redirect is resolved with withConsoleBaseRootRelative exactly as before; an unsafe or missing one gives no callbackURL, as before. withConsoleBaseRootRelative itself is untouched.
  • RegisterPage.tsx (the sign-up mail) and VerifyEmailPromptPage.tsx (the "Resend" mail) build their callbackURL through it.
  • VerifyEmailPage.tsx: with no token and ?handoff=verified, the server has already verified the address (better-auth 302s to the callback after GET /verify-email), so the page spends no token of its own and shows "Email verified" with "Go back to the app you were signing in to, and sign in there", and no Console sign-in link. better-auth appends error=CODE to the callback for a bad or expired token; the page then shows its existing failure state. Without the mark, a token-less visit still reports the missing token.
  • The refusal time: handoffMarkQuery(redirect) is appended on LoginPage's EMAIL_NOT_VERIFIED navigation, RegisterPage's onVerificationRequired navigation and VerifyEmailPromptPage.handleBackToLogin. It is empty unless the redirect names the hand-off and the page carries a mark, so every other link is unchanged.
  • @object-ui/i18n: auth.verifyEmail.handoffSuccessDescription in all ten locales.
  • Changeset: @object-ui/console and @object-ui/i18n, patch.

No server file moves: no auth route, no token issuance, no change to the hand-off endpoint.

The landing, and what it does for a person who opens the mail elsewhere

Three landings were defensible, with different results for the person; the project's user chose this one on a decision card (2026-11-05 03:06Z, option 「回 App 提示页」, "back-to-the-app page"):

  • Chosen: "go back to the app". The link verifies the address and lands on /verify-email?handoff=verified: "Email verified. Go back to the app you were signing in to, and sign in there." Whatever browser or device opens the mail (a desktop browser, Safari while the app waits in an ASWebAuthenticationSession, a browser signed in as someone else), the person sees that page and no sign-in token is handed out there. Back in the app's waiting window, which still shows /verify-email-prompt, "Back to login" (now carrying the route's mark) and one sign-in finish the hand-off. This keeps the route's own rule that every hand-off follows a full sign-in in the system auth session.
  • Not chosen: return to /login carrying the hand-off, so the mail browser signs in and is forwarded to the route. Smoother on Android, where Custom Tabs share Chrome's cookies and the scheme opens the app by intent; on iOS it relies on the app taking a cold scheme open, and on another device it ends at a scheme nothing opens. (This is what a signed-out mail browser got before this PR, by way of the SPA's sign-in guard.)
  • Not chosen: plain Console /login with no hand-off, which signs the person into the web Console instead of the app.

Tests

New pins: handoffVerificationMail-30157.test.tsx (11 tests)

The real AuthProvider over a mock client, the Console mounted as the shipped embeddable build (base href /_console/, BASE_URL ./); callbackURL is read off the client call that goes on the wire.

  • The mail link: the sign-up mail's callbackURL is /_console/verify-email?handoff=verified; the resent mail's too. CONTROL: an invitation redirect keeps /_console/accept-invitation/inv_1 on both mails.
  • One pin per hop, with the mark &handoff_refused_at=1793620800000 on the page and a hand-off redirect: /login EMAIL_NOT_VERIFIED, /register verification required, "Back to login" each carry it. CONTROL: beside an invitation redirect the three navigations are byte for byte today's; beside a hand-off redirect with no mark on the page they are today's too.
  • The landing: ?handoff=verified shows the confirmed state, no "Go to sign in" link, and no /verify-email fetch; &error=INVALID_TOKEN shows the failure state; CONTROL: no token and no mark still reports the missing token.

Run: pnpm --dir ui exec vitest run --maxWorkers=2 apps/console/src/pages/auth/__tests__/handoffVerificationMail-30157.test.tsx → Test Files 1 passed, Tests 11 passed.

Ablation

The fix committed first (9d2f99aa), then the five Console source files checked out from origin/main inside a script with a restore trap; landing proved by grep -c (verificationCallbackURL 0 hits in handoffRedirect.ts, handoffMarkQuery 0 hits in VerifyEmailPromptPage.tsx). Result: Tests 6 failed, 5 passed: the two mail pins, the three hop pins and the landing pin went red; the controls stayed green (as they should: they assert today's behaviour). Restore by git checkout HEAD --; git diff HEAD empty and each file's hash equals its HEAD blob. No build in the loop: the Console tests import these files from source.

Verification

Browser walk on a dev boot (shots in the project's ui-orders/30157-shots)

examples/app-crm, pnpm exec objectstack dev --fresh, OS_TRUSTED_APP_SCHEMES=ai.objectos.mobile, OS_AUTH_REQUIRE_EMAIL_VERIFICATION=true, audience posture open (settings auth), log mail provider (the mail read from sys_identity_email). Headless Chromium: window A opens the hand-off route (302 to /_console/login?redirect=…&handoff_refused_at=…), follows "Sign up", registers; window B (a separate browser context) opens the mail's link; A then presses "Back to login" and signs in.

Before (Console built from origin/main dfafae813):

  • The mail's link: callbackURL=/_console/api/v1/auth/one-time-token/handoff?redirect_uri=….
  • B signed in as any account: 302 from /verify-email, then the Console's "Page not found" (before-signedin-3-mail-lands.png).
  • B signed out (the stock case: os serve cannot turn on autoSignInAfterVerification): the SPA's sign-in guard sends it to /_console/login?redirect=/api/v1/auth/one-time-token/handoff…, so the mail browser is offered the hand-off by accident (before-3-mail-lands.png).
  • /verify-email-prompt and "Back to login" carry no handoff_refused_at (before-walk.json, steps 3 and 6).

After (this branch's Console):

  • The mail's link: callbackURL=/_console/verify-email?handoff=verified.
  • B signed out and B signed in both land on "Email verified. Go back to the app you were signing in to, and sign in there." (after-3-mail-lands.png, after-signedin-3-mail-lands.png).
  • The prompt and "Back to login" carry handoff_refused_at (after-walk.json, steps 3 and 6); signing in on A forwards to the route, which answers 302 ai.objectos.mobile://auth/callback?token=… (step 7).
Gates (local, head 9d2f99a, one locked run, 40 commands, every one exit 0)
  • Card checks: pnpm --dir ui exec vitest run apps/console/src/pages/auth/ (Test Files 28 passed, Tests 228 passed); pnpm --dir ui exec vitest run packages/i18n/ (95 files, 1443 passed, 13 skipped); pnpm --filter @object-ui/console run type-check; pnpm --filter @object-ui/i18n run type-check; eslint on the six changed Console files.
  • node scripts/check-sdui-manifest.mjs and its --self-test: exit 0.
  • All 35 families node scripts/pm/dispatch-gates.mjs --commands derives for this diff: exit 0 each (list in the project's ui-orders/30157-gates-ran.list). --ran reconciliation: "35 derived famil(ies) accounted for, 35 run, 0 NOT-MEASURED".
  • The repo-wide lint and Test Core / Test UI shards are CI's.

Acceptance notes

  • Premise, measured: the dead end ("Page not found") appears only when the browser that opens the mail holds a Console session. A signed-out browser reached a sign-in page carrying the hand-off, by way of the SPA's guard rather than by design. Both now land on the same page.
  • autoSignInAfterVerification is not reachable on a stock os serve; on a host that turns it on, the mail browser gets a session for the new account and still lands on the same page, which offers no Console link.
  • The waiting window is not told that the address was verified elsewhere; the person returns to it and presses "Back to login" (which now keeps the mark), as before.

…ack to the app", and the refusal time survives /verify-email-prompt

The sign-up and resend mails sent a mobile sign-in hand-off redirect through
withConsoleBaseRootRelative, so the link landed on
/_console/api/v1/auth/one-time-token/handoff (page not found). A hand-off
redirect now returns to /verify-email?handoff=verified, which says the
address is confirmed and to go back to the app; every other redirect keeps
its callbackURL byte for byte.

handoff_refused_at is now carried on the three hops through
/verify-email-prompt: /login's EMAIL_NOT_VERIFIED branch, /register's
verification-required branch, and "Back to login".

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzgzSz4DH8R8v1yq6bm
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tooling labels Nov 5, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:ui#4 · os-support-ai · session_01WGufzinedUGSXteN45poyo

维护者速读

  • 改了什么:移动端登录(hand-off)途中注册、服务器要求验证邮箱时,验证邮件链接改落到「邮箱已确认,请回到 App 登录」页(/verify-email?handoff=verified),不在打开邮件的浏览器发令牌;handoff_refused_at 在经过验证提示页的三跳上不再丢失。
  • 为什么改:以前链接指向不存在的页面(已登录浏览器)或碰巧走进带交接的登录页(未登录浏览器);落地页按你 03:06Z 在决策卡上选的「回 App 提示页」实现(Mobile sign-in: a person who has to verify their email during the hand-off gets a mail link to 页面不存在, and the verify page drops the refusal time #30157)。
  • 风险与回滚:只有交接 redirect 换落地页;邀请等其他 redirect 的邮件链接和三跳链接逐字节不变(对照测试钉住);服务端路由、令牌发放、交接端点一行未动。回滚即 revert。
  • 先看:handoffRedirect.ts 的 verificationCallbackURL,VerifyEmailPage.tsx 的无 token 落地分支。
  • 两分钟验证:pnpm --dir ui exec vitest run apps/console/src/pages/auth/__tests__/handoffVerificationMail-30157.test.tsx(11 条全绿)。
  • 需要你做的:登录会话转移流程,按分诊要求由你审阅并合并;CI 全绿后我会再提醒一次。

席位意见:ACCEPT。实现与裁决 A 一致,改动面小,安全判据与旧代码逐字等价。

席位核对

ACCEPT → PR #30243 @ 9d2f99aaba26ca8a01793e263cc9068bc1ebb55a (card #30157, claim 6647429022, ruling recorded on the card).

  • Shape: draft, base main, Fixes #30157 (merge should close the card: both halves ship). 17 files, +334/−30 = 364 changed lines; check-governed-merges --pr 30243: NOT governed (0 of 17 paths). Human floor by triage 6637932107: session-transfer flow, the maintainer reviews and merges — the seat does not ready or queue it on its own.
  • Ruling: A (「回 App 提示页」, maintainer's decision card, 2026-11-05T03:06Z). The diff implements A: verificationCallbackURL(redirect) returns /verify-email?handoff=verified (Console mount) for a redirect handoffNavigationTarget recognises, else withConsoleBaseRootRelative(redirect) as before; VerifyEmailPage with no token and handoff=verified spends no token, shows success (or the failure state when the server appended error), and draws no Console sign-in link.
  • Premise, measured: dev boot on main dfafae81 (shots 30157-shots/before-*): signed-in mail browser → 页面不存在; signed-out → SPA guard to /login?redirect=<hand-off>; the three hops dropped the mark (walk json). After (after-*, seat read after-signedin-3-mail-lands.png): both land on "Email verified … Go back to the app".
  • Seat diff read: the safety predicate is unchanged — verificationCallbackURL's startsWith('/') && !startsWith('//') equals RegisterPage's isSafeRedirect and the prompt page's old inline test; the three hops append handoffMarkQuery(redirect), which is empty unless the redirect names the hand-off and the page carries a mark. A ?handoff=verified visit without a token changes no state (text only).
  • Gates: dev local (report 6648608120): 11/11 new tests (one pin per hop + mail link + controls), ablation 6 pins red / 5 controls green, 40 local gates exit 0, --ran 35/35. CI on this head: in progress at this record (11 success, 11 skipped, 30 running, 0 red); the hand-over waits for every check green or expected-skip.
  • Scope: changeset names @object-ui/console and @object-ui/i18n (patch); new string auth.verifyEmail.handoffSuccessDescription in ten locales. Clause-②: no, Metadata-protocol: none, Skills-docs: not owed. No out-of-scope findings.
  • Landing: maintainer reviews and merges (human floor). On MERGED: landing note on Mobile sign-in: a person who has to verify their email during the hand-off gets a mail link to 页面不存在, and the verify page drops the refusal time #30157, remove pm:dispatched.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tooling

Projects

None yet

1 participant