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