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..e8c5f57 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,57 @@ 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 }); + 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, + }); + } 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 aeb0951..1f90ad1 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 () { @@ -28,6 +41,9 @@ UserSchema.pre('save', async function () { // 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 38e15b8..8118210 100644 --- a/backend/package.json +++ b/backend/package.json @@ -6,7 +6,6 @@ "dev": "nodemon server.js", "start": "node server.js", "test": "jasmine spec/**/*.spec.cjs" - }, "keywords": [], "author": "", @@ -21,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", "zod": "^4.4.3" }, diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 7c2cda7..6cb38dd 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -5,10 +5,41 @@ const { signupSchema, loginSchema } = require("../validators/authValidator"); const { validateRequest } = require("../validators/validationRequest"); 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); + +// GitHub OAuth start route +router.get("/github", (req, res, next) => { + if (!isGitHubConfigured) { + return res.redirect(`${frontendUrl}/login?githubAuth=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 next(); + }, + passport.authenticate("github", { + failureRedirect: `${frontendUrl}/login?githubAuth=failed`, + session: true, + }), + (_req, res) => { + return res.redirect(`${frontendUrl}/login?githubAuth=success`); + } +); + // Signup route router.post("/signup", validateRequest(signupSchema), async (req, res) => { - const { username, email, password } = req.body; + const { username, email, password } = req.body; try { const existingUser = await User.findOne({ 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/src/pages/Login/Login.tsx b/src/pages/Login/Login.tsx index e77ee3b..8afdd22 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}
)}