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() {
+
diff --git a/app/components/SignInButton.tsx b/app/components/SignInButton.tsx
index 0d030033..0086f5a7 100644
--- a/app/components/SignInButton.tsx
+++ b/app/components/SignInButton.tsx
@@ -4,16 +4,21 @@ import { Button } from "@/app/components/ui/button";
interface SignInButtonProps {
className?: string;
+ // 默认 github(header 处无 props 调用不变);登录页可传 discord。
+ provider?: "github" | "discord";
+ label?: string;
}
-export function SignInButton({ className }: SignInButtonProps) {
- // 同源跳到 /oauth/render/github,经 next.config.mjs 的 rewrite 代理到后端。
- // 好处:开发环境后端端口改来改去(8080 / 8081)都不用改前端;302 由 Next.js 透传给浏览器,
- // 最终由浏览器跳到 GitHub 授权页。
- // 注意:GitHub OAuth app 注册的 callback URL 决定最终返回的前端端口
- // (当前注册为 localhost:3000/api/auth/callback/github),换端口跑本地时需在 GitHub OAuth app 里补一个。
+export function SignInButton({
+ className,
+ provider = "github",
+ label = "SignIn",
+}: SignInButtonProps) {
+ // 同源跳到 /oauth/render/{provider},经 next.config.mjs 的 rewrite 代理到后端。
+ // 好处:开发环境后端端口改来改去都不用改前端;302 由 Next.js 透传给浏览器,
+ // 最终跳到 provider 授权页。各 provider 的 OAuth app 回调 URL 决定返回的前端地址。
const handleSignIn = () => {
- window.location.href = "/oauth/render/github";
+ window.location.href = `/oauth/render/${provider}`;
};
return (
@@ -25,8 +30,9 @@ export function SignInButton({ className }: SignInButtonProps) {
data-umami-event="auth_click"
data-umami-event-action="signin"
data-umami-event-location="header"
+ data-umami-event-provider={provider}
>
- SignIn
+ {label}
);
}
diff --git a/messages/en.json b/messages/en.json
index 52c77608..2d7148b8 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -68,7 +68,9 @@
},
"login": {
"heading": "Sign In",
- "subheading": "Sign in to access protected pages"
+ "subheading": "Sign in to access protected pages",
+ "github": "Sign in with GitHub",
+ "discord": "Sign in with Discord"
},
"settings": {
"theme": {
@@ -93,6 +95,14 @@
"saveSuccess": "Preferences saved",
"saveFail": "Save failed — try again later",
"fetchFail": "Failed to fetch preferences"
+ },
+ "linked": {
+ "label": "Sign-in methods",
+ "unbind": "Unlink",
+ "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"
}
},
"signIn": {
diff --git a/messages/zh.json b/messages/zh.json
index df089a6e..7b935f7a 100644
--- a/messages/zh.json
+++ b/messages/zh.json
@@ -68,7 +68,9 @@
},
"login": {
"heading": "登录",
- "subheading": "请登录以访问受保护的页面"
+ "subheading": "请登录以访问受保护的页面",
+ "github": "用 GitHub 登录",
+ "discord": "用 Discord 登录"
},
"settings": {
"theme": {
@@ -93,6 +95,14 @@
"saveSuccess": "偏好设置已保存",
"saveFail": "保存失败,请稍后重试",
"fetchFail": "获取偏好失败"
+ },
+ "linked": {
+ "label": "登录方式",
+ "unbind": "解绑",
+ "empty": "暂无绑定的第三方登录",
+ "loadFail": "加载登录方式失败",
+ "unbindFail": "解绑失败",
+ "lastHint": "这是你唯一的登录方式,不能解绑"
}
},
"signIn": {
diff --git a/next.config.mjs b/next.config.mjs
index f6ac6aa6..4107fdb0 100644
--- a/next.config.mjs
+++ b/next.config.mjs
@@ -357,10 +357,11 @@ const config = {
const backendUrl = process.env.BACKEND_URL ?? "http://localhost:8080";
return [
{
- // GitHub OAuth 回调:GitHub → localhost:3000/api/auth/callback/github → 后端
- // 路径与 GitHub OAuth App 注册的 callback URL 保持一致,无需改 GitHub App 设置
- source: "/api/auth/callback/github",
- destination: `${backendUrl}/api/auth/callback/github`,
+ // OAuth 回调代理到后端。:provider 覆盖 github / discord / …,路径与各家
+ // OAuth App 注册的 callback URL 保持一致(后端按 {provider} 分发)。
+ // 之前硬编码只有 github,Discord 回调 /api/auth/callback/discord 会漏代理 → 404。
+ source: "/api/auth/callback/:provider",
+ destination: `${backendUrl}/api/auth/callback/:provider`,
},
{
// 认证 API(/auth/me, /auth/logout 等)走 Next.js 代理,避免浏览器跨域 CORS 问题