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/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);
+ }
+ });
+});