Skip to content

Commit 25baf9d

Browse files
feat(auth): 前端接入 Discord 登录 + 设置页登录方式管理
配合后端 Discord provider(involutionhell-backend#48): - next.config.mjs:OAuth 回调 rewrite 从硬编码 /api/auth/callback/github 泛化为 /api/auth/callback/:provider——否则 Discord 回调漏代理 → 404(登录跑不通的阻断项) - 登录页加"用 Discord 登录"按钮;SignInButton 参数化 provider(header 处默认 github 不变) - 设置页加"登录方式"区:列出已绑定身份 + 解绑(后端 M2a)。最后一种登录方式 的解绑按钮禁用(防锁死,后端也有兜底) - i18n:login.github/discord、settings.linked.* 暂不加"连接 Discord"按钮:绑定流程(M2b)未上线前,点它只会把用户登成新账号(分叉)。 typecheck / lint(0 error)/ build 通过;/login 仍 ● SSG。
1 parent 7a3a5e4 commit 25baf9d

7 files changed

Lines changed: 163 additions & 16 deletions

File tree

app/[locale]/login/page.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,9 @@ export default async function LoginPage({ params }: Props) {
3030
<h1 className="text-3xl font-bold">{t("heading")}</h1>
3131
<p className="text-muted-foreground">{t("subheading")}</p>
3232
</div>
33-
<div className="flex justify-center">
34-
<SignInButton />
33+
<div className="flex flex-col items-center gap-3">
34+
<SignInButton provider="github" label={t("github")} />
35+
<SignInButton provider="discord" label={t("discord")} />
3536
</div>
3637
</div>
3738
</div>
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"use client";
2+
3+
import { useEffect, useState } from "react";
4+
import { useTranslations } from "next-intl";
5+
6+
type LinkedIdentity = {
7+
provider: string;
8+
displayNameAtLink: string | null;
9+
linkedAt: string | null;
10+
lastLoginAt: string | null;
11+
};
12+
13+
function getToken(): string | null {
14+
if (typeof window === "undefined") return null;
15+
return localStorage.getItem("satoken");
16+
}
17+
18+
const PROVIDER_LABEL: Record<string, string> = {
19+
github: "GitHub",
20+
discord: "Discord",
21+
};
22+
23+
/**
24+
* 已绑定登录方式的查看 + 解绑(后端 M2a)。绑定新 provider 走 OAuth 流程,
25+
* 待绑定流程(M2b)上线后再加"连接"按钮——现在加会把用户登成新账号(分叉)。
26+
*/
27+
export function LinkedAccounts() {
28+
const t = useTranslations("settings.linked");
29+
const [items, setItems] = useState<LinkedIdentity[] | null>(null);
30+
const [error, setError] = useState<string | null>(null);
31+
const [busy, setBusy] = useState<string | null>(null);
32+
33+
async function load() {
34+
const token = getToken();
35+
if (!token) return;
36+
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 ?? []);
43+
setError(null);
44+
} catch {
45+
setError(t("loadFail"));
46+
}
47+
}
48+
49+
useEffect(() => {
50+
load();
51+
// eslint-disable-next-line react-hooks/exhaustive-deps
52+
}, []);
53+
54+
async function unbind(provider: string) {
55+
const token = getToken();
56+
if (!token) return;
57+
setBusy(provider);
58+
try {
59+
const res = await fetch(`/api/user-center/identities/${provider}`, {
60+
method: "DELETE",
61+
headers: { satoken: token },
62+
});
63+
const body = await res.json();
64+
if (!res.ok || body.success === false) {
65+
// 后端把"最后一种登录方式不能解绑"等作为 400 + message 返回
66+
setError(body.message ?? t("unbindFail"));
67+
return;
68+
}
69+
setItems(body.data ?? []);
70+
setError(null);
71+
} catch {
72+
setError(t("unbindFail"));
73+
} finally {
74+
setBusy(null);
75+
}
76+
}
77+
78+
if (items === null && !error) return null; // 未登录或加载中,保持安静
79+
80+
return (
81+
<section className="mt-10">
82+
<label className="block font-serif font-bold text-lg mb-3">
83+
{t("label")}
84+
</label>
85+
{error && (
86+
<p className="font-mono text-xs text-[#CC0000] mb-3">{error}</p>
87+
)}
88+
<ul className="border border-[var(--foreground)] divide-y divide-[var(--foreground)]/20">
89+
{(items ?? []).map((it) => (
90+
<li
91+
key={it.provider}
92+
className="flex items-center justify-between px-4 py-3"
93+
>
94+
<span className="font-mono text-sm text-[var(--foreground)]">
95+
{PROVIDER_LABEL[it.provider] ?? it.provider}
96+
{it.displayNameAtLink ? ` · ${it.displayNameAtLink}` : ""}
97+
</span>
98+
<button
99+
type="button"
100+
onClick={() => unbind(it.provider)}
101+
disabled={busy === it.provider || (items ?? []).length <= 1}
102+
title={(items ?? []).length <= 1 ? t("lastHint") : undefined}
103+
className="font-mono text-xs uppercase tracking-widest text-[var(--foreground)]/70 hover:text-[#CC0000] transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
104+
>
105+
{busy === it.provider ? "…" : t("unbind")}
106+
</button>
107+
</li>
108+
))}
109+
{(items ?? []).length === 0 && (
110+
<li className="px-4 py-3 font-mono text-sm text-neutral-500">
111+
{t("empty")}
112+
</li>
113+
)}
114+
</ul>
115+
</section>
116+
);
117+
}

app/[locale]/settings/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { Metadata } from "next";
55
import { Header } from "@/app/components/Header";
66
import { Footer } from "@/app/components/Footer";
77
import { SettingsForm } from "./SettingsForm";
8+
import { LinkedAccounts } from "./LinkedAccounts";
89

910
// SEO: 设置页仅登录用户相关,不参与搜索索引
1011
export const metadata: Metadata = {
@@ -29,6 +30,7 @@ export default function SettingsPage() {
2930
</p>
3031
</div>
3132
<SettingsForm />
33+
<LinkedAccounts />
3234
</div>
3335
</main>
3436
<Footer />

app/components/SignInButton.tsx

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,21 @@ import { Button } from "@/app/components/ui/button";
44

55
interface SignInButtonProps {
66
className?: string;
7+
// 默认 github(header 处无 props 调用不变);登录页可传 discord。
8+
provider?: "github" | "discord";
9+
label?: string;
710
}
811

9-
export function SignInButton({ className }: SignInButtonProps) {
10-
// 同源跳到 /oauth/render/github,经 next.config.mjs 的 rewrite 代理到后端。
11-
// 好处:开发环境后端端口改来改去(8080 / 8081)都不用改前端;302 由 Next.js 透传给浏览器,
12-
// 最终由浏览器跳到 GitHub 授权页。
13-
// 注意:GitHub OAuth app 注册的 callback URL 决定最终返回的前端端口
14-
// (当前注册为 localhost:3000/api/auth/callback/github),换端口跑本地时需在 GitHub OAuth app 里补一个。
12+
export function SignInButton({
13+
className,
14+
provider = "github",
15+
label = "SignIn",
16+
}: SignInButtonProps) {
17+
// 同源跳到 /oauth/render/{provider},经 next.config.mjs 的 rewrite 代理到后端。
18+
// 好处:开发环境后端端口改来改去都不用改前端;302 由 Next.js 透传给浏览器,
19+
// 最终跳到 provider 授权页。各 provider 的 OAuth app 回调 URL 决定返回的前端地址。
1520
const handleSignIn = () => {
16-
window.location.href = "/oauth/render/github";
21+
window.location.href = `/oauth/render/${provider}`;
1722
};
1823

1924
return (
@@ -25,8 +30,9 @@ export function SignInButton({ className }: SignInButtonProps) {
2530
data-umami-event="auth_click"
2631
data-umami-event-action="signin"
2732
data-umami-event-location="header"
33+
data-umami-event-provider={provider}
2834
>
29-
SignIn
35+
{label}
3036
</Button>
3137
);
3238
}

messages/en.json

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,9 @@
6868
},
6969
"login": {
7070
"heading": "Sign In",
71-
"subheading": "Sign in to access protected pages"
71+
"subheading": "Sign in to access protected pages",
72+
"github": "Sign in with GitHub",
73+
"discord": "Sign in with Discord"
7274
},
7375
"settings": {
7476
"theme": {
@@ -93,6 +95,14 @@
9395
"saveSuccess": "Preferences saved",
9496
"saveFail": "Save failed — try again later",
9597
"fetchFail": "Failed to fetch preferences"
98+
},
99+
"linked": {
100+
"label": "Sign-in methods",
101+
"unbind": "Unlink",
102+
"empty": "No linked sign-in methods",
103+
"loadFail": "Failed to load sign-in methods",
104+
"unbindFail": "Unlink failed",
105+
"lastHint": "This is your only sign-in method and can't be unlinked"
96106
}
97107
},
98108
"signIn": {

messages/zh.json

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,9 @@
6868
},
6969
"login": {
7070
"heading": "登录",
71-
"subheading": "请登录以访问受保护的页面"
71+
"subheading": "请登录以访问受保护的页面",
72+
"github": "用 GitHub 登录",
73+
"discord": "用 Discord 登录"
7274
},
7375
"settings": {
7476
"theme": {
@@ -93,6 +95,14 @@
9395
"saveSuccess": "偏好设置已保存",
9496
"saveFail": "保存失败,请稍后重试",
9597
"fetchFail": "获取偏好失败"
98+
},
99+
"linked": {
100+
"label": "登录方式",
101+
"unbind": "解绑",
102+
"empty": "暂无绑定的第三方登录",
103+
"loadFail": "加载登录方式失败",
104+
"unbindFail": "解绑失败",
105+
"lastHint": "这是你唯一的登录方式,不能解绑"
96106
}
97107
},
98108
"signIn": {

next.config.mjs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -357,10 +357,11 @@ const config = {
357357
const backendUrl = process.env.BACKEND_URL ?? "http://localhost:8080";
358358
return [
359359
{
360-
// GitHub OAuth 回调:GitHub → localhost:3000/api/auth/callback/github → 后端
361-
// 路径与 GitHub OAuth App 注册的 callback URL 保持一致,无需改 GitHub App 设置
362-
source: "/api/auth/callback/github",
363-
destination: `${backendUrl}/api/auth/callback/github`,
360+
// OAuth 回调代理到后端。:provider 覆盖 github / discord / …,路径与各家
361+
// OAuth App 注册的 callback URL 保持一致(后端按 {provider} 分发)。
362+
// 之前硬编码只有 github,Discord 回调 /api/auth/callback/discord 会漏代理 → 404。
363+
source: "/api/auth/callback/:provider",
364+
destination: `${backendUrl}/api/auth/callback/:provider`,
364365
},
365366
{
366367
// 认证 API(/auth/me, /auth/logout 等)走 Next.js 代理,避免浏览器跨域 CORS 问题

0 commit comments

Comments
 (0)