Skip to content

Commit 02f96a4

Browse files
committed
新增GitHub登录回调
1 parent 508b676 commit 02f96a4

4 files changed

Lines changed: 290 additions & 15 deletions

File tree

.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Copy this file to .env.local (do NOT commit secrets to git)
2+
# GitHub OAuth application credentials (server-side only)
3+
GITHUB_CLIENT_ID=Ov23lirB3OziFtPWOy9X
4+
GITHUB_CLIENT_SECRET=your_github_client_secret_here
5+
6+
# Optional: set NODE_ENV=development for local dev (most tools set this automatically)
7+
# NODE_ENV=development

app/api/auth/github/route.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { NextResponse } from "next/server";
2+
3+
// Server-side route to exchange GitHub OAuth code for an access token
4+
// and fetch basic user info. Requires environment variables:
5+
// GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET
6+
7+
export async function POST(req: Request) {
8+
try {
9+
const body = await req.json();
10+
const { code } = body || {};
11+
12+
if (!code) {
13+
return NextResponse.json({ error: "Missing code" }, { status: 400 });
14+
}
15+
16+
const client_id = process.env.GITHUB_CLIENT_ID;
17+
const client_secret = process.env.GITHUB_CLIENT_SECRET;
18+
19+
const missing: string[] = [];
20+
if (!client_id) missing.push("GITHUB_CLIENT_ID");
21+
if (!client_secret) missing.push("GITHUB_CLIENT_SECRET");
22+
if (missing.length > 0) {
23+
// In production we must have the secrets. In development provide a mock user
24+
if (process.env.NODE_ENV === "production") {
25+
return NextResponse.json({ error: `Server missing environment variables: ${missing.join(", ")}` }, { status: 500 });
26+
}
27+
28+
// Development fallback: return a safe mock user so frontend dev can continue
29+
const mockUser = {
30+
provider: "github",
31+
login: "devuser",
32+
id: 0,
33+
name: "Dev User",
34+
avatar_url: null,
35+
email: "dev@example.com",
36+
raw: { dev: true },
37+
};
38+
39+
return NextResponse.json({ ok: true, user: mockUser, warning: `Missing env vars: ${missing.join(", ")}. Using mock user in development.` });
40+
}
41+
42+
// Exchange code for access_token
43+
const tokenRes = await fetch("https://github.com/login/oauth/access_token", {
44+
method: "POST",
45+
headers: {
46+
Accept: "application/json",
47+
"Content-Type": "application/json",
48+
},
49+
body: JSON.stringify({ client_id, client_secret, code }),
50+
});
51+
52+
if (!tokenRes.ok) {
53+
const txt = await tokenRes.text();
54+
return NextResponse.json({ error: `GitHub token exchange failed: ${tokenRes.status} ${txt}` }, { status: 502 });
55+
}
56+
57+
const tokenJson = await tokenRes.json();
58+
const access_token = tokenJson.access_token;
59+
60+
if (!access_token) {
61+
return NextResponse.json({ error: "No access_token returned from GitHub", details: tokenJson }, { status: 502 });
62+
}
63+
64+
// Use token to fetch user info
65+
const userRes = await fetch("https://api.github.com/user", {
66+
headers: { Authorization: `token ${access_token}`, Accept: "application/vnd.github.v3+json" },
67+
});
68+
69+
if (!userRes.ok) {
70+
const txt = await userRes.text();
71+
return NextResponse.json({ error: `GitHub user fetch failed: ${userRes.status} ${txt}` }, { status: 502 });
72+
}
73+
74+
const user = await userRes.json();
75+
76+
// Try to fetch primary email if not present
77+
let primaryEmail: string | null = null;
78+
if (!user.email) {
79+
const emailsRes = await fetch("https://api.github.com/user/emails", {
80+
headers: { Authorization: `token ${access_token}`, Accept: "application/vnd.github.v3+json" },
81+
});
82+
if (emailsRes.ok) {
83+
const emails = await emailsRes.json();
84+
const primary = (emails || []).find((e: any) => e.primary) || emails[0];
85+
if (primary) primaryEmail = primary.email;
86+
}
87+
}
88+
89+
const result = {
90+
provider: "github",
91+
login: user.login,
92+
id: user.id,
93+
name: user.name || null,
94+
avatar_url: user.avatar_url || null,
95+
email: user.email || primaryEmail,
96+
raw: user,
97+
};
98+
99+
return NextResponse.json({ ok: true, user: result });
100+
} catch (err: any) {
101+
return NextResponse.json({ error: err?.message || String(err) }, { status: 500 });
102+
}
103+
}

app/users/login/success/page.tsx

Lines changed: 134 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,151 @@
11
"use client";
22

3-
import React, { useState } from "react";
3+
import React, { useEffect, useState } from "react";
44
import type { JSX } from "react";
5+
import { useRouter } from "next/navigation";
56
import { Navigation } from "@/components/navigation";
67
import { Footer } from "@/components/footer";
78
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
89
import { Badge } from "@/components/ui/badge";
910
import { Button } from "@/components/ui/button";
10-
import { Github, Lock, UserCheck, Shield, ArrowRight, Sparkles } from "lucide-react";
11+
import { Github, UserCheck, ArrowRight } from "lucide-react";
12+
13+
type UserInfo = {
14+
provider: string;
15+
login: string;
16+
id: number;
17+
name: string | null;
18+
avatar_url?: string | null;
19+
email?: string | null;
20+
};
21+
22+
export default function LoginSuccessPage(): JSX.Element {
23+
const [loading, setLoading] = useState(true);
24+
const [error, setError] = useState<string | null>(null);
25+
const [user, setUser] = useState<UserInfo | null>(null);
26+
const router = useRouter();
27+
28+
useEffect(() => {
29+
// Parse query params
30+
const params = new URLSearchParams(window.location.search);
31+
const code = params.get("code");
32+
const state = params.get("state");
33+
34+
if (!code) {
35+
setError("缺少 code 参数,无法完成登录。");
36+
setLoading(false);
37+
return;
38+
}
39+
40+
// POST to our server-side exchange endpoint
41+
(async () => {
42+
try {
43+
setLoading(true);
44+
setError(null);
45+
46+
const resp = await fetch("/api/auth/github", {
47+
method: "POST",
48+
headers: { "Content-Type": "application/json" },
49+
body: JSON.stringify({ code, state }),
50+
});
51+
52+
const data = await resp.json();
53+
54+
if (!resp.ok || data.error) {
55+
setError(data.error || `后端返回错误: ${resp.status}`);
56+
setLoading(false);
57+
return;
58+
}
59+
60+
setUser(data.user || null);
61+
// 持久化到 localStorage,供导航栏读取
62+
try {
63+
if (data?.user) {
64+
localStorage.setItem("ep_user", JSON.stringify(data.user));
65+
}
66+
} catch (e) {
67+
// ignore
68+
}
69+
setLoading(false);
70+
71+
// 在登录成功后导航到用户页或主页
72+
router.push('/users/home');
73+
} catch (err: any) {
74+
setError(err?.message || String(err));
75+
setLoading(false);
76+
}
77+
})();
78+
}, [router]);
1179

12-
export default function LoginPage(): JSX.Element {
1380
return (
1481
<>
1582
<Navigation />
1683
<main className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-cyan-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900 py-12">
1784
<div className="container mx-auto px-4">
18-
<h1 className="text-4xl font-bold text-center text-gray-900 dark:text-white">
19-
正在开发中...
20-
</h1>
21-
<p className="text-center text-gray-600 dark:text-gray-400">
22-
我们正在努力开发新功能,敬请期待!
23-
</p>
85+
<div className="max-w-xl mx-auto">
86+
<Card>
87+
<CardHeader>
88+
<CardTitle className="flex items-center gap-2">
89+
<Github className="w-6 h-6" />
90+
GitHub 登录回调
91+
</CardTitle>
92+
<CardDescription>
93+
处理 GitHub 返回的授权 code,并使用服务器端密钥完成 token 交换。
94+
</CardDescription>
95+
</CardHeader>
96+
<CardContent>
97+
{loading && (
98+
<div className="space-y-2">
99+
<p>正在完成登录……</p>
100+
<p className="text-sm text-gray-500">请稍候,页面会在处理完成后显示结果。</p>
101+
</div>
102+
)}
103+
104+
{!loading && error && (
105+
<div className="space-y-2">
106+
<p className="text-red-600">发生错误:{error}</p>
107+
<div className="flex gap-2">
108+
<Button onClick={() => window.location.assign('/users/login')}>返回登录</Button>
109+
</div>
110+
</div>
111+
)}
112+
113+
{!loading && user && (
114+
<div className="space-y-4">
115+
<div className="flex items-center gap-4">
116+
{user.avatar_url ? (
117+
// eslint-disable-next-line @next/next/no-img-element
118+
<img src={user.avatar_url} alt="avatar" className="w-16 h-16 rounded-full" />
119+
) : (
120+
<div className="w-16 h-16 rounded-full bg-gray-200 flex items-center justify-center">
121+
<UserCheck />
122+
</div>
123+
)}
124+
125+
<div>
126+
<div className="text-lg font-medium">{user.name || user.login}</div>
127+
<div className="text-sm text-gray-500">{user.email || '未公开邮箱'}</div>
128+
</div>
129+
</div>
130+
131+
<div className="flex gap-2">
132+
<Badge>provider: {user.provider}</Badge>
133+
<Badge>login: {user.login}</Badge>
134+
</div>
135+
136+
<div className="flex gap-2">
137+
<Button onClick={() => router.push('/')}>
138+
<ArrowRight className="w-4 h-4 mr-2" /> 返回首页
139+
</Button>
140+
<Button variant="ghost" onClick={() => window.location.assign('/users/login')}>
141+
返回登录页
142+
</Button>
143+
</div>
144+
</div>
145+
)}
146+
</CardContent>
147+
</Card>
148+
</div>
24149
</div>
25150
</main>
26151
<Footer />

components/navigation.tsx

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"use client"
22

33
import Link from "next/link"
4-
import { useState } from "react"
4+
import { useState, useEffect } from "react"
55
import { Button } from "@/components/ui/button"
66
import { ThemeToggle } from "@/components/ui/theme-toggle"
77
import { Menu, X, Server, Download, Activity, BookOpen, Users, Sparkles, Home } from "lucide-react"
@@ -17,6 +17,17 @@ export function Navigation() {
1717
{ href: "/about", label: "关于我们", icon: Users, description: "团队介绍" },
1818
]
1919

20+
const [user, setUser] = useState<{ login?: string; name?: string; avatar_url?: string } | null>(null)
21+
22+
useEffect(() => {
23+
try {
24+
const raw = localStorage.getItem("ep_user")
25+
if (raw) setUser(JSON.parse(raw))
26+
} catch (e) {
27+
// ignore
28+
}
29+
}, [])
30+
2031
return (
2132
<nav className="bg-white/80 dark:bg-slate-900/80 backdrop-blur-md border-b border-slate-200 dark:border-slate-800 sticky top-0 z-50 shadow-sm">
2233
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
@@ -62,11 +73,40 @@ export function Navigation() {
6273
{/* Separator */}
6374
<div className="w-px h-6 bg-slate-200 dark:bg-slate-700 mx-2"></div>
6475

65-
<Button variant="outline" size="sm" className="text-sm font-medium text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white transition-all duration-200 py-2 px-4 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 group relative min-w-[80px]">
66-
<a href="/users/login" className="flex items-center justify-center h-full w-full">
67-
登录
68-
</a>
69-
</Button>
76+
{/* 如果检测到已登录用户,显示头像/姓名与登出;否则显示登录按钮 */}
77+
{user ? (
78+
<div className="flex items-center gap-2">
79+
<Link href="/users/home" className="flex items-center gap-2 text-sm font-medium text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white transition-all">
80+
{user.avatar_url ? (
81+
// eslint-disable-next-line @next/next/no-img-element
82+
<img src={user.avatar_url} alt="avatar" className="w-8 h-8 rounded-full" />
83+
) : (
84+
<div className="w-8 h-8 rounded-full bg-slate-200 dark:bg-slate-800 flex items-center justify-center">EP</div>
85+
)}
86+
<span className="hidden sm:inline">{user.name || user.login}</span>
87+
</Link>
88+
<Button
89+
variant="ghost"
90+
size="sm"
91+
onClick={() => {
92+
try {
93+
localStorage.removeItem("ep_user")
94+
} catch (e) {}
95+
// reload to reflect changes
96+
window.location.reload()
97+
}}
98+
className="text-sm"
99+
>
100+
登出
101+
</Button>
102+
</div>
103+
) : (
104+
<Button variant="outline" size="sm" className="text-sm font-medium text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white transition-all duration-200 py-2 px-4 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 group relative min-w-[80px]">
105+
<a href="/users/login" className="flex items-center justify-center h-full w-full">
106+
登录
107+
</a>
108+
</Button>
109+
)}
70110

71111
{/* Separator */}
72112
<div className="w-px h-6 bg-slate-200 dark:bg-slate-700 mx-2"></div>

0 commit comments

Comments
 (0)