diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx index 67a9a04..a8aa037 100644 --- a/app/[locale]/login/page.tsx +++ b/app/[locale]/login/page.tsx @@ -33,18 +33,10 @@ export default async function LoginPage({ params }: Props) {

{t("subheading")}

- +
- {/* Discord 登录灰度中:后端按 Discord id 白名单放行(auth.discord.allowlist), - 名单外的人点了会被回调弹回 /login?error=discord_canary。GA(OTP wiring 完成、 - 清空白名单)后此按钮即对所有人开放。 */}
diff --git a/app/components/LoginErrorNotice.tsx b/app/components/LoginErrorNotice.tsx index bc3d3d0..9ac12d0 100644 --- a/app/components/LoginErrorNotice.tsx +++ b/app/components/LoginErrorNotice.tsx @@ -1,25 +1,39 @@ "use client"; +import { useTranslations } from "next-intl"; import { useSearchParams } from "next/navigation"; -// 登录页顶端的错误提示。后端 OAuth 回调失败会 302 到 /login?error=xxx。 +// 后端 OAuth 回调失败会 302 到 /login?error=,这里把 code 映射成 i18n key。 +// +// 必须是显式白名单 + Object.hasOwn,不能拿 error 直接当对象索引:那样会命中原型链, +// ?error=__proto__ 取到的是 Object.prototype(对象而非 undefined,所以 ?? 兜底不 +// 触发),把非字符串交给 React 渲染会抛错;/login 没有 error boundary,整个登录页 +// 会被替换成 Application error,连 GitHub 登录一起不可用。 +const ERROR_KEYS: Record = { + discord_canary: "errorDiscordCanary", + oauth_state: "errorOauthState", + oauth_provider: "errorOauthProvider", + oauth_failed: "errorGeneric", +}; + +/** null 表示不显示提示。未知 code 一律落到通用文案,绝不透传原值。 */ +export function resolveErrorKey(error: string | null): string | null { + if (!error) return null; + return Object.hasOwn(ERROR_KEYS, error) ? ERROR_KEYS[error]! : "errorGeneric"; +} + // 用 useSearchParams(客户端读 query)而非 server 读 searchParams,避免整页退回 // 动态渲染(登录页要保持 SSG,见 CLAUDE.md 路由分类约束)。父级用 包裹。 -// 文案由 server 端 getTranslations 取好后作为 prop 传入,省一套 client i18n provider。 -export function LoginErrorNotice({ - messages, -}: { - messages: Record; -}) { - const error = useSearchParams().get("error"); - if (!error) return null; - const msg = messages[error] ?? messages.generic; +export function LoginErrorNotice() { + const t = useTranslations("login"); + const key = resolveErrorKey(useSearchParams().get("error")); + if (!key) return null; return (
- {msg} + {t(key)}
); } diff --git a/generated/doc-contributors.json b/generated/doc-contributors.json index af4a834..a636962 100644 --- a/generated/doc-contributors.json +++ b/generated/doc-contributors.json @@ -1,6 +1,6 @@ { "repo": "InvolutionHell/involutionhell", - "generatedAt": "2026-07-24T13:55:10.610Z", + "generatedAt": "2026-07-25T18:05:19.341Z", "docsDir": "content/docs", "totalDocs": 144, "results": [ diff --git a/messages/en.json b/messages/en.json index 484d1d1..15ce186 100644 --- a/messages/en.json +++ b/messages/en.json @@ -72,7 +72,9 @@ "github": "Sign in with GitHub", "discord": "Sign in with Discord", "errorDiscordCanary": "Discord sign-in is in limited testing and not open yet — stay tuned!", - "errorGeneric": "Sign-in didn't complete. Please try again." + "errorGeneric": "Sign-in didn't complete. Please try again.", + "errorOauthState": "Your sign-in session expired (took too long on the provider's page, or cookies were blocked). Please start the sign-in again.", + "errorOauthProvider": "This sign-in method isn't configured on the server — retrying won't help. Please contact an admin." }, "settings": { "theme": { diff --git a/messages/zh.json b/messages/zh.json index 7355d94..6abd326 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -72,7 +72,9 @@ "github": "用 GitHub 登录", "discord": "用 Discord 登录", "errorDiscordCanary": "Discord 登录正在灰度测试中,暂未对外开放,敬请期待~", - "errorGeneric": "登录未完成,请重试。" + "errorGeneric": "登录未完成,请重试。", + "errorOauthState": "登录会话已失效(可能是等待授权太久,或浏览器拦截了 Cookie)。请重新点击登录。", + "errorOauthProvider": "该登录方式服务端未配置好,重试也无法解决。请联系管理员。" }, "settings": { "theme": { diff --git a/tests/login-error-notice.test.ts b/tests/login-error-notice.test.ts new file mode 100644 index 0000000..c02ba2a --- /dev/null +++ b/tests/login-error-notice.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import enMessages from "../messages/en.json"; +import zhMessages from "../messages/zh.json"; +import { resolveErrorKey } from "../app/components/LoginErrorNotice"; + +// 后端 OAuthController 实际会重定向的全部 error code。新增 code 时两边一起加, +// 否则用户只会看到通用的"请重试"(对 oauth_provider 这类重试无用的错误是误导)。 +const BACKEND_ERROR_CODES = [ + "discord_canary", + "oauth_failed", + "oauth_state", + "oauth_provider", +]; + +describe("resolveErrorKey", () => { + it("没有 error 参数时不显示提示", () => { + expect(resolveErrorKey(null)).toBeNull(); + expect(resolveErrorKey("")).toBeNull(); + }); + + it("后端每个 error code 都有专属文案,不塌缩成通用提示", () => { + for (const code of BACKEND_ERROR_CODES) { + const key = resolveErrorKey(code); + expect(key, `${code} 应有映射`).not.toBeNull(); + if (code !== "oauth_failed") { + expect(key, `${code} 不该塌缩成通用文案`).not.toBe("errorGeneric"); + } + } + }); + + // 回归:直接用 error 索引对象会命中原型链,?error=__proto__ 取到 Object.prototype + // (非 undefined,?? 兜底不触发),渲染非字符串会让整个登录页崩掉。 + it("原型链上的键不会逃逸,一律落到通用文案", () => { + for (const key of [ + "__proto__", + "constructor", + "toString", + "valueOf", + "hasOwnProperty", + ]) { + expect(resolveErrorKey(key), `${key} 必须被兜住`).toBe("errorGeneric"); + } + }); + + it("未知 code 落到通用文案", () => { + expect(resolveErrorKey("wat")).toBe("errorGeneric"); + }); + + it("解析出的 key 在 zh/en 里都存在", () => { + for (const code of [...BACKEND_ERROR_CODES, "__proto__", "wat"]) { + const key = resolveErrorKey(code)!; + expect(zhMessages.login, `zh 缺 ${key}`).toHaveProperty(key); + expect(enMessages.login, `en 缺 ${key}`).toHaveProperty(key); + } + }); +});