From 1d8116409d46c828be49ea59229ed26582353c70 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 18:05:52 +0000 Subject: [PATCH 1/2] chore(docs): sync doc metadata [skip ci] --- generated/doc-contributors.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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": [ From ef34372a860b72ed56f975ba59572372d80d1283 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 26 Jul 2026 06:27:50 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix(login):=20=3Ferror=3D=5F=5Fproto=5F=5F?= =?UTF-8?q?=20=E4=BC=9A=E5=B4=A9=E6=8E=89=E6=95=B4=E4=B8=AA=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E9=A1=B5=20+=20=E8=A1=A5=E5=85=A8=20error=20code=20?= =?UTF-8?q?=E6=98=A0=E5=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 线上活跃缺陷:LoginErrorNotice 里 messages[error] 是无保护的原型链查找。 messages["__proto__"] 取到的是 Object.prototype(对象而非 undefined,所以 ?? 兜底不触发),把非字符串交给 React 渲染会抛错;/login 没有 error boundary, 整个登录页被替换成 "Application error",连 GitHub 登录一起不可用。 constructor / toString / valueOf 同理(取到函数)。发条链接即可让人登不了录。 改为显式 code→i18n key 白名单 + Object.hasOwn,未知 code 一律落到通用文案。 顺带(同区域): - 补齐 4 个 error code 的映射。后端实发 discord_canary / oauth_failed / oauth_state / oauth_provider,此前后两者塌缩成"请重试"——而 oauth_state (cookie 被拦或超 300s)重试会无限复现,oauth_provider(服务端缺配置) 再怎么重试都不可能好,给的建议是错的。 - 去掉 messages prop:NextIntlClientProvider 已在 layout 包裹全站,直接 useTranslations 即可,省掉"加一个 error code 要改三个文件"的同步负担。 - 删掉按钮旁叙述当前灰度阶段的注释(CLAUDE.md §3 禁止的任务性注释,且 GA 是 改另一个仓库的 env,没人会回来删它)。 新增 tests/login-error-notice.test.ts(5 条),含 __proto__ 系列回归。 72 测试通过,typecheck / lint 干净,build 表 login 仍为 ● SSG。 --- app/[locale]/login/page.tsx | 10 +----- app/components/LoginErrorNotice.tsx | 36 +++++++++++++------ messages/en.json | 4 ++- messages/zh.json | 4 ++- tests/login-error-notice.test.ts | 56 +++++++++++++++++++++++++++++ 5 files changed, 88 insertions(+), 22 deletions(-) create mode 100644 tests/login-error-notice.test.ts 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")}
,这里把 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/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);
+ }
+ });
+});