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")}

- +
- {/* 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/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 边界,否则整页会被拖成动态渲染 */} + + +