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/3] 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/3] =?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);
+ }
+ });
+});
From 21021d5ae7343fdce4fee4cf0ba25ba5c917216d Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Sun, 26 Jul 2026 12:04:40 +0000
Subject: [PATCH 3/3] =?UTF-8?q?feat(settings):=20=E7=99=BB=E5=BD=95?=
=?UTF-8?q?=E6=96=B9=E5=BC=8F=E5=8A=A0"=E8=BF=9E=E6=8E=A5"=E6=8C=89?=
=?UTF-8?q?=E9=92=AE=EF=BC=88M2b=20=E7=BB=91=E5=AE=9A=E5=85=A5=E5=8F=A3?=
=?UTF-8?q?=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
配套后端的 /oauth/bind/{provider}。此前设置页只有"解绑",没有绑定入口——
组件注释自己也写着"现在加会把用户登成新账号(分叉)",因为绑定流程还没做。
现在绑定走 /oauth/bind/{provider}(后端要求已登录,把身份挂到当前账号,
不建号、不换会话),所以按钮可以放出来了。
可绑列表来自新端点 GET /api/user-center/identities/providers,返回后端已注册的
provider——前端不再维护"有哪些登录方式"的写死列表,接入新 provider 时按钮会自动
出现。PROVIDER_LABEL 退化成纯展示名,查不到就显示原始 key,保证新 provider 不会
静默消失(顺带用 Object.hasOwn 查表,避免原型链键)。
绑定回调结果 ?bind=ok / ?bind_error= 给出可区分文案,其中 bind_taken
(该第三方账号已绑到别人)必须说清楚"先去那个账号解绑",否则用户只会看到
一个无法自助解决的失败。
用 Suspense 包裹(新增 useSearchParams)。settings 页改动前后均为 ƒ 动态,
非本次引入。72 测试通过 / typecheck / lint 0 error / build 通过。
---
app/[locale]/settings/LinkedAccounts.tsx | 102 +++++++++++++++++++----
app/[locale]/settings/page.tsx | 7 +-
messages/en.json | 9 +-
messages/zh.json | 9 +-
4 files changed, 106 insertions(+), 21 deletions(-)
diff --git a/app/[locale]/settings/LinkedAccounts.tsx b/app/[locale]/settings/LinkedAccounts.tsx
index 1558bc6..ab64c77 100644
--- a/app/[locale]/settings/LinkedAccounts.tsx
+++ b/app/[locale]/settings/LinkedAccounts.tsx
@@ -1,7 +1,8 @@
"use client";
-import { useEffect, useState } from "react";
+import { useCallback, useEffect, useState } from "react";
import { useTranslations } from "next-intl";
+import { useSearchParams } from "next/navigation";
type LinkedIdentity = {
provider: string;
@@ -15,41 +16,84 @@ function getToken(): string | null {
return localStorage.getItem("satoken");
}
+// 只是展示名。后端 /identities/providers 才是"有哪些登录方式"的真相源,
+// 这里查不到就回退显示原始 key,保证新接入的 provider 不会静默消失。
const PROVIDER_LABEL: Record = {
github: "GitHub",
discord: "Discord",
};
+function labelOf(provider: string): string {
+ return Object.hasOwn(PROVIDER_LABEL, provider)
+ ? PROVIDER_LABEL[provider]!
+ : provider;
+}
+
+// 后端绑定回调会跳回 /settings?bind=ok 或 ?bind_error=
+const BIND_ERROR_KEYS: Record = {
+ bind_taken: "bindTaken",
+ bind_duplicate: "bindDuplicate",
+ bind_already_yours: "bindAlreadyYours",
+ bind_session: "bindSession",
+ oauth_state: "bindFailed",
+ oauth_provider: "bindFailed",
+ oauth_failed: "bindFailed",
+};
+
/**
- * 已绑定登录方式的查看 + 解绑(后端 M2a)。绑定新 provider 走 OAuth 流程,
- * 待绑定流程(M2b)上线后再加"连接"按钮——现在加会把用户登成新账号(分叉)。
+ * 已绑定登录方式的查看 / 绑定(M2b) / 解绑(M2a)。
+ *
+ * 绑定必须走 /oauth/bind/{provider} 而不是普通登录入口:后者会把用户登成新账号
+ * (分叉),而分叉之后那个第三方身份就被新账号占住,本尊再也补绑不回来。
*/
export function LinkedAccounts() {
const t = useTranslations("settings.linked");
const [items, setItems] = useState(null);
+ const [supported, setSupported] = useState([]);
const [error, setError] = useState(null);
+ const [notice, setNotice] = useState(null);
const [busy, setBusy] = useState(null);
+ const params = useSearchParams();
- async function load() {
+ const load = useCallback(async () => {
const token = getToken();
if (!token) return;
try {
- const res = await fetch("/api/user-center/identities", {
- headers: { satoken: token },
- });
- if (!res.ok) throw new Error();
- const body = await res.json();
- setItems(body.data ?? []);
+ const [identities, providers] = await Promise.all([
+ fetch("/api/user-center/identities", { headers: { satoken: token } }),
+ fetch("/api/user-center/identities/providers", {
+ headers: { satoken: token },
+ }),
+ ]);
+ if (!identities.ok) throw new Error();
+ setItems((await identities.json()).data ?? []);
+ if (providers.ok) setSupported((await providers.json()).data ?? []);
setError(null);
} catch {
setError(t("loadFail"));
}
- }
+ }, [t]);
useEffect(() => {
load();
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
+ }, [load]);
+
+ // 绑定回调结果:成功给提示,失败给出可区分的原因("已被别的账号占用"等)
+ useEffect(() => {
+ if (params.get("bind") === "ok") {
+ setNotice(t("bindOk"));
+ return;
+ }
+ const code = params.get("bind_error");
+ if (!code) return;
+ setError(
+ t(
+ Object.hasOwn(BIND_ERROR_KEYS, code)
+ ? BIND_ERROR_KEYS[code]!
+ : "bindFailed",
+ ),
+ );
+ }, [params, t]);
async function unbind(provider: string) {
const token = getToken();
@@ -77,36 +121,58 @@ export function LinkedAccounts() {
if (items === null && !error) return null; // 未登录或加载中,保持安静
+ const bound = items ?? [];
+ const boundKeys = new Set(bound.map((i) => i.provider));
+ const bindable = supported.filter((p) => !boundKeys.has(p));
+
return (
+ {notice && (
+
+ {notice}
+
+ )}
{error && (
{error}
)}
- {(items ?? []).map((it) => (
+ {bound.map((it) => (
-
- {PROVIDER_LABEL[it.provider] ?? it.provider}
+ {labelOf(it.provider)}
{it.displayNameAtLink ? ` · ${it.displayNameAtLink}` : ""}
))}
- {(items ?? []).length === 0 && (
+ {bindable.map((p) => (
+ -
+
+ {labelOf(p)}
+
+
+ {t("connect")}
+
+
+ ))}
+ {bound.length === 0 && bindable.length === 0 && (
-
{t("empty")}
diff --git a/app/[locale]/settings/page.tsx b/app/[locale]/settings/page.tsx
index 7294d24..5b7965a 100644
--- a/app/[locale]/settings/page.tsx
+++ b/app/[locale]/settings/page.tsx
@@ -2,6 +2,7 @@
// 登录态由客户端 SettingsForm 内部的 useAuth 处理:token 存在 localStorage,服务端无法读取,
// 所以这里不做服务端鉴权,仅负责渲染页面壳。未登录 → 客户端 router.replace 到 /login?redirect=/settings。
import type { Metadata } from "next";
+import { Suspense } from "react";
import { Header } from "@/app/components/Header";
import { Footer } from "@/app/components/Footer";
import { SettingsForm } from "./SettingsForm";
@@ -30,7 +31,11 @@ export default function SettingsPage() {
-
+ {/* LinkedAccounts 用 useSearchParams 读绑定回调结果(?bind=ok / ?bind_error=),
+ 必须有 Suspense 边界,否则整页会被拖成动态渲染 */}
+
+
+
diff --git a/messages/en.json b/messages/en.json
index 15ce186..97ad43d 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -106,7 +106,14 @@
"empty": "No linked sign-in methods",
"loadFail": "Failed to load sign-in methods",
"unbindFail": "Unlink failed",
- "lastHint": "This is your only sign-in method and can't be unlinked"
+ "lastHint": "This is your only sign-in method and can't be unlinked",
+ "connect": "Connect",
+ "bindOk": "Linked successfully",
+ "bindTaken": "That account is already linked to a different user. Unlink it there first, then come back.",
+ "bindDuplicate": "You've already linked this sign-in method — one per account.",
+ "bindAlreadyYours": "This sign-in method is already on your account.",
+ "bindSession": "Your session changed during linking. Please click Connect again.",
+ "bindFailed": "Linking failed. Please try again."
}
},
"signIn": {
diff --git a/messages/zh.json b/messages/zh.json
index 6abd326..26f83ce 100644
--- a/messages/zh.json
+++ b/messages/zh.json
@@ -106,7 +106,14 @@
"empty": "暂无绑定的第三方登录",
"loadFail": "加载登录方式失败",
"unbindFail": "解绑失败",
- "lastHint": "这是你唯一的登录方式,不能解绑"
+ "lastHint": "这是你唯一的登录方式,不能解绑",
+ "connect": "连接",
+ "bindOk": "绑定成功",
+ "bindTaken": "该第三方账号已经绑定到另一个账号了。请先登录那个账号解绑,再回来绑定。",
+ "bindDuplicate": "你已经绑定过这种登录方式了,一个账号同一登录方式只能绑一个。",
+ "bindAlreadyYours": "这个登录方式已经在你的账号上了,无需重复绑定。",
+ "bindSession": "绑定过程中登录状态发生了变化,请重新点一次连接。",
+ "bindFailed": "绑定失败,请重试。"
}
},
"signIn": {