From 255d658a8d73b377c73aac1c99bba31709af1b1c Mon Sep 17 00:00:00 2001 From: pratyushranjn Date: Tue, 19 May 2026 21:40:18 +0530 Subject: [PATCH] feat: add GitHub OAuth sign-in support --- backend/.env.sample | 7 ++ backend/config/passportConfig.js | 64 ++++++++++++++++- backend/models/User.js | 20 +++++- backend/package.json | 1 + backend/routes/auth.js | 50 +++++++++++-- backend/server.js | 13 +++- package.json | 2 +- src/pages/Login/Login.tsx | 120 ++++++++++++++++++++++++------- 8 files changed, 238 insertions(+), 39 deletions(-) diff --git a/backend/.env.sample b/backend/.env.sample index 98f9688..ee98083 100644 --- a/backend/.env.sample +++ b/backend/.env.sample @@ -1,3 +1,10 @@ PORT=5000 MONGO_URI=mongodb://localhost:27017/githubTracker SESSION_SECRET=your-secret-key + +# GitHub OAuth +GITHUB_CLIENT_ID=your_client_id +GITHUB_CLIENT_SECRET=your_secret +GITHUB_CALLBACK_URL=http://localhost:5000/api/auth/github/callback + +FRONTEND_URL=http://localhost:5173 \ No newline at end of file diff --git a/backend/config/passportConfig.js b/backend/config/passportConfig.js index 842f50c..db9238f 100644 --- a/backend/config/passportConfig.js +++ b/backend/config/passportConfig.js @@ -1,5 +1,6 @@ const passport = require("passport"); const LocalStrategy = require('passport-local').Strategy; +const GitHubStrategy = require('passport-github2').Strategy; const User = require("../models/User"); passport.use( @@ -7,9 +8,13 @@ passport.use( { usernameField: "email" }, async (email, password, done) => { try { - const user = await User.findOne( {email} ); + const user = await User.findOne({ email }); if (!user) { - return done(null, false, { message: 'Email is invalid '}); + return done(null, false, { message: 'Email is invalid ' }); + } + + if (!user.password) { + return done(null, false, { message: 'Use GitHub sign in for this account' }); } const isMatch = await user.comparePassword(password); @@ -18,7 +23,7 @@ passport.use( } return done(null, { - id : user._id.toString(), + id: user._id.toString(), username: user.username, email: user.email }); @@ -29,6 +34,59 @@ passport.use( ) ); +if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET) { + passport.use( + new GitHubStrategy( + { + clientID: process.env.GITHUB_CLIENT_ID, + clientSecret: process.env.GITHUB_CLIENT_SECRET, + callbackURL: process.env.GITHUB_CALLBACK_URL, + scope: ['user:email'], + }, + async (accessToken, refreshToken, profile, done) => { + try { + const primaryEmail = profile.emails?.[0]?.value || null; + const avatar = profile.photos?.[0]?.value || ""; + + let user = await User.findOne({ githubId: profile.id }); + console.log(user); + if (!user && primaryEmail) { + user = await User.findOne({ email: primaryEmail }); + } + + if (!user) { + const loginName = profile.username || `github_${profile.id}`; + const uniqueSuffix = Math.random().toString(36).slice(2, 7); + + user = new User({ + githubId: profile.id, + username: `${loginName}_${uniqueSuffix}`, + email: primaryEmail, + avatar, + }); + console.log("New User\n", user); + } else { + user.githubId = user.githubId || profile.id; + user.email = user.email || primaryEmail; + user.avatar = user.avatar || avatar; + } + + await user.save(); + + return done(null, { + id: user._id.toString(), + username: user.username, + email: user.email, + }); + + } catch (err) { + return done(err); + } + } + ) + ); +} + // Serialize user (store user info in session) passport.serializeUser((user, done) => { done(null, user.id); diff --git a/backend/models/User.js b/backend/models/User.js index 779294f..015c90f 100644 --- a/backend/models/User.js +++ b/backend/models/User.js @@ -9,13 +9,26 @@ const UserSchema = new mongoose.Schema({ }, email: { type: String, - required: true, + required: function requiredEmail() { + return !this.githubId; + }, unique: true, + sparse: true, }, password: { type: String, - required: true, + required: function requiredPassword() { + return !this.githubId; + }, + }, + githubId: { + type: String, + unique: true, + sparse: true, }, + avatar: { + type: String, + }, }); UserSchema.pre('save', async function (next) { @@ -34,6 +47,9 @@ UserSchema.pre('save', async function (next) { // Compare passwords during login UserSchema.methods.comparePassword = async function (enteredPassword) { + if (!this.password) + return false; + return await bcrypt.compare(enteredPassword, this.password); }; diff --git a/backend/package.json b/backend/package.json index 9891c08..82b8f53 100644 --- a/backend/package.json +++ b/backend/package.json @@ -20,6 +20,7 @@ "express-session": "^1.18.1", "mongoose": "^8.8.2", "passport": "^0.7.0", + "passport-github2": "^0.1.12", "passport-local": "^1.0.0" }, "devDependencies": { diff --git a/backend/routes/auth.js b/backend/routes/auth.js index e26c7a9..aaa53ff 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -3,20 +3,23 @@ const passport = require("passport"); const User = require("../models/User"); const router = express.Router(); +const frontendUrl = process.env.FRONTEND_URL || "http://localhost:5173"; +const isGitHubConfigured = Boolean(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET && process.env.GITHUB_CALLBACK_URL); + // Signup route router.post("/signup", async (req, res) => { - const { username, email, password } = req.body; + const { username, email, password } = req.body; try { - const existingUser = await User.findOne( {email} ); + const existingUser = await User.findOne({ email }); if (existingUser) - return res.status(400).json( {message: 'User already exists'} ); + return res.status(400).json({ message: 'User already exists' }); - const newUser = new User( {username, email, password} ); + const newUser = new User({ username, email, password }); await newUser.save(); - res.status(201).json( {message: 'User created successfully'} ); + res.status(201).json({ message: 'User created successfully' }); } catch (err) { res.status(500).json({ message: 'Error creating user', error: err.message }); } @@ -24,7 +27,42 @@ router.post("/signup", async (req, res) => { // Login route router.post("/login", passport.authenticate('local'), (req, res) => { - res.status(200).json( { message: 'Login successful', user: req.user } ); + res.status(200).json({ message: 'Login successful', user: req.user }); +}); + +// GitHub OAuth start route +router.get('/github', (req, res, next) => { + if (!isGitHubConfigured) { + return res.status(503).json({ message: 'GitHub OAuth is not configured' }); + } + + return passport.authenticate('github', { scope: ['user:email'] })(req, res, next); +}); + +// GitHub OAuth callback route +router.get('/github/callback', (req, res, next) => { + if (!isGitHubConfigured) { + return res.redirect(`${frontendUrl}/login?githubAuth=not_configured`); + } + + return passport.authenticate('github', { + failureRedirect: `${frontendUrl}/login?githubAuth=failed`, + })(req, res, next); +}, (_req, res) => { + res.redirect(`${frontendUrl}/login?githubAuth=success`); +}); + +router.get("/me", (req, res) => { + if (!req.user) { + return res.status(401).json({ + authenticated: false, + }); + } + + return res.status(200).json({ + authenticated: true, + user: req.user, + }); }); // Logout route diff --git a/backend/server.js b/backend/server.js index 3f19f00..2dea28a 100644 --- a/backend/server.js +++ b/backend/server.js @@ -10,16 +10,25 @@ const cors = require('cors'); require('./config/passportConfig'); const app = express(); +const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173'; // CORS configuration -app.use(cors('*')); +app.use(cors({ + origin: frontendUrl, + credentials: true, +})); // Middleware app.use(bodyParser.json()); app.use(session({ - secret: process.env.SESSION_SECRET, + secret: process.env.SESSION_SECRET || 'dev-session-secret', resave: false, saveUninitialized: false, + cookie: { + secure: process.env.NODE_ENV === 'production', + httpOnly: true, + sameSite: 'lax', + }, })); app.use(passport.initialize()); app.use(passport.session()); diff --git a/package.json b/package.json index f2d89f5..a369248 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,6 @@ "passport": "^0.7.0", "passport-local": "^1.0.0", "supertest": "^7.1.4", - "vite": "^5.4.10" + "vite": "^8.0.13" } } diff --git a/src/pages/Login/Login.tsx b/src/pages/Login/Login.tsx index d6f21a7..ae15839 100644 --- a/src/pages/Login/Login.tsx +++ b/src/pages/Login/Login.tsx @@ -3,6 +3,9 @@ import axios from "axios"; import { useNavigate, Link } from "react-router-dom"; import { ThemeContext } from "../../context/ThemeContext"; import type { ThemeContextType } from "../../context/ThemeContext"; +import { FaGithub } from "react-icons/fa"; +import { motion } from "framer-motion"; +import toast from "react-hot-toast"; const backendUrl = import.meta.env.VITE_BACKEND_URL; @@ -15,6 +18,7 @@ const Login: React.FC = () => { const [formData, setFormData] = useState({ email: "", password: "" }); const [message, setMessage] = useState(""); const [isLoading, setIsLoading] = useState(false); + const [githubLoading, setGithubLoading] = useState(false); const navigate = useNavigate(); const themeContext = useContext(ThemeContext) as ThemeContextType; @@ -43,13 +47,43 @@ const Login: React.FC = () => { } }; + const handleGitHubSignIn = () => { + setGithubLoading(true); + window.location.href = `${backendUrl}/api/auth/github`; + }; + + React.useEffect(() => { + const githubAuthStatus = new URLSearchParams(window.location.search).get("githubAuth"); + + if (githubAuthStatus === "success") { + toast.success("GitHub login successful"); + + window.history.replaceState({}, document.title, window.location.pathname); + + setTimeout(() => { + navigate("/track"); + }, 1000); + } + + if (githubAuthStatus === "failed") { + toast.error("GitHub sign in failed. Please try again."); + + window.history.replaceState({}, document.title, window.location.pathname); + } + + if (githubAuthStatus === "not_configured") { + toast.error("GitHub sign in is not configured on server."); + + window.history.replaceState({}, document.title, window.location.pathname); + } + }, [navigate]); + return (
{/* Animated background elements */}
@@ -66,11 +100,10 @@ const Login: React.FC = () => { Logo
-

+

GitHubTracker

@@ -94,11 +127,10 @@ const Login: React.FC = () => { onChange={handleChange} autoComplete="username" required - className={`w-full pl-4 pr-4 py-4 rounded-2xl focus:outline-none transition-all ${ - mode === "dark" - ? "bg-white/5 border border-white/10 text-white placeholder-slate-400 focus:ring-2 focus:ring-purple-500" - : "bg-gray-100 border border-gray-300 text-gray-900 placeholder-gray-500 focus:ring-2 focus:ring-purple-400" - }`} + className={`w-full pl-4 pr-4 py-4 rounded-2xl focus:outline-none transition-all ${mode === "dark" + ? "bg-white/5 border border-white/10 text-white placeholder-slate-400 focus:ring-2 focus:ring-purple-500" + : "bg-gray-100 border border-gray-300 text-gray-900 placeholder-gray-500 focus:ring-2 focus:ring-purple-400" + }`} />

@@ -111,11 +143,10 @@ const Login: React.FC = () => { value={formData.password} onChange={handleChange} required - className={`w-full pl-4 pr-4 py-4 rounded-2xl focus:outline-none transition-all ${ - mode === "dark" - ? "bg-white/5 border border-white/10 text-white placeholder-slate-400 focus:ring-2 focus:ring-purple-500" - : "bg-gray-100 border border-gray-300 text-gray-900 placeholder-gray-500 focus:ring-2 focus:ring-purple-400" - }`} + className={`w-full pl-4 pr-4 py-4 rounded-2xl focus:outline-none transition-all ${mode === "dark" + ? "bg-white/5 border border-white/10 text-white placeholder-slate-400 focus:ring-2 focus:ring-purple-500" + : "bg-gray-100 border border-gray-300 text-gray-900 placeholder-gray-500 focus:ring-2 focus:ring-purple-400" + }`} /> @@ -126,15 +157,54 @@ const Login: React.FC = () => { > {isLoading ? "Signing in..." : "Sign In"} + + {/* Message */} {message && ( -
+
{message}
)}