diff --git a/GITHUB_OAUTH_SETUP.md b/GITHUB_OAUTH_SETUP.md new file mode 100644 index 0000000..782b291 --- /dev/null +++ b/GITHUB_OAUTH_SETUP.md @@ -0,0 +1,95 @@ +# GitHub OAuth Setup Guide + +## 🚀 Setting up GitHub OAuth for GitHubTracker + +To enable "Sign in with GitHub" functionality, you need to create a GitHub OAuth App and configure the environment variables. + +### Step 1: Create a GitHub OAuth App + +1. Go to [GitHub Developer Settings](https://github.com/settings/developers) +2. Click "New OAuth App" +3. Fill in the following details: + - **Application name**: `GitHubTracker` (or your preferred name) + - **Homepage URL**: `http://localhost:5174` (for development) + - **Authorization callback URL**: `http://localhost:5174/auth/github/callback` + - **Description**: `GitHub activity tracker application` + +4. Click "Register application" +5. Copy the **Client ID** and **Client Secret** (you'll need these for the environment variables) + +### Step 2: Configure Environment Variables + +Create a `.env` file in the **root directory** with: +``` +VITE_BACKEND_URL=http://localhost:5000 +VITE_GITHUB_CLIENT_ID=your-github-client-id-here +``` + +Create a `.env` file in the **backend directory** with: +``` +PORT=5000 +MONGO_URI=mongodb://127.0.0.1:27017/github_tracker +SESSION_SECRET=your-secret-key-here +GITHUB_CLIENT_ID=your-github-client-id-here +GITHUB_CLIENT_SECRET=your-github-client-secret-here +FRONTEND_URL=http://localhost:5174 +``` + +### Step 3: Install MongoDB (if not already installed) + +For the backend to work, you need MongoDB running: + +**Windows:** +1. Download MongoDB Community Server from [mongodb.com](https://www.mongodb.com/try/download/community) +2. Install and start the MongoDB service + +**Or use Docker:** +```bash +docker run -d -p 27017:27017 --name mongodb mongo:latest +``` + +### Step 4: Start the Application + +1. **Start the backend:** + ```bash + cd backend + npm run dev + ``` + +2. **Start the frontend:** + ```bash + npm run dev + ``` + +3. **Visit the application:** + - Frontend: http://localhost:5174 + - Backend: http://localhost:5000 + +### Step 5: Test GitHub OAuth + +1. Go to http://localhost:5174/login +2. Click "Sign in with GitHub" +3. You should be redirected to GitHub for authorization +4. After authorizing, you'll be redirected back to the application + +### Production Deployment + +For production, update the GitHub OAuth App settings: +- **Homepage URL**: Your production domain +- **Authorization callback URL**: `https://yourdomain.com/auth/github/callback` + +And update the environment variables accordingly. + +### Troubleshooting + +- **"Invalid client" error**: Check that your GitHub Client ID is correct +- **"Redirect URI mismatch"**: Ensure the callback URL in GitHub matches exactly +- **MongoDB connection errors**: Make sure MongoDB is running +- **CORS errors**: Check that the backend CORS configuration allows your frontend URL + +### Security Notes + +- Never commit your `.env` files to version control +- Use strong, unique session secrets +- Consider using environment-specific OAuth apps for development vs production +- Regularly rotate your GitHub OAuth app secrets \ No newline at end of file diff --git a/backend/models/User.js b/backend/models/User.js index 779294f..714c185 100644 --- a/backend/models/User.js +++ b/backend/models/User.js @@ -16,6 +16,29 @@ const UserSchema = new mongoose.Schema({ type: String, required: true, }, + // GitHub OAuth fields + githubId: { + type: String, + unique: true, + sparse: true, + }, + githubUsername: { + type: String, + unique: true, + sparse: true, + }, + avatarUrl: { + type: String, + }, + // Timestamps + createdAt: { + type: Date, + default: Date.now, + }, + updatedAt: { + type: Date, + default: Date.now, + }, }); UserSchema.pre('save', async function (next) { @@ -32,6 +55,12 @@ UserSchema.pre('save', async function (next) { } }); +// Update the updatedAt field on save +UserSchema.pre('save', function(next) { + this.updatedAt = Date.now(); + next(); +}); + // Compare passwords during login UserSchema.methods.comparePassword = async function (enteredPassword) { return await bcrypt.compare(enteredPassword, this.password); diff --git a/backend/routes/auth.js b/backend/routes/auth.js index e26c7a9..45e1daa 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -1,6 +1,7 @@ const express = require("express"); const passport = require("passport"); const User = require("../models/User"); +const crypto = require("crypto"); const router = express.Router(); // Signup route @@ -27,6 +28,117 @@ router.post("/login", passport.authenticate('local'), (req, res) => { res.status(200).json( { message: 'Login successful', user: req.user } ); }); +// GitHub OAuth callback route +router.post("/github/callback", async (req, res) => { + const { code } = req.body; + + if (!code) { + return res.status(400).json({ message: 'Authorization code is required' }); + } + + try { + // Exchange the authorization code for an access token + const tokenResponse = await fetch('https://github.com/login/oauth/access_token', { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + signal: AbortSignal.timeout(10000), // 10 second timeout + body: JSON.stringify({ + client_id: process.env.GITHUB_CLIENT_ID, + client_secret: process.env.GITHUB_CLIENT_SECRET, + code: code, + redirect_uri: `${process.env.FRONTEND_URL || 'http://localhost:5174'}/auth/github/callback` + }), + }); + + if (!tokenResponse.ok) { + return res.status(400).json({ message: 'Failed to authenticate with GitHub' }); + } + + const tokenData = await tokenResponse.json(); + + if (tokenData.error) { + console.error('GitHub token error:', tokenData.error); + return res.status(400).json({ message: 'Failed to authenticate with GitHub' }); + } + + const accessToken = tokenData.access_token; + + // Get user information from GitHub + const userResponse = await fetch('https://api.github.com/user', { + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Accept': 'application/vnd.github.v3+json', + }, + signal: AbortSignal.timeout(10000), // 10 second timeout + }); + + const userData = await userResponse.json(); + + if (!userResponse.ok) { + return res.status(400).json({ message: 'Failed to get user data from GitHub' }); + } + + // Get user emails from GitHub + const emailsResponse = await fetch('https://api.github.com/user/emails', { + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Accept': 'application/vnd.github.v3+json', + }, + signal: AbortSignal.timeout(10000), // 10 second timeout + }); + + const emailsData = await emailsResponse.json(); + const primaryEmail = emailsData.find(email => email.primary)?.email || userData.email; + + // Check if user exists, if not create one + let user = await User.findOne({ email: primaryEmail }); + + if (!user) { + // Create new user with GitHub data + user = new User({ + username: userData.login, + email: primaryEmail, + githubId: userData.id.toString(), // Convert to string + githubUsername: userData.login, + avatarUrl: userData.avatar_url, + // Set a cryptographically secure random password + password: crypto.randomBytes(32).toString('hex') + }); + await user.save(); + } else { + // Update existing user with GitHub info + user.githubId = userData.id.toString(); // Convert to string + user.githubUsername = userData.login; + user.avatarUrl = userData.avatar_url; + await user.save(); + } + + // Log the user in + req.login(user, (err) => { + if (err) { + return res.status(500).json({ message: 'Login failed', error: err.message }); + } + res.status(200).json({ + message: 'GitHub authentication successful', + user: { + id: user._id, + username: user.username, + email: user.email, + githubUsername: user.githubUsername, + avatarUrl: user.avatarUrl + } + }); + }); + + } catch (error) { + console.error('GitHub OAuth error:', error); + res.status(500).json({ message: 'GitHub authentication failed', error: error.message }); + } +}); + // Logout route router.get("/logout", (req, res) => { diff --git a/src/Routes/Router.tsx b/src/Routes/Router.tsx index 900a915..0f98af6 100644 --- a/src/Routes/Router.tsx +++ b/src/Routes/Router.tsx @@ -6,6 +6,7 @@ import Contributors from "../pages/Contributors/Contributors" import Signup from "../pages/Signup/Signup.tsx" import Login from "../pages/Login/Login.tsx" import UserProfile from "../pages/UserProfile/UserProfile.tsx" +import GitHubCallback from "../pages/GitHubCallback/GitHubCallback.tsx" @@ -15,6 +16,7 @@ const Router = () => { {/* Redirect from root (/) to the home page */} } /> } /> + } /> } /> } /> } /> diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx new file mode 100644 index 0000000..087b8f2 --- /dev/null +++ b/src/context/AuthContext.tsx @@ -0,0 +1,121 @@ +import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; +import axios from 'axios'; + +interface User { + id: string; + username: string; + email: string; +} + +interface AuthContextType { + user: User | null; + isAuthenticated: boolean; + isLoading: boolean; + login: (email: string, password: string) => Promise; + signup: (username: string, email: string, password: string) => Promise; + logout: () => Promise; + checkAuthStatus: () => Promise; +} + +const AuthContext = createContext(undefined); + +export const useAuth = () => { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +}; + +interface AuthProviderProps { + children: ReactNode; +} + +export const AuthProvider: React.FC = ({ children }) => { + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + const backendUrl = import.meta.env.VITE_BACKEND_URL || 'http://localhost:5000'; + + const checkAuthStatus = async () => { + try { + const response = await axios.get(`${backendUrl}/api/auth/status`, { + withCredentials: true + }); + + if (response.data.isAuthenticated) { + setUser(response.data.user); + } else { + setUser(null); + } + } catch (error) { + setUser(null); + } finally { + setIsLoading(false); + } + }; + + const login = async (email: string, password: string): Promise => { + try { + const response = await axios.post(`${backendUrl}/api/auth/login`, + { email, password }, + { withCredentials: true } + ); + + if (response.data.message === 'Login successful') { + setUser(response.data.user); + return true; + } + return false; + } catch (error) { + console.error('Login error:', error); + return false; + } + }; + + const signup = async (username: string, email: string, password: string): Promise => { + try { + const response = await axios.post(`${backendUrl}/api/auth/signup`, + { username, email, password }, + { withCredentials: true } + ); + + return response.data.message === 'User created successfully'; + } catch (error) { + console.error('Signup error:', error); + return false; + } + }; + + const logout = async (): Promise => { + try { + await axios.get(`${backendUrl}/api/auth/logout`, { + withCredentials: true + }); + setUser(null); + } catch (error) { + console.error('Logout error:', error); + setUser(null); + } + }; + + useEffect(() => { + checkAuthStatus(); + }, []); + + const value: AuthContextType = { + user, + isAuthenticated: !!user, + isLoading, + login, + signup, + logout, + checkAuthStatus + }; + + return ( + + {children} + + ); +}; \ No newline at end of file diff --git a/src/pages/GitHubCallback/GitHubCallback.tsx b/src/pages/GitHubCallback/GitHubCallback.tsx new file mode 100644 index 0000000..6cff65a --- /dev/null +++ b/src/pages/GitHubCallback/GitHubCallback.tsx @@ -0,0 +1,142 @@ +import React, { useEffect, useState, useContext } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { ThemeContext } from "../../context/ThemeContext"; +import type { ThemeContextType } from "../../context/ThemeContext"; +import { FaGithub, FaCheckCircle, FaExclamationCircle } from "react-icons/fa"; + +const backendUrl = import.meta.env.VITE_BACKEND_URL || 'http://localhost:5000'; + +const GitHubCallback: React.FC = () => { + const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading'); + const [message, setMessage] = useState(''); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const themeContext = useContext(ThemeContext) as ThemeContextType; + const { mode } = themeContext; + + useEffect(() => { + const handleGitHubCallback = async () => { + const code = searchParams.get('code'); + const error = searchParams.get('error'); + + if (error) { + setStatus('error'); + setMessage('GitHub authentication was cancelled or failed.'); + setTimeout(() => navigate('/login'), 3000); + return; + } + + if (!code) { + setStatus('error'); + setMessage('No authorization code received from GitHub.'); + setTimeout(() => navigate('/login'), 3000); + return; + } + + try { + // Exchange the authorization code for an access token + const response = await fetch(`${backendUrl}/api/auth/github/callback`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ code }), + credentials: 'include', + }); + + const data = await response.json(); + + if (response.ok) { + setStatus('success'); + setMessage('Successfully authenticated with GitHub!'); + setTimeout(() => navigate('/'), 2000); + } else { + throw new Error(data.message || 'Authentication failed'); + } + } catch (error: any) { + setStatus('error'); + setMessage(error.message || 'Failed to authenticate with GitHub'); + setTimeout(() => navigate('/login'), 3000); + } + }; + + handleGitHubCallback(); + }, [searchParams, navigate]); + + return ( +
+ {/* Animated background elements */} +
+
+
+
+ +
+
+ + {/* GitHub Icon */} +
+
+ {status === 'loading' ? ( + + ) : status === 'success' ? ( + + ) : ( + + )} +
+
+ + {/* Status Message */} +

+ {status === 'loading' && 'Connecting to GitHub...'} + {status === 'success' && 'Authentication Successful!'} + {status === 'error' && 'Authentication Failed'} +

+ +

+ {message} +

+ + {/* Loading Animation */} + {status === 'loading' && ( +
+
+
+ )} + + {/* Redirect Message */} +

+ {status === 'loading' && 'Please wait while we complete the authentication...'} + {status === 'success' && 'Redirecting you to the dashboard...'} + {status === 'error' && 'Redirecting you back to login...'} +

+
+
+
+ ); +}; + +export default GitHubCallback; \ No newline at end of file diff --git a/src/pages/Login/Login.tsx b/src/pages/Login/Login.tsx index 5cdc342..cbc19f6 100644 --- a/src/pages/Login/Login.tsx +++ b/src/pages/Login/Login.tsx @@ -1,8 +1,9 @@ import React, { useState, ChangeEvent, FormEvent, useContext } from "react"; import axios from "axios"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, Link } from "react-router-dom"; import { ThemeContext } from "../../context/ThemeContext"; import type { ThemeContextType } from "../../context/ThemeContext"; +import { FaGithub, FaEye, FaEyeSlash, FaArrowRight } from "react-icons/fa"; const backendUrl = import.meta.env.VITE_BACKEND_URL; @@ -15,6 +16,8 @@ const Login: React.FC = () => { const [formData, setFormData] = useState({ email: "", password: "" }); const [message, setMessage] = useState(""); const [isLoading, setIsLoading] = useState(false); + const [showPassword, setShowPassword] = useState(false); + const [isGitHubLoading, setIsGitHubLoading] = useState(false); const navigate = useNavigate(); const themeContext = useContext(ThemeContext) as ThemeContextType; @@ -28,6 +31,7 @@ const Login: React.FC = () => { const handleSubmit = async (e: FormEvent) => { e.preventDefault(); setIsLoading(true); + setMessage(""); try { const response = await axios.post(`${backendUrl}/api/auth/login`, formData); @@ -43,6 +47,27 @@ const Login: React.FC = () => { } }; + const handleGitHubSignIn = async () => { + setIsGitHubLoading(true); + setMessage(""); + + try { + const githubClientId = import.meta.env.VITE_GITHUB_CLIENT_ID; + + if (!githubClientId) { + throw new Error('GitHub Client ID not configured'); + } + + const redirectUri = `${window.location.origin}/auth/github/callback`; + const githubAuthUrl = `https://github.com/login/oauth/authorize?client_id=${githubClientId}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=user:email`; + + window.location.href = githubAuthUrl; + } catch (error: any) { + setMessage(error.message || "GitHub authentication failed. Please try again."); + setIsGitHubLoading(false); + } + }; + return (
{ Welcome Back + {/* GitHub Sign In Button */} + + + {/* Divider */} +
+
+
+
+
+ + or continue with email + +
+
+
{
+
@@ -142,10 +214,13 @@ const Login: React.FC = () => { {/* Footer Text */}