11"use client" ;
22
3- import { useEffect , useState } from "react" ;
3+ import { useCallback , useEffect , useState } from "react" ;
44import { useTranslations } from "next-intl" ;
5+ import { useSearchParams } from "next/navigation" ;
56
67type 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 不会静默消失。
1821const 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 */
2749export 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 >
0 commit comments