Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 1 addition & 9 deletions app/[locale]/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,10 @@ export default async function LoginPage({ params }: Props) {
<p className="text-muted-foreground">{t("subheading")}</p>
</div>
<Suspense fallback={null}>
<LoginErrorNotice
messages={{
discord_canary: t("errorDiscordCanary"),
generic: t("errorGeneric"),
}}
/>
<LoginErrorNotice />
</Suspense>
<div className="flex flex-col items-center gap-3">
<SignInButton provider="github" label={t("github")} />
{/* Discord 登录灰度中:后端按 Discord id 白名单放行(auth.discord.allowlist),
名单外的人点了会被回调弹回 /login?error=discord_canary。GA(OTP wiring 完成、
清空白名单)后此按钮即对所有人开放。 */}
<SignInButton provider="discord" label={t("discord")} />
</div>
</div>
Expand Down
36 changes: 25 additions & 11 deletions app/components/LoginErrorNotice.tsx
Original file line number Diff line number Diff line change
@@ -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>,这里把 code 映射成 i18n key。
//
// 必须是显式白名单 + Object.hasOwn,不能拿 error 直接当对象索引:那样会命中原型链,
// ?error=__proto__ 取到的是 Object.prototype(对象而非 undefined,所以 ?? 兜底不
// 触发),把非字符串交给 React 渲染会抛错;/login 没有 error boundary,整个登录页
// 会被替换成 Application error,连 GitHub 登录一起不可用。
const ERROR_KEYS: Record<string, string> = {
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 路由分类约束)。父级用 <Suspense> 包裹。
// 文案由 server 端 getTranslations 取好后作为 prop 传入,省一套 client i18n provider。
export function LoginErrorNotice({
messages,
}: {
messages: Record<string, string>;
}) {
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 (
<div
role="alert"
className="w-full rounded-md border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-200"
>
{msg}
{t(key)}
</div>
);
}
2 changes: 1 addition & 1 deletion generated/doc-contributors.json
Original file line number Diff line number Diff line change
@@ -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": [
Expand Down
4 changes: 3 additions & 1 deletion messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
4 changes: 3 additions & 1 deletion messages/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,9 @@
"github": "用 GitHub 登录",
"discord": "用 Discord 登录",
"errorDiscordCanary": "Discord 登录正在灰度测试中,暂未对外开放,敬请期待~",
"errorGeneric": "登录未完成,请重试。"
"errorGeneric": "登录未完成,请重试。",
"errorOauthState": "登录会话已失效(可能是等待授权太久,或浏览器拦截了 Cookie)。请重新点击登录。",
"errorOauthProvider": "该登录方式服务端未配置好,重试也无法解决。请联系管理员。"
},
"settings": {
"theme": {
Expand Down
56 changes: 56 additions & 0 deletions tests/login-error-notice.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
Loading