diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx index a22d62d5..db7a6e8b 100644 --- a/app/[locale]/login/page.tsx +++ b/app/[locale]/login/page.tsx @@ -30,8 +30,9 @@ export default async function LoginPage({ params }: Props) {

{t("heading")}

{t("subheading")}

-
- +
+ +
diff --git a/app/[locale]/settings/LinkedAccounts.tsx b/app/[locale]/settings/LinkedAccounts.tsx new file mode 100644 index 00000000..1558bc60 --- /dev/null +++ b/app/[locale]/settings/LinkedAccounts.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; + +type LinkedIdentity = { + provider: string; + displayNameAtLink: string | null; + linkedAt: string | null; + lastLoginAt: string | null; +}; + +function getToken(): string | null { + if (typeof window === "undefined") return null; + return localStorage.getItem("satoken"); +} + +const PROVIDER_LABEL: Record = { + github: "GitHub", + discord: "Discord", +}; + +/** + * 已绑定登录方式的查看 + 解绑(后端 M2a)。绑定新 provider 走 OAuth 流程, + * 待绑定流程(M2b)上线后再加"连接"按钮——现在加会把用户登成新账号(分叉)。 + */ +export function LinkedAccounts() { + const t = useTranslations("settings.linked"); + const [items, setItems] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(null); + + async function load() { + 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 ?? []); + setError(null); + } catch { + setError(t("loadFail")); + } + } + + useEffect(() => { + load(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + async function unbind(provider: string) { + const token = getToken(); + if (!token) return; + setBusy(provider); + try { + const res = await fetch(`/api/user-center/identities/${provider}`, { + method: "DELETE", + headers: { satoken: token }, + }); + const body = await res.json(); + if (!res.ok || body.success === false) { + // 后端把"最后一种登录方式不能解绑"等作为 400 + message 返回 + setError(body.message ?? t("unbindFail")); + return; + } + setItems(body.data ?? []); + setError(null); + } catch { + setError(t("unbindFail")); + } finally { + setBusy(null); + } + } + + if (items === null && !error) return null; // 未登录或加载中,保持安静 + + return ( +
+ + {error && ( +

{error}

+ )} +
    + {(items ?? []).map((it) => ( +
  • + + {PROVIDER_LABEL[it.provider] ?? it.provider} + {it.displayNameAtLink ? ` · ${it.displayNameAtLink}` : ""} + + +
  • + ))} + {(items ?? []).length === 0 && ( +
  • + {t("empty")} +
  • + )} +
+
+ ); +} diff --git a/app/[locale]/settings/page.tsx b/app/[locale]/settings/page.tsx index 8d3b796e..7294d24a 100644 --- a/app/[locale]/settings/page.tsx +++ b/app/[locale]/settings/page.tsx @@ -5,6 +5,7 @@ import type { Metadata } from "next"; import { Header } from "@/app/components/Header"; import { Footer } from "@/app/components/Footer"; import { SettingsForm } from "./SettingsForm"; +import { LinkedAccounts } from "./LinkedAccounts"; // SEO: 设置页仅登录用户相关,不参与搜索索引 export const metadata: Metadata = { @@ -29,6 +30,7 @@ export default function SettingsPage() {

+