Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions app/[locale]/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@ export default async function LoginPage({ params }: Props) {
<h1 className="text-3xl font-bold">{t("heading")}</h1>
<p className="text-muted-foreground">{t("subheading")}</p>
</div>
<div className="flex justify-center">
<SignInButton />
<div className="flex flex-col items-center gap-3">
<SignInButton provider="github" label={t("github")} />
<SignInButton provider="discord" label={t("discord")} />
</div>
</div>
</div>
Expand Down
117 changes: 117 additions & 0 deletions app/[locale]/settings/LinkedAccounts.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
github: "GitHub",
discord: "Discord",
};

/**
* 已绑定登录方式的查看 + 解绑(后端 M2a)。绑定新 provider 走 OAuth 流程,
* 待绑定流程(M2b)上线后再加"连接"按钮——现在加会把用户登成新账号(分叉)。
*/
export function LinkedAccounts() {
const t = useTranslations("settings.linked");
const [items, setItems] = useState<LinkedIdentity[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(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 (
<section className="mt-10">
<label className="block font-serif font-bold text-lg mb-3">
{t("label")}
</label>
{error && (
<p className="font-mono text-xs text-[#CC0000] mb-3">{error}</p>
)}
<ul className="border border-[var(--foreground)] divide-y divide-[var(--foreground)]/20">
{(items ?? []).map((it) => (
<li
key={it.provider}
className="flex items-center justify-between px-4 py-3"
>
<span className="font-mono text-sm text-[var(--foreground)]">
{PROVIDER_LABEL[it.provider] ?? it.provider}
{it.displayNameAtLink ? ` · ${it.displayNameAtLink}` : ""}
</span>
<button
type="button"
onClick={() => unbind(it.provider)}
disabled={busy === it.provider || (items ?? []).length <= 1}
title={(items ?? []).length <= 1 ? t("lastHint") : undefined}
className="font-mono text-xs uppercase tracking-widest text-[var(--foreground)]/70 hover:text-[#CC0000] transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{busy === it.provider ? "…" : t("unbind")}
</button>
</li>
))}
{(items ?? []).length === 0 && (
<li className="px-4 py-3 font-mono text-sm text-neutral-500">
{t("empty")}
</li>
)}
</ul>
</section>
);
}
2 changes: 2 additions & 0 deletions app/[locale]/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -29,6 +30,7 @@ export default function SettingsPage() {
</p>
</div>
<SettingsForm />
<LinkedAccounts />
</div>
</main>
<Footer />
Expand Down
22 changes: 14 additions & 8 deletions app/components/SignInButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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}
</Button>
);
}
12 changes: 11 additions & 1 deletion messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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": {
Expand Down
12 changes: 11 additions & 1 deletion messages/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,9 @@
},
"login": {
"heading": "登录",
"subheading": "请登录以访问受保护的页面"
"subheading": "请登录以访问受保护的页面",
"github": "用 GitHub 登录",
"discord": "用 Discord 登录"
},
"settings": {
"theme": {
Expand All @@ -93,6 +95,14 @@
"saveSuccess": "偏好设置已保存",
"saveFail": "保存失败,请稍后重试",
"fetchFail": "获取偏好失败"
},
"linked": {
"label": "登录方式",
"unbind": "解绑",
"empty": "暂无绑定的第三方登录",
"loadFail": "加载登录方式失败",
"unbindFail": "解绑失败",
"lastHint": "这是你唯一的登录方式,不能解绑"
}
},
"signIn": {
Expand Down
9 changes: 5 additions & 4 deletions next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 问题
Expand Down
Loading