diff --git a/backend/config/passport.js b/backend/config/passport.js index 92767f4..c4c1149 100644 --- a/backend/config/passport.js +++ b/backend/config/passport.js @@ -1,5 +1,6 @@ const passport = require("passport"); const GoogleStrategy = require("passport-google-oauth20").Strategy; +const GitHubStrategy = require("passport-github2").Strategy; const User = require("../models/User"); console.log('Initializing Google OAuth strategy...'); @@ -35,6 +36,52 @@ passport.use( ) ); +// GitHub OAuth +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 || "http://localhost:5000/auth/github/callback", + scope: ["read:user", "user:email"], + }, + async (accessToken, refreshToken, profile, done) => { + try { + let email = null; + if (Array.isArray(profile.emails) && profile.emails.length > 0) { + email = profile.emails.find(e => e.verified)?.value || profile.emails[0].value; + } + + let user = await User.findOne({ githubId: profile.id }); + if (!user && email) { + // Optional linking by email if previously registered + user = await User.findOne({ email }); + if (user && !user.githubId) { + user.githubId = profile.id; + if (!user.avatar) user.avatar = profile.photos?.[0]?.value; + await user.save(); + } + } + + if (!user) { + user = new User({ + githubId: profile.id, + name: profile.displayName || profile.username, + email: email, + avatar: profile.photos?.[0]?.value, + }); + await user.save(); + } + return done(null, user); + } catch (err) { + return done(err, null); + } + } + ) + ); +} + // serialize + deserialize passport.serializeUser((user, done) => { done(null, user.id); diff --git a/backend/models/User.js b/backend/models/User.js index 1a23c04..73d8fee 100644 --- a/backend/models/User.js +++ b/backend/models/User.js @@ -2,6 +2,11 @@ const mongoose = require('mongoose'); const Schema = mongoose.Schema; const UserSchema = new Schema({ + githubId: { + type: String, + unique: true, + sparse: true, + }, googleId: { type: String, unique: true, diff --git a/backend/package-lock.json b/backend/package-lock.json index e772e60..be36494 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -26,6 +26,7 @@ "node-fetch": "^3.3.2", "nodemailer": "^7.0.6", "passport": "^0.7.0", + "passport-github2": "^0.1.12", "passport-google-oauth20": "^2.0.0", "rate-limiter-flexible": "^7.3.0", "resend": "^6.0.1" @@ -1751,6 +1752,17 @@ "url": "https://github.com/sponsors/jaredhanson" } }, + "node_modules/passport-github2": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/passport-github2/-/passport-github2-0.1.12.tgz", + "integrity": "sha512-3nPUCc7ttF/3HSP/k9sAXjz3SkGv5Nki84I05kSQPo01Jqq1NzJACgMblCK0fGcv9pKCG/KXU3AJRDGLqHLoIw==", + "dependencies": { + "passport-oauth2": "1.x.x" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/passport-google-oauth20": { "version": "2.0.0", "license": "MIT", diff --git a/backend/package.json b/backend/package.json index fe1156d..4cfe0a8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -21,10 +21,11 @@ "jsonwebtoken": "^9.0.2", "mongoose": "^8.17.1", "multer": "^2.0.2", - "node-fetch": "^3.3.2", "node-cron": "^4.2.1", + "node-fetch": "^3.3.2", "nodemailer": "^7.0.6", "passport": "^0.7.0", + "passport-github2": "^0.1.12", "passport-google-oauth20": "^2.0.0", "rate-limiter-flexible": "^7.3.0", "resend": "^6.0.1" diff --git a/backend/routes/auth.js b/backend/routes/auth.js index a6fd745..88cecae 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -72,6 +72,31 @@ router.get( } ); +// GitHub OAuth +router.get( + "/github", + passport.authenticate("github", { scope: ["read:user", "user:email"] }) +); + +router.get( + "/github/callback", + passport.authenticate("github", { + failureRedirect: `${process.env.CLIENT_URL}/login`, + failureMessage: true, + session: true, + }), + async (req, res) => { + try { + const token = await generateJWT(req.user.id); + const redirectUrl = `${process.env.CLIENT_URL}/dashboard?token=${encodeURIComponent(token)}`; + return res.redirect(redirectUrl); + } catch (err) { + console.error('JWT generation failed after GitHub OAuth:', err); + return res.redirect(`${process.env.CLIENT_URL}/login?error=github_oauth_token_failed`); + } + } +); + // @route POST api/auth/register // @desc Register user // @access Public diff --git a/frontend/src/Components/Dashboard.jsx b/frontend/src/Components/Dashboard.jsx index 1bdc43a..212d66a 100644 --- a/frontend/src/Components/Dashboard.jsx +++ b/frontend/src/Components/Dashboard.jsx @@ -20,9 +20,10 @@ export default function Dashboard() { const [goals, setGoals] = useState([]); const navigate = useNavigate(); - const token = localStorage.getItem("token"); + const getAuthToken = () => localStorage.getItem("token"); const fetchProfile = async () => { + const token = getAuthToken(); if (!token) { navigate("/login"); setLoading(false); @@ -78,6 +79,7 @@ export default function Dashboard() { const handleGoalsChange = async (updatedGoals) => { setGoals(updatedGoals); try { + const token = getAuthToken(); await fetch(`${import.meta.env.VITE_API_URL}/api/profile/goals`, { method: "PUT", headers: { "Content-Type": "application/json", "x-auth-token": token }, @@ -91,6 +93,7 @@ export default function Dashboard() { const handleNotesChange = async (updatedNotes) => { setProfile((prev) => ({ ...prev, notes: updatedNotes })); try { + const token = getAuthToken(); await fetch(`${import.meta.env.VITE_API_URL}/api/profile/notes`, { method: "PUT", headers: { "Content-Type": "application/json", "x-auth-token": token }, @@ -104,6 +107,7 @@ export default function Dashboard() { const handleActivityAdd = async (date) => { setProfile((prev) => ({ ...prev, activity: [...prev.activity, date] })); try { + const token = getAuthToken(); await fetch(`${import.meta.env.VITE_API_URL}/api/profile/activity`, { method: "PUT", headers: { "Content-Type": "application/json", "x-auth-token": token }, @@ -117,6 +121,7 @@ export default function Dashboard() { const handleTimeUpdate = async (newTime) => { setProfile((prev) => ({ ...prev, timeSpent: newTime })); try { + const token = getAuthToken(); await fetch(`${import.meta.env.VITE_API_URL}/api/profile/time`, { method: "PUT", headers: { "Content-Type": "application/json", "x-auth-token": token }, @@ -193,7 +198,7 @@ export default function Dashboard() { activityData={activity} onAddActivity={async (day) => { try { - const token = localStorage.getItem("token"); + const token = getAuthToken(); const res = await fetch( `${import.meta.env.VITE_API_URL}/api/profile/activity`, { diff --git a/frontend/src/Components/auth/Login.jsx b/frontend/src/Components/auth/Login.jsx index 6d1182b..893a224 100644 --- a/frontend/src/Components/auth/Login.jsx +++ b/frontend/src/Components/auth/Login.jsx @@ -78,6 +78,10 @@ const Login = () => { window.location.href = `${import.meta.env.VITE_API_URL}/auth/google`; }; + const handleGithubLogin = () => { + window.location.href = `${import.meta.env.VITE_API_URL}/auth/github`; + }; + // Show verification component if user needs to verify email if (showVerification) { return ( @@ -243,6 +247,7 @@ const Login = () => {