Skip to content
Open
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
102 changes: 84 additions & 18 deletions app/[locale]/settings/LinkedAccounts.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -15,41 +16,84 @@ function getToken(): string | null {
return localStorage.getItem("satoken");
}

// 只是展示名。后端 /identities/providers 才是"有哪些登录方式"的真相源,
// 这里查不到就回退显示原始 key,保证新接入的 provider 不会静默消失。
const PROVIDER_LABEL: Record<string, string> = {
github: "GitHub",
discord: "Discord",
};

function labelOf(provider: string): string {
return Object.hasOwn(PROVIDER_LABEL, provider)
? PROVIDER_LABEL[provider]!
: provider;
}

// 后端绑定回调会跳回 /settings?bind=ok 或 ?bind_error=<code>
const BIND_ERROR_KEYS: Record<string, string> = {
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<LinkedIdentity[] | null>(null);
const [supported, setSupported] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(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();
Expand Down Expand Up @@ -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 (
<section className="mt-10">
<label className="block font-serif font-bold text-lg mb-3">
{t("label")}
</label>
{notice && (
<p className="font-mono text-xs text-green-700 dark:text-green-400 mb-3">
{notice}
</p>
)}
{error && (
<p className="font-mono text-xs text-[#CC0000] mb-3">{error}</p>
)}
<ul className="border border-[var(--foreground)] divide-y divide-[var(--foreground)]/20">
{(items ?? []).map((it) => (
{bound.map((it) => (
<li
key={it.provider}
className="flex items-center justify-between px-4 py-3"
>
<span className="font-mono text-sm text-[var(--foreground)]">
{PROVIDER_LABEL[it.provider] ?? it.provider}
{labelOf(it.provider)}
{it.displayNameAtLink ? ` · ${it.displayNameAtLink}` : ""}
</span>
<button
type="button"
onClick={() => unbind(it.provider)}
disabled={busy === it.provider || (items ?? []).length <= 1}
title={(items ?? []).length <= 1 ? t("lastHint") : undefined}
disabled={busy === it.provider || bound.length <= 1}
title={bound.length <= 1 ? t("lastHint") : undefined}
className="font-mono text-xs uppercase tracking-widest text-[var(--foreground)]/70 hover:text-[#CC0000] transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{busy === it.provider ? "…" : t("unbind")}
</button>
</li>
))}
{(items ?? []).length === 0 && (
{bindable.map((p) => (
<li key={p} className="flex items-center justify-between px-4 py-3">
<span className="font-mono text-sm text-[var(--foreground)]/50">
{labelOf(p)}
</span>
<a
href={`/oauth/bind/${p}`}
className="font-mono text-xs uppercase tracking-widest text-[var(--foreground)]/70 hover:text-[var(--foreground)] transition-colors"
>
{t("connect")}
</a>
</li>
))}
{bound.length === 0 && bindable.length === 0 && (
<li className="px-4 py-3 font-mono text-sm text-neutral-500">
{t("empty")}
</li>
Expand Down
7 changes: 6 additions & 1 deletion app/[locale]/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -30,7 +31,11 @@ export default function SettingsPage() {
</p>
</div>
<SettingsForm />
<LinkedAccounts />
{/* LinkedAccounts 用 useSearchParams 读绑定回调结果(?bind=ok / ?bind_error=),
必须有 Suspense 边界,否则整页会被拖成动态渲染 */}
<Suspense fallback={null}>
<LinkedAccounts />
</Suspense>
</div>
</main>
<Footer />
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
13 changes: 11 additions & 2 deletions 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 Expand Up @@ -104,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": {
Expand Down
13 changes: 11 additions & 2 deletions 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 Expand Up @@ -104,7 +106,14 @@
"empty": "暂无绑定的第三方登录",
"loadFail": "加载登录方式失败",
"unbindFail": "解绑失败",
"lastHint": "这是你唯一的登录方式,不能解绑"
"lastHint": "这是你唯一的登录方式,不能解绑",
"connect": "连接",
"bindOk": "绑定成功",
"bindTaken": "该第三方账号已经绑定到另一个账号了。请先登录那个账号解绑,再回来绑定。",
"bindDuplicate": "你已经绑定过这种登录方式了,一个账号同一登录方式只能绑一个。",
"bindAlreadyYours": "这个登录方式已经在你的账号上了,无需重复绑定。",
"bindSession": "绑定过程中登录状态发生了变化,请重新点一次连接。",
"bindFailed": "绑定失败,请重试。"
}
},
"signIn": {
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