Skip to content

Commit aac515d

Browse files
committed
feat: Add OAuth avatar support, database integration, and theme system
- Implement OAuth profile image capture for GitHub and Google providers - Add database integration with Neon PostgreSQL (users, oauth_accounts tables) - Create account settings page with OAuth account management - Add theme system with light/dark mode support - Enhance user authentication with custom auth adapter - Update all tool pages with improved UI components - Add comprehensive documentation for database and account features - Improve session management and user data handling
1 parent 4f13573 commit aac515d

35 files changed

+1710
-300
lines changed

app/account/page.tsx

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
'use client'
2+
3+
import { useEffect, useState } from 'react'
4+
import { useSession, getSession } from 'next-auth/react'
5+
import { useRouter } from 'next/navigation'
6+
import Link from 'next/link'
7+
import Image from 'next/image'
8+
import {
9+
FaUser,
10+
FaGithub,
11+
FaGoogle,
12+
FaLock,
13+
FaCog
14+
} from 'react-icons/fa'
15+
16+
interface OAuthAccount {
17+
id: number
18+
provider: string
19+
provider_user_id: string
20+
created_at: string
21+
}
22+
23+
export default function AccountPage() {
24+
const { data: session, status } = useSession()
25+
const router = useRouter()
26+
const [activeTab, setActiveTab] = useState('general')
27+
const [oauthAccounts, setOAuthAccounts] = useState<OAuthAccount[]>([])
28+
const [loading, setLoading] = useState(true)
29+
30+
useEffect(() => {
31+
if (status === 'unauthenticated') {
32+
router.push('/login')
33+
return
34+
}
35+
36+
if (status === 'authenticated') {
37+
fetchOAuthAccounts()
38+
}
39+
}, [status, router])
40+
41+
const fetchOAuthAccounts = async () => {
42+
try {
43+
const response = await fetch('/api/user/oauth-accounts')
44+
if (response.ok) {
45+
const accounts = await response.json()
46+
setOAuthAccounts(accounts)
47+
}
48+
} catch (error) {
49+
console.error('Failed to fetch OAuth accounts:', error)
50+
} finally {
51+
setLoading(false)
52+
}
53+
}
54+
55+
if (status === 'loading' || loading) {
56+
return (
57+
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
58+
<div className="text-center">
59+
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
60+
<p className="mt-2 text-gray-600 dark:text-gray-400">Loading...</p>
61+
</div>
62+
</div>
63+
)
64+
}
65+
66+
if (!session?.user) {
67+
return null
68+
}
69+
70+
const getProviderIcon = (provider: string) => {
71+
switch (provider) {
72+
case 'github':
73+
return <FaGithub className="w-5 h-5" />
74+
case 'google':
75+
return <FaGoogle className="w-5 h-5" />
76+
default:
77+
return <FaLock className="w-5 h-5" />
78+
}
79+
}
80+
81+
const getProviderName = (provider: string) => {
82+
switch (provider) {
83+
case 'github':
84+
return 'GitHub'
85+
case 'google':
86+
return 'Google'
87+
default:
88+
return provider.charAt(0).toUpperCase() + provider.slice(1)
89+
}
90+
}
91+
92+
const formatDate = (dateString: string) => {
93+
return new Date(dateString).toLocaleDateString('en-US', {
94+
year: 'numeric',
95+
month: 'long',
96+
day: 'numeric'
97+
})
98+
}
99+
100+
const sidebarTabs = [
101+
{ id: 'general', name: 'General', icon: FaUser },
102+
{ id: 'security', name: 'Security', icon: FaLock, disabled: true },
103+
{ id: 'preferences', name: 'Preferences', icon: FaCog, disabled: true },
104+
]
105+
106+
return (
107+
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
108+
{/* Header */}
109+
<div className="bg-white dark:bg-gray-800 shadow-sm border-b border-gray-200 dark:border-gray-700">
110+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
111+
<div className="flex items-center space-x-4">
112+
<Link
113+
href="/"
114+
className="text-blue-600 hover:text-blue-700 text-sm font-medium"
115+
>
116+
← Back to Home
117+
</Link>
118+
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
119+
Account Settings
120+
</h1>
121+
</div>
122+
</div>
123+
</div>
124+
125+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
126+
<div className="flex flex-col lg:flex-row gap-6">
127+
{/* Sidebar */}
128+
<div className="w-full lg:w-64">
129+
<nav className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-1">
130+
{sidebarTabs.map((tab) => (
131+
<button
132+
key={tab.id}
133+
onClick={() => !tab.disabled && setActiveTab(tab.id)}
134+
disabled={tab.disabled}
135+
className={`w-full flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors ${
136+
activeTab === tab.id
137+
? 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300'
138+
: tab.disabled
139+
? 'text-gray-400 dark:text-gray-600 cursor-not-allowed'
140+
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
141+
}`}
142+
>
143+
<tab.icon className="w-4 h-4 mr-3" />
144+
{tab.name}
145+
{tab.disabled && (
146+
<span className="ml-auto text-xs text-gray-400">Soon</span>
147+
)}
148+
</button>
149+
))}
150+
</nav>
151+
</div>
152+
153+
{/* Content */}
154+
<div className="flex-1">
155+
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
156+
{activeTab === 'general' && (
157+
<div className="p-6">
158+
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-6">
159+
General Information
160+
</h2>
161+
162+
{/* User Profile */}
163+
<div className="mb-8">
164+
<h3 className="text-md font-medium text-gray-900 dark:text-white mb-4">
165+
Profile
166+
</h3>
167+
<div className="flex items-start space-x-4">
168+
{session.user.image ? (
169+
<Image
170+
src={session.user.image}
171+
alt={session.user.name || 'User'}
172+
width={64}
173+
height={64}
174+
className="rounded-full"
175+
/>
176+
) : (
177+
<div className="w-16 h-16 bg-blue-600 rounded-full flex items-center justify-center text-white">
178+
<FaUser className="w-6 h-6" />
179+
</div>
180+
)}
181+
<div className="flex-1">
182+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
183+
<div>
184+
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
185+
Name
186+
</label>
187+
<div className="px-3 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md text-gray-900 dark:text-white">
188+
{session.user.name || 'Not provided'}
189+
</div>
190+
</div>
191+
<div>
192+
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
193+
Email
194+
</label>
195+
<div className="px-3 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md text-gray-900 dark:text-white">
196+
{session.user.email}
197+
</div>
198+
</div>
199+
</div>
200+
</div>
201+
</div>
202+
</div>
203+
204+
{/* Connected Accounts */}
205+
<div>
206+
<h3 className="text-md font-medium text-gray-900 dark:text-white mb-4">
207+
Connected Accounts
208+
</h3>
209+
<div className="space-y-3">
210+
{oauthAccounts.map((account) => (
211+
<div
212+
key={account.id}
213+
className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600"
214+
>
215+
<div className="flex items-center space-x-3">
216+
{getProviderIcon(account.provider)}
217+
<div>
218+
<p className="font-medium text-gray-900 dark:text-white">
219+
{getProviderName(account.provider)}
220+
</p>
221+
<p className="text-sm text-gray-500 dark:text-gray-400">
222+
Connected on {formatDate(account.created_at)}
223+
</p>
224+
</div>
225+
</div>
226+
<div className="flex items-center space-x-2">
227+
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900/50 text-green-800 dark:text-green-300">
228+
Connected
229+
</span>
230+
</div>
231+
</div>
232+
))}
233+
234+
{oauthAccounts.length === 0 && (
235+
<p className="text-gray-500 dark:text-gray-400 text-sm">
236+
No connected accounts found.
237+
</p>
238+
)}
239+
</div>
240+
</div>
241+
</div>
242+
)}
243+
</div>
244+
</div>
245+
</div>
246+
</div>
247+
</div>
248+
)
249+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { NextRequest, NextResponse } from 'next/server'
2+
import { getServerSession } from 'next-auth/next'
3+
import { authOptions } from '../../../auth'
4+
import { OAuthAccountModel } from '../../../lib/models/user'
5+
6+
export const runtime = 'nodejs'
7+
export const dynamic = 'force-dynamic'
8+
9+
export async function GET(request: NextRequest) {
10+
try {
11+
const session = await getServerSession(authOptions)
12+
13+
if (!session?.user?.id) {
14+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
15+
}
16+
17+
const oauthAccounts = await OAuthAccountModel.findByUserId(parseInt(session.user.id))
18+
19+
// Return sanitized data (no sensitive tokens)
20+
const sanitizedAccounts = oauthAccounts.map(account => ({
21+
id: account.id,
22+
provider: account.provider,
23+
provider_user_id: account.provider_user_id,
24+
created_at: account.created_at,
25+
}))
26+
27+
return NextResponse.json(sanitizedAccounts)
28+
} catch (error) {
29+
console.error('Error fetching OAuth accounts:', error)
30+
return NextResponse.json(
31+
{ error: 'Internal server error' },
32+
{ status: 500 }
33+
)
34+
}
35+
}

app/auth.ts

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import NextAuth, { NextAuthOptions } from "next-auth";
22
import GitHubProvider from "next-auth/providers/github";
33
import GoogleProvider from "next-auth/providers/google";
4+
import { NeonDatabaseAdapter } from "./lib/auth-adapter";
5+
import { UserModel, OAuthAccountModel } from "./lib/models/user";
46

57
export const authOptions: NextAuthOptions = {
8+
adapter: NeonDatabaseAdapter(),
69
providers: [
710
GitHubProvider({
811
clientId: process.env.GITHUB_ID || "",
@@ -17,15 +20,75 @@ export const authOptions: NextAuthOptions = {
1720
signIn: '/login',
1821
},
1922
callbacks: {
20-
async jwt({ token, user }) {
23+
async signIn({ user, account, profile, email }) {
24+
// Handle account linking for trusted OAuth providers
25+
if (account?.provider === "github" || account?.provider === "google") {
26+
try {
27+
// Get profile image URL from the OAuth provider
28+
let profileImageUrl = user.image;
29+
if (account.provider === 'github' && profile) {
30+
profileImageUrl = (profile as any).avatar_url || user.image;
31+
} else if (account.provider === 'google' && profile) {
32+
profileImageUrl = (profile as any).picture || user.image;
33+
}
34+
35+
// Check if user with this email already exists
36+
const existingUser = await UserModel.findByEmail(user.email!);
37+
38+
if (existingUser) {
39+
// Update user's image if not set
40+
if (profileImageUrl && !existingUser.image) {
41+
await UserModel.update(existingUser.id, {
42+
image: profileImageUrl
43+
});
44+
}
45+
46+
// Check if this OAuth account is already linked
47+
const existingOAuth = await OAuthAccountModel.findByProviderAndUserId(
48+
account.provider,
49+
account.providerAccountId
50+
);
51+
52+
if (!existingOAuth) {
53+
// Link this new OAuth account to the existing user
54+
await OAuthAccountModel.create({
55+
user_id: existingUser.id,
56+
provider: account.provider,
57+
provider_user_id: account.providerAccountId,
58+
profile_image: profileImageUrl || undefined,
59+
access_token: account.access_token,
60+
refresh_token: account.refresh_token,
61+
expires_at: account.expires_at ? new Date(account.expires_at * 1000) : undefined,
62+
});
63+
}
64+
}
65+
} catch (error) {
66+
console.error('Error during sign in callback:', error);
67+
// Continue with sign in even if linking fails
68+
}
69+
return true;
70+
}
71+
return true;
72+
},
73+
async jwt({ token, user, account }) {
2174
if (user) {
22-
token.user = user;
75+
token.user = {
76+
id: user.id,
77+
name: user.name,
78+
email: user.email,
79+
image: user.image,
80+
};
2381
}
2482
return token;
2583
},
2684
async session({ session, token }) {
2785
if (token.user) {
28-
session.user = token.user as any;
86+
session.user = {
87+
id: token.user.id,
88+
name: token.user.name,
89+
email: token.user.email,
90+
image: token.user.image,
91+
};
2992
}
3093
return session;
3194
},

app/components/ClientToolLayout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export default function ClientToolLayout({
4343
const user = session?.user
4444

4545
return (
46-
<div className={`min-h-screen ${backgroundColor}`}>
46+
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
4747
<UserHeader user={user} />
4848
<ToolHeader
4949
title={title}

0 commit comments

Comments
 (0)