Skip to content

Commit 74b12fe

Browse files
feat(settings): 登录方式加"连接"按钮(M2b 绑定入口)
配套后端的 /oauth/bind/{provider}。此前设置页只有"解绑",没有绑定入口—— 组件注释自己也写着"现在加会把用户登成新账号(分叉)",因为绑定流程还没做。 现在绑定走 /oauth/bind/{provider}(后端要求已登录,把身份挂到当前账号, 不建号、不换会话),所以按钮可以放出来了。 可绑列表来自新端点 GET /api/user-center/identities/providers,返回后端已注册的 provider——前端不再维护"有哪些登录方式"的写死列表,接入新 provider 时按钮会自动 出现。PROVIDER_LABEL 退化成纯展示名,查不到就显示原始 key,保证新 provider 不会 静默消失(顺带用 Object.hasOwn 查表,避免原型链键)。 绑定回调结果 ?bind=ok / ?bind_error=<code> 给出可区分文案,其中 bind_taken (该第三方账号已绑到别人)必须说清楚"先去那个账号解绑",否则用户只会看到 一个无法自助解决的失败。 用 Suspense 包裹(新增 useSearchParams)。settings 页改动前后均为 ƒ 动态, 非本次引入。72 测试通过 / typecheck / lint 0 error / build 通过。
1 parent 63da533 commit 74b12fe

4 files changed

Lines changed: 106 additions & 21 deletions

File tree

‎app/[locale]/settings/LinkedAccounts.tsx‎

Lines changed: 84 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
"use client";
22

3-
import { useEffect, useState } from "react";
3+
import { useCallback, useEffect, useState } from "react";
44
import { useTranslations } from "next-intl";
5+
import { useSearchParams } from "next/navigation";
56

67
type LinkedIdentity = {
78
provider: string;
@@ -15,41 +16,84 @@ function getToken(): string | null {
1516
return localStorage.getItem("satoken");
1617
}
1718

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

26+
function labelOf(provider: string): string {
27+
return Object.hasOwn(PROVIDER_LABEL, provider)
28+
? PROVIDER_LABEL[provider]!
29+
: provider;
30+
}
31+
32+
// 后端绑定回调会跳回 /settings?bind=ok 或 ?bind_error=<code>
33+
const BIND_ERROR_KEYS: Record<string, string> = {
34+
bind_taken: "bindTaken",
35+
bind_duplicate: "bindDuplicate",
36+
bind_already_yours: "bindAlreadyYours",
37+
bind_session: "bindSession",
38+
oauth_state: "bindFailed",
39+
oauth_provider: "bindFailed",
40+
oauth_failed: "bindFailed",
41+
};
42+
2343
/**
24-
* 已绑定登录方式的查看 + 解绑(后端 M2a)。绑定新 provider 走 OAuth 流程,
25-
* 待绑定流程(M2b)上线后再加"连接"按钮——现在加会把用户登成新账号(分叉)。
44+
* 已绑定登录方式的查看 / 绑定(M2b) / 解绑(M2a)。
45+
*
46+
* 绑定必须走 /oauth/bind/{provider} 而不是普通登录入口:后者会把用户登成新账号
47+
* (分叉),而分叉之后那个第三方身份就被新账号占住,本尊再也补绑不回来。
2648
*/
2749
export function LinkedAccounts() {
2850
const t = useTranslations("settings.linked");
2951
const [items, setItems] = useState<LinkedIdentity[] | null>(null);
52+
const [supported, setSupported] = useState<string[]>([]);
3053
const [error, setError] = useState<string | null>(null);
54+
const [notice, setNotice] = useState<string | null>(null);
3155
const [busy, setBusy] = useState<string | null>(null);
56+
const params = useSearchParams();
3257

33-
async function load() {
58+
const load = useCallback(async () => {
3459
const token = getToken();
3560
if (!token) return;
3661
try {
37-
const res = await fetch("/api/user-center/identities", {
38-
headers: { satoken: token },
39-
});
40-
if (!res.ok) throw new Error();
41-
const body = await res.json();
42-
setItems(body.data ?? []);
62+
const [identities, providers] = await Promise.all([
63+
fetch("/api/user-center/identities", { headers: { satoken: token } }),
64+
fetch("/api/user-center/identities/providers", {
65+
headers: { satoken: token },
66+
}),
67+
]);
68+
if (!identities.ok) throw new Error();
69+
setItems((await identities.json()).data ?? []);
70+
if (providers.ok) setSupported((await providers.json()).data ?? []);
4371
setError(null);
4472
} catch {
4573
setError(t("loadFail"));
4674
}
47-
}
75+
}, [t]);
4876

4977
useEffect(() => {
5078
load();
51-
// eslint-disable-next-line react-hooks/exhaustive-deps
52-
}, []);
79+
}, [load]);
80+
81+
// 绑定回调结果:成功给提示,失败给出可区分的原因("已被别的账号占用"等)
82+
useEffect(() => {
83+
if (params.get("bind") === "ok") {
84+
setNotice(t("bindOk"));
85+
return;
86+
}
87+
const code = params.get("bind_error");
88+
if (!code) return;
89+
setError(
90+
t(
91+
Object.hasOwn(BIND_ERROR_KEYS, code)
92+
? BIND_ERROR_KEYS[code]!
93+
: "bindFailed",
94+
),
95+
);
96+
}, [params, t]);
5397

5498
async function unbind(provider: string) {
5599
const token = getToken();
@@ -77,36 +121,58 @@ export function LinkedAccounts() {
77121

78122
if (items === null && !error) return null; // 未登录或加载中,保持安静
79123

124+
const bound = items ?? [];
125+
const boundKeys = new Set(bound.map((i) => i.provider));
126+
const bindable = supported.filter((p) => !boundKeys.has(p));
127+
80128
return (
81129
<section className="mt-10">
82130
<label className="block font-serif font-bold text-lg mb-3">
83131
{t("label")}
84132
</label>
133+
{notice && (
134+
<p className="font-mono text-xs text-green-700 dark:text-green-400 mb-3">
135+
{notice}
136+
</p>
137+
)}
85138
{error && (
86139
<p className="font-mono text-xs text-[#CC0000] mb-3">{error}</p>
87140
)}
88141
<ul className="border border-[var(--foreground)] divide-y divide-[var(--foreground)]/20">
89-
{(items ?? []).map((it) => (
142+
{bound.map((it) => (
90143
<li
91144
key={it.provider}
92145
className="flex items-center justify-between px-4 py-3"
93146
>
94147
<span className="font-mono text-sm text-[var(--foreground)]">
95-
{PROVIDER_LABEL[it.provider] ?? it.provider}
148+
{labelOf(it.provider)}
96149
{it.displayNameAtLink ? ` · ${it.displayNameAtLink}` : ""}
97150
</span>
98151
<button
99152
type="button"
100153
onClick={() => unbind(it.provider)}
101-
disabled={busy === it.provider || (items ?? []).length <= 1}
102-
title={(items ?? []).length <= 1 ? t("lastHint") : undefined}
154+
disabled={busy === it.provider || bound.length <= 1}
155+
title={bound.length <= 1 ? t("lastHint") : undefined}
103156
className="font-mono text-xs uppercase tracking-widest text-[var(--foreground)]/70 hover:text-[#CC0000] transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
104157
>
105158
{busy === it.provider ? "…" : t("unbind")}
106159
</button>
107160
</li>
108161
))}
109-
{(items ?? []).length === 0 && (
162+
{bindable.map((p) => (
163+
<li key={p} className="flex items-center justify-between px-4 py-3">
164+
<span className="font-mono text-sm text-[var(--foreground)]/50">
165+
{labelOf(p)}
166+
</span>
167+
<a
168+
href={`/oauth/bind/${p}`}
169+
className="font-mono text-xs uppercase tracking-widest text-[var(--foreground)]/70 hover:text-[var(--foreground)] transition-colors"
170+
>
171+
{t("connect")}
172+
</a>
173+
</li>
174+
))}
175+
{bound.length === 0 && bindable.length === 0 && (
110176
<li className="px-4 py-3 font-mono text-sm text-neutral-500">
111177
{t("empty")}
112178
</li>

‎app/[locale]/settings/page.tsx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// 登录态由客户端 SettingsForm 内部的 useAuth 处理:token 存在 localStorage,服务端无法读取,
33
// 所以这里不做服务端鉴权,仅负责渲染页面壳。未登录 → 客户端 router.replace 到 /login?redirect=/settings。
44
import type { Metadata } from "next";
5+
import { Suspense } from "react";
56
import { Header } from "@/app/components/Header";
67
import { Footer } from "@/app/components/Footer";
78
import { SettingsForm } from "./SettingsForm";
@@ -30,7 +31,11 @@ export default function SettingsPage() {
3031
</p>
3132
</div>
3233
<SettingsForm />
33-
<LinkedAccounts />
34+
{/* LinkedAccounts 用 useSearchParams 读绑定回调结果(?bind=ok / ?bind_error=),
35+
必须有 Suspense 边界,否则整页会被拖成动态渲染 */}
36+
<Suspense fallback={null}>
37+
<LinkedAccounts />
38+
</Suspense>
3439
</div>
3540
</main>
3641
<Footer />

‎messages/en.json‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,14 @@
106106
"empty": "No linked sign-in methods",
107107
"loadFail": "Failed to load sign-in methods",
108108
"unbindFail": "Unlink failed",
109-
"lastHint": "This is your only sign-in method and can't be unlinked"
109+
"lastHint": "This is your only sign-in method and can't be unlinked",
110+
"connect": "Connect",
111+
"bindOk": "Linked successfully",
112+
"bindTaken": "That account is already linked to a different user. Unlink it there first, then come back.",
113+
"bindDuplicate": "You've already linked this sign-in method — one per account.",
114+
"bindAlreadyYours": "This sign-in method is already on your account.",
115+
"bindSession": "Your session changed during linking. Please click Connect again.",
116+
"bindFailed": "Linking failed. Please try again."
110117
}
111118
},
112119
"signIn": {

‎messages/zh.json‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,14 @@
106106
"empty": "暂无绑定的第三方登录",
107107
"loadFail": "加载登录方式失败",
108108
"unbindFail": "解绑失败",
109-
"lastHint": "这是你唯一的登录方式,不能解绑"
109+
"lastHint": "这是你唯一的登录方式,不能解绑",
110+
"connect": "连接",
111+
"bindOk": "绑定成功",
112+
"bindTaken": "该第三方账号已经绑定到另一个账号了。请先登录那个账号解绑,再回来绑定。",
113+
"bindDuplicate": "你已经绑定过这种登录方式了,一个账号同一登录方式只能绑一个。",
114+
"bindAlreadyYours": "这个登录方式已经在你的账号上了,无需重复绑定。",
115+
"bindSession": "绑定过程中登录状态发生了变化,请重新点一次连接。",
116+
"bindFailed": "绑定失败,请重试。"
110117
}
111118
},
112119
"signIn": {

0 commit comments

Comments
 (0)