diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..5e49e1a Binary files /dev/null and b/backend/.gitignore differ diff --git a/backend/config/passport.js b/backend/config/passport.js index 92767f4..9385d7e 100644 --- a/backend/config/passport.js +++ b/backend/config/passport.js @@ -1,50 +1,186 @@ 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...'); -console.log('Callback URL:', process.env.GOOGLE_CALLBACK_URL); - -passport.use( - new GoogleStrategy( - { - clientID: process.env.GOOGLE_CLIENT_ID, - clientSecret: process.env.GOOGLE_CLIENT_SECRET, - callbackURL: process.env.GOOGLE_CALLBACK_URL || "http://localhost:5000/auth/callback", - }, - async (accessToken, refreshToken, profile, done) => { - console.log('Google profile received:', profile); - try { - let user = await User.findOne({ googleId: profile.id }); - - if (!user) { - console.log('Creating new user from Google profile'); - user = new User({ +// Only use Google Strategy if credentials are provided +if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) { + console.log('Initializing Google OAuth strategy...'); + console.log('Callback URL:', process.env.GOOGLE_CALLBACK_URL); + + passport.use( + new GoogleStrategy( + { + clientID: process.env.GOOGLE_CLIENT_ID, + clientSecret: process.env.GOOGLE_CLIENT_SECRET, + callbackURL: process.env.GOOGLE_CALLBACK_URL || "http://localhost:5000/auth/callback", + }, + async (accessToken, refreshToken, profile, done) => { + console.log('Google profile received:', profile); + try { + // Simplified to avoid MongoDB dependency + const user = { + id: profile.id, googleId: profile.id, name: profile.displayName, email: profile.emails && profile.emails[0] ? profile.emails[0].value : null, + isEmailVerified: true + }; + return done(null, user); + } catch (err) { + return done(err, null); + } + } + ) + ); +} + +// GitHub OAuth Strategy - Only use if credentials are provided + +if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET) { + console.log("Initializing GitHub OAuth Strategy with:", { + clientID: process.env.GITHUB_CLIENT_ID?.substring(0, 5) + '...', + callbackURL: process.env.GITHUB_CALLBACK_URL + }); + + 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", "read:user", "public_repo"], + passReqToCallback: true + }, + async (req, accessToken, refreshToken, profile, done) => { + try { + console.log("GitHub profile received:", profile.username); + console.log("GitHub auth successful, processing user data"); + + // Fetch additional GitHub data + let githubData = {}; + try { + const res = await fetch(`https://api.github.com/user/${profile.id}`, { + headers: { + 'Accept': 'application/vnd.github.v3+json', + 'Authorization': `token ${accessToken}` + } + }); + if (res.ok) githubData = await res.json(); + } catch (err) { + console.error("Error fetching GitHub user data:", err); + } + + // Find or create user manually instead of using the static method + let user = await User.findOne({ + $or: [ + { githubId: profile.id }, + { email: profile.emails?.[0]?.value } + ] }); + + // If user exists, update GitHub information + if (user) { + console.log("Found existing user, updating GitHub info"); + user.githubId = profile.id; + user.githubUsername = profile.username; + user.name = user.name || profile.displayName || profile.username; + user.accessToken = accessToken; + + // Update email if not already set + if (!user.email && profile.emails && profile.emails[0]) { + user.email = profile.emails[0].value; + } + + // Update avatar if using default + if (user.avatar === '/uploads/avatars/default-avatar.png' && profile.photos && profile.photos[0]) { + user.avatar = profile.photos[0].value; + } + + // Update GitHub in social links + if (!user.socialLinks) user.socialLinks = {}; + user.socialLinks.github = profile._json?.html_url || `https://github.com/${profile.username}`; + } else { + // Create new user if not found + console.log("Creating new user from GitHub profile"); + user = new User({ + githubId: profile.id, + githubUsername: profile.username, + name: profile.displayName || profile.username, + email: profile.emails?.[0]?.value || `${profile.username}@github.user`, + avatar: profile.photos?.[0]?.value || "/uploads/avatars/default-avatar.png", + accessToken: accessToken, + isEmailVerified: true, // GitHub emails are verified + socialLinks: { + github: profile._json?.html_url || `https://github.com/${profile.username}` + } + }); + } + + // Add GitHub platform data + if (Object.keys(githubData).length > 0) { + const platformData = { + name: 'GitHub', + username: profile.username, + url: profile._json?.html_url || `https://github.com/${profile.username}`, + followers: githubData?.followers || 0, + following: githubData?.following || 0, + repos: githubData?.public_repos || 0, + lastUpdated: new Date() + }; + + // Find or create the GitHub platform entry + if (!user.platforms) user.platforms = []; + const existingPlatform = user.platforms.findIndex(p => p.name === 'GitHub'); + + if (existingPlatform === -1) { + user.platforms.push(platformData); + } else { + user.platforms[existingPlatform] = { + ...user.platforms[existingPlatform], + ...platformData + }; + } + } + await user.save(); - } - return done(null, user); - } catch (err) { - return done(err, null); + return done(null, user); + } catch (err) { + console.error("Error in GitHub strategy:", err); + return done(err, null); + } } - } - ) -); + ) + ); +} -// serialize + deserialize +// serialize + deserialize (improved to store full user object) passport.serializeUser((user, done) => { - done(null, user.id); + console.log("Serializing user:", user.id || user.githubId || user.googleId); + // Store the whole user object instead of just the ID + // This avoids needing to retrieve the user from the database on every request + + // Make sure we're preserving the access token + if (user.accessToken) { + console.log("Access token preserved in session"); + } else { + console.log("WARNING: No access token available in user object during serialization"); + } + + done(null, user); }); -passport.deserializeUser(async (id, done) => { - try { - const user = await User.findById(id); - done(null, user); - } catch (err) { - done(err, null); +passport.deserializeUser((user, done) => { + console.log("Deserializing user:", user.id || user.githubId || user.googleId); + + // Confirm access token availability during deserialization + if (user.accessToken) { + console.log("Access token available during deserialization"); + } else { + console.log("WARNING: Access token missing during deserialization"); } + + // Simply pass through the user object + done(null, user); }); diff --git a/backend/db/connection.js b/backend/db/connection.js index 8f6fc83..617c565 100644 --- a/backend/db/connection.js +++ b/backend/db/connection.js @@ -2,9 +2,15 @@ require('dotenv').config(); const mongoose = require('mongoose'); +// Only connect to MongoDB if MONGODB_URI is provided and not testing mode const dburl = process.env.MONGODB_URI; -mongoose.connect(dburl).then(() => { - console.log("Connected to DB Successfully "); -}).catch((err) => { - console.log(err.message); -}); \ No newline at end of file +if (dburl && process.env.NODE_ENV !== 'test-auth') { + mongoose.connect(dburl).then(() => { + console.log("Connected to DB Successfully "); + }).catch((err) => { + console.log("MongoDB connection error:", err.message); + console.log("Continuing without MongoDB for authentication testing..."); + }); +} else { + console.log("MongoDB connection skipped for authentication testing"); +} diff --git a/backend/env.example b/backend/env.example index 26d4331..ade1d26 100644 --- a/backend/env.example +++ b/backend/env.example @@ -4,6 +4,9 @@ JWT_SECRET= GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= GOOGLE_CALLBACK_URL= +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= +GITHUB_CALLBACK_URL= CLIENT_URL= SESSION_SECRET= ADMIN_EMAIL= diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js index db40e01..aba49b5 100644 --- a/backend/middleware/auth.js +++ b/backend/middleware/auth.js @@ -1,25 +1,37 @@ -const jwt = require('jsonwebtoken'); -require('dotenv').config(); +const jwt = require("jsonwebtoken"); +require("dotenv").config(); +const User = require("../models/User"); -// Use a fallback JWT secret if env variable is missing -const JWT_SECRET = process.env.JWT_SECRET || 'devsync_secure_jwt_secret_key_for_authentication'; +const JWT_SECRET = process.env.JWT_SECRET || "devsync_secure_jwt_secret_key_for_authentication"; -module.exports = function(req, res, next) { - // Get token from header - const token = req.header('x-auth-token'); +module.exports = async function (req, res, next) { + try { + const token = req.header("x-auth-token"); - // Check if no token - if (!token) { - return res.status(401).json({ errors: [{ msg: 'No token, authorization denied' }] }); - } + if (token) { + try { + const decoded = jwt.verify(token, JWT_SECRET); + const user = await User.findById(decoded.user.id).select("-password"); + if (!user) return res.status(401).json({ errors: [{ msg: "Unauthorized user" }] }); - // Verify token - try { - const decoded = jwt.verify(token, JWT_SECRET); - req.user = decoded.user; - next(); + req.user = user; + req.authMethod = "token"; + return next(); + } catch (err) { + console.error("JWT verification failed:", err.message); + return res.status(401).json({ errors: [{ msg: "Token is not valid" }] }); + } + } + + if (req.isAuthenticated && req.isAuthenticated()) { + req.user = req.user; + req.authMethod = "session"; + return next(); + } + + return res.status(401).json({ errors: [{ msg: "Not authenticated" }] }); } catch (err) { - console.error('Token verification error:', err.message); - res.status(401).json({ errors: [{ msg: 'Token is not valid' }] }); + console.error("Auth middleware error:", err); + res.status(500).json({ errors: [{ msg: "Server error" }] }); } }; \ No newline at end of file diff --git a/backend/middleware/rateLimit/authLimiterMiddleware.js b/backend/middleware/rateLimit/authLimiterMiddleware.js index 59a22fb..db01791 100644 --- a/backend/middleware/rateLimit/authLimiterMiddleware.js +++ b/backend/middleware/rateLimit/authLimiterMiddleware.js @@ -1,7 +1,7 @@ const { RateLimiterMemory } = require('rate-limiter-flexible'); exports.authLimiter = new RateLimiterMemory({ - points: 5, - duration: 60, - blockDuration: 60 * 5, + points: 20, // Increased from 5 to 20 attempts + duration: 60, // Per minute + blockDuration: 60 * 2, // Reduced block time to 2 minutes }) \ No newline at end of file diff --git a/backend/models/User.js b/backend/models/User.js index 1a23c04..0b4e314 100644 --- a/backend/models/User.js +++ b/backend/models/User.js @@ -7,6 +7,15 @@ const UserSchema = new Schema({ unique: true, sparse: true, // multiple nulls allowed }, + githubId: { + type: String, + unique: true, + sparse: true, // multiple nulls allowed + }, + githubUsername: { + type: String, + sparse: true, + }, name: { type: String, required: true, @@ -21,10 +30,11 @@ const UserSchema = new Schema({ }, emailVerificationToken: String, emailVerificationExpires: Date, + accessToken: String, // Store OAuth access tokens (GitHub, Google) password: { type: String, required: function () { - return !this.googleId; + return !this.googleId && !this.githubId; }, }, avatar: { @@ -89,6 +99,27 @@ const UserSchema = new Schema({ type: [String], default: [] }, + + // Platform-specific data (GitHub, LeetCode, etc.) + platforms: [{ + name: { + type: String, + enum: ['GitHub', 'LeetCode', 'CodeForces', 'HackerRank', 'CodeChef', 'HackerEarth'] + }, + username: String, + url: String, + followers: Number, + following: Number, + repos: Number, + contributions: Number, + streak: Number, + ranking: Number, + score: Number, + lastUpdated: { + type: Date, + default: Date.now + } + }], // ✅ Fields for forgot/reset password resetPasswordToken: String, @@ -100,4 +131,106 @@ const UserSchema = new Schema({ } }); -module.exports = mongoose.model('User', UserSchema); +// Static method to find or create a user by GitHub profile +UserSchema.statics.findOrCreateByGitHub = async function(profile, accessToken) { + try { + // Find existing user by githubId or email + let user = await this.findOne({ + $or: [ + { githubId: profile.id }, + { email: profile.emails && profile.emails[0] ? profile.emails[0].value : null } + ] + }); + + // If user exists, update GitHub information + if (user) { + user.githubId = profile.id; + user.githubUsername = profile.username; + user.name = user.name || profile.displayName || profile.username; + user.accessToken = accessToken; + + // Update email if not already set + if (!user.email && profile.emails && profile.emails[0]) { + user.email = profile.emails[0].value; + } + + // Update avatar if using default + if (user.avatar === '/uploads/avatars/default-avatar.png' && profile.photos && profile.photos[0]) { + user.avatar = profile.photos[0].value; + } + + // Update GitHub in social links + if (!user.socialLinks) user.socialLinks = {}; + user.socialLinks.github = profile._json?.html_url || `https://github.com/${profile.username}`; + + await user.save(); + return user; + } + + // Create new user if not found + const newUser = await this.create({ + githubId: profile.id, + githubUsername: profile.username, + name: profile.displayName || profile.username, + email: profile.emails && profile.emails[0] ? profile.emails[0].value : `${profile.username}@github.user`, + avatar: profile.photos && profile.photos[0] ? profile.photos[0].value : '/uploads/avatars/default-avatar.png', + accessToken: accessToken, + isEmailVerified: true, // GitHub emails are verified + socialLinks: { + github: profile._json?.html_url || `https://github.com/${profile.username}` + } + }); + + return newUser; + } catch (err) { + console.error('Error in findOrCreateByGitHub:', err); + throw err; + } +}; + +// Method to update GitHub data using the GitHub API +UserSchema.methods.updateGitHubData = async function(accessToken) { + try { + if (!this.githubUsername) return false; + + // Make API call to GitHub + const response = await fetch(`https://api.github.com/users/${this.githubUsername}`, { + headers: { + 'Accept': 'application/vnd.github.v3+json', + 'Authorization': `token ${accessToken || this.accessToken}` + } + }); + + if (!response.ok) throw new Error(`GitHub API error: ${response.status}`); + const data = await response.json(); + + // Find or create the GitHub platform entry + let githubPlatform = this.platforms?.find(p => p.name === 'GitHub'); + + if (!githubPlatform) { + if (!this.platforms) this.platforms = []; + this.platforms.push({ + name: 'GitHub', + username: this.githubUsername, + url: data.html_url, + followers: data.followers || 0, + following: data.following || 0, + repos: data.public_repos || 0, + lastUpdated: new Date() + }); + } else { + githubPlatform.followers = data.followers || 0; + githubPlatform.following = data.following || 0; + githubPlatform.repos = data.public_repos || 0; + githubPlatform.lastUpdated = new Date(); + } + + await this.save(); + return true; + } catch (err) { + console.error('Error updating GitHub data:', err); + return false; + } +}; + +module.exports = mongoose.model('User', UserSchema); \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index e772e60..76bee0e 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -22,10 +22,10 @@ "jsonwebtoken": "^9.0.2", "mongoose": "^8.17.1", "multer": "^2.0.2", - "node-cron": "^4.2.1", - "node-fetch": "^3.3.2", + "node-fetch": "^2.7.0", "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" @@ -1555,49 +1555,46 @@ "node": "^18 || ^20 || >= 21" } }, - "node_modules/node-cron": { - "version": "4.2.1", - "license": "ISC", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "deprecated": "Use your platform's native DOMException instead", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "engines": { - "node": ">=10.5.0" - } - }, "node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "license": "MIT", "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" + "whatwg-url": "^5.0.0" }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": "4.x || >=6.0.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" } }, "node_modules/node-gyp-build": { @@ -1751,8 +1748,21 @@ "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", + "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", + "integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==", "license": "MIT", "dependencies": { "passport-oauth2": "1.x.x" diff --git a/backend/package.json b/backend/package.json index fe1156d..20e34ef 100644 --- a/backend/package.json +++ b/backend/package.json @@ -21,10 +21,10 @@ "jsonwebtoken": "^9.0.2", "mongoose": "^8.17.1", "multer": "^2.0.2", - "node-fetch": "^3.3.2", - "node-cron": "^4.2.1", + "node-fetch": "^2.7.0", "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..082e4b5 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -72,6 +72,67 @@ router.get( } ); +// Start GitHub OAuth flow +router.get( + "/github", + (req, res, next) => { + console.log("GitHub auth route hit"); + console.log("GitHub credentials:", { + clientID: process.env.GITHUB_CLIENT_ID?.substring(0, 5) + '...', + callbackURL: process.env.GITHUB_CALLBACK_URL + }); + // Store where the user came from (register or login) in the session + if (req.query.from) { + req.session.authFrom = req.query.from; + console.log(`Auth request from: ${req.query.from}`); + } + next(); + }, + passport.authenticate("github", { + scope: ["user:email", "read:user", "public_repo", "read:org", "user:follow"] + }) +); + +// Handle callback from GitHub +router.get( + "/github/callback", + (req, res, next) => { + console.log("GitHub callback received:", req.url); + next(); + }, + passport.authenticate("github", { + failureRedirect: `${process.env.CLIENT_URL || "http://localhost:5173"}/register?error=github_auth`, + session: true, + failWithError: true + }), + (req, res) => { + const user = req.user; + + // Create JWT for frontend auth + const token = jwt.sign({ user: { id: user._id || user.id } }, JWT_SECRET, { expiresIn: "7d" }); + + // Grab GitHub token from user object + const githubToken = user.accessToken || null; + + // Store GitHub token in session + req.session.accessToken = githubToken; + req.session.authMethod = "github"; + req.session.isAuthenticated = true; + + console.log("GitHub auth successful, redirecting with tokens"); + console.log("JWT token available:", !!token); + console.log("GitHub token available:", !!githubToken); + + // Redirect to frontend dashboard + const frontendUrl = process.env.CLIENT_URL || "http://localhost:5173"; + res.redirect(`${frontendUrl}/dashboard?token=${token}&github_token=${githubToken}`); + }, + (err, req, res, next) => { + console.error("GitHub auth error:", err); + res.redirect(`${process.env.CLIENT_URL || "http://localhost:5173"}/register?error=github`); + } +); + // @route POST api/auth/register // @desc Register user // @access Public @@ -410,15 +471,44 @@ router.get("/", auth, async (req, res) => { } }); -// @route GET api/auth/me -// @desc Get authenticated user data -// @access Private -router.get("/me", (req, res) => { - if (req.isAuthenticated()) { - res.json(req.user); - } else { - res.status(401).json({ message: "Not logged in" }); +// @route GET api/auth/me +// @desc Get authenticated user data +// @access Private +router.get("/me", auth, (req, res) => { + res.json({ user: req.user, authMethod: req.authMethod }); +}); + +// @route GET api/auth/check +// @desc Check if user is authenticated (works for JWT and session) +// @access Public +router.get("/check", async (req, res) => { + try { + // Try JWT first + const token = req.header("x-auth-token"); + let user = null; + let authMethod = null; + + if (token) { + try { + const decoded = jwt.verify(token, JWT_SECRET); + user = await User.findById(decoded.user.id).select("-password"); + authMethod = "token"; + } catch {} + } else if (req.isAuthenticated && req.isAuthenticated()) { + user = req.user; + authMethod = "session"; + } + + if (!user) return res.json({ isAuthenticated: false }); + + res.json({ isAuthenticated: true, authMethod, user }); + } catch (err) { + console.error("Error checking auth:", err.message); + res.status(500).json({ isAuthenticated: false }); } }); + + + module.exports = router; \ No newline at end of file diff --git a/backend/routes/profile.js b/backend/routes/profile.js index 07ff0db..ad94c52 100644 --- a/backend/routes/profile.js +++ b/backend/routes/profile.js @@ -9,6 +9,58 @@ const fs = require('fs'); const crypto = require('crypto'); const LeetCode = require("../models/Leetcode") +// @route GET api/profile +// @desc Get user profile +// @access Private +router.get('/', auth, async (req, res) => { + try { + // For GitHub/Google authenticated users (session-based) + if (req.isAuthenticated && req.isAuthenticated()) { + console.log('Profile route: User authenticated via session:', req.user); + + // Return the complete user object with all fields + // This uses the enhanced user object we created during authentication + if (req.user.platforms && req.user.streak !== undefined) { + return res.json(req.user); + } + + // Fallback if we don't have the enhanced user object + return res.json({ + name: req.user.name || req.user.displayName || 'Social Auth User', + email: req.user.email || '', + avatar: req.user.avatar || req.user.photos?.[0]?.value || generateAvatarUrl(req.user.email, req.user.name), + platforms: req.user.username ? [ + { + name: 'GitHub', + username: req.user.username, + url: `https://github.com/${req.user.username}` + } + ] : [], + streak: 0, + timeSpent: '0 minutes', + notes: [], + activity: [], + goals: [] + }); + } + + // For regular JWT users with MongoDB records + if (req.user && req.user.id) { + let user = await User.findById(req.user.id).select('-password'); + if (!user) { + return res.status(404).json({ msg: 'User not found' }); + } + return res.json(user); + } + + // If we get here, something's wrong with authentication + return res.status(401).json({ errors: [{ msg: 'Authentication failed' }] }); + } catch (err) { + console.error('Profile error:', err.message); + res.status(500).json({ errors: [{ msg: 'Server Error' }] }); + } +}); + // Helper function to generate avatar URL from email or name const generateAvatarUrl = (email, name) => { // Use email for consistent avatar, or fallback to name diff --git a/backend/server.js b/backend/server.js index 749be65..4339c9b 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,28 +1,27 @@ // Entry point of the backend server require("dotenv").config(); + +// Dependencies const express = require("express"); -const path = require("path"); const cors = require("cors"); +const path = require("path"); const session = require("express-session"); require("./utils/leetcodeCron"); const passport = require("passport"); const githubRoute = require("./routes/github.route"); +const contactRouter = require("./routes/contact.route"); - -// Database connection +// Connect to MongoDB require("./db/connection"); -// Passport config (optional Google OAuth) +// Passport config with error handling try { require("./config/passport"); } catch (err) { console.warn("Google OAuth is not configured properly. Skipping Passport strategy."); } -// Import routes -const contactRouter = require("./routes/contact.route"); - -// Rate limiter middleware placeholders +// Rate limiter middleware const { generalMiddleware, authMiddleware } = require("./middleware/rateLimit/index"); // Initialize Express @@ -31,11 +30,22 @@ const app = express(); // JSON parsing app.use(express.json()); -// Enable CORS +// CORS preflight handling - respond to OPTIONS requests explicitly +app.options('*', (req, res) => { + res.header('Access-Control-Allow-Origin', process.env.CLIENT_URL || 'http://localhost:5173'); + res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, x-auth-token'); + res.header('Access-Control-Allow-Credentials', 'true'); + res.status(200).send(); +}); + +// Enable CORS for all other requests app.use( cors({ origin: process.env.CLIENT_URL || "http://localhost:5173", credentials: true, + methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allowedHeaders: ["Content-Type", "Authorization", "x-auth-token"], }) ); @@ -44,8 +54,12 @@ app.use( session({ secret: process.env.SESSION_SECRET || "devsync_session_secret", resave: false, - saveUninitialized: false, - cookie: { secure: false }, // set true if using HTTPS + saveUninitialized: true, // keep sessions for unauthenticated users + cookie: { + secure: false, // set true if using HTTPS + maxAge: 24 * 60 * 60 * 1000, + httpOnly: true, + }, }) ); @@ -60,12 +74,13 @@ app.use("/uploads", express.static(path.join(__dirname, "uploads"))); app.use("/auth", require("./routes/auth")); app.use("/api/github", githubRoute); // API Routes -app.use("/api/auth", authMiddleware, require("./routes/auth")); +const authRouter = require("./routes/auth"); +app.use("/api/auth", authMiddleware, authRouter); +app.use("/auth", authRouter); // OAuth callbacks shouldn't have middleware app.use("/api/profile", generalMiddleware, require("./routes/profile")); app.use("/api/contact", generalMiddleware, contactRouter); app.use("/api/tasks", require("./routes/tasks.route")); - // Default route app.get("/", (req, res) => { res.send("DEVSYNC BACKEND API 🚀"); @@ -75,4 +90,4 @@ app.get("/", (req, res) => { const PORT = process.env.PORT || 5000; app.listen(PORT, () => { console.log(`Server is up and running at http://localhost:${PORT} 🚀`); -}); \ No newline at end of file +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4b64e87..15a21f3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -36,7 +36,7 @@ "react-calendar-heatmap": "^1.10.0", "react-chartjs-2": "^5.3.0", "react-dom": "^19.1.0", - "react-hook-form": "^7.62.0", + "react-hook-form": "^7.63.0", "react-icons": "^5.5.0", "react-intersection-observer": "^9.16.0", "react-router-dom": "^7.7.0", @@ -7167,9 +7167,9 @@ } }, "node_modules/react-hook-form": { - "version": "7.62.0", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.62.0.tgz", - "integrity": "sha512-7KWFejc98xqG/F4bAxpL41NB3o1nnvQO1RWZT3TqRZYL8RryQETGfEdVnJN2fy1crCiBLLjkRBVK05j24FxJGA==", + "version": "7.63.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.63.0.tgz", + "integrity": "sha512-ZwueDMvUeucovM2VjkCf7zIHcs1aAlDimZu2Hvel5C5907gUzMpm4xCrQXtRzCvsBqFjonB4m3x4LzCFI1ZKWA==", "license": "MIT", "engines": { "node": ">=18.0.0" diff --git a/frontend/package.json b/frontend/package.json index e39eef1..b8766b5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -38,7 +38,7 @@ "react-calendar-heatmap": "^1.10.0", "react-chartjs-2": "^5.3.0", "react-dom": "^19.1.0", - "react-hook-form": "^7.62.0", + "react-hook-form": "^7.63.0", "react-icons": "^5.5.0", "react-intersection-observer": "^9.16.0", "react-router-dom": "^7.7.0", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 7aa514f..6211486 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -130,12 +130,12 @@ function App() { } /> - } /> - } /> - } /> + } /> + } /> + } /> } /> - } /> - } /> + } /> + } /> ); diff --git a/frontend/src/Components/Contributors.jsx b/frontend/src/Components/Contributors.jsx index 6328cbc..56f40dd 100644 --- a/frontend/src/Components/Contributors.jsx +++ b/frontend/src/Components/Contributors.jsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from "react"; -import { FaArrowRight } from "react-icons/fa6"; +import { FaArrowRight } from "react-icons/fa"; import { useNavigate } from "react-router-dom"; const ContributorsSection = () => { diff --git a/frontend/src/Components/DashBoard/ActivityHeatMap.jsx b/frontend/src/Components/DashBoard/ActivityHeatMap.jsx index 3dc26db..ca5fb28 100644 --- a/frontend/src/Components/DashBoard/ActivityHeatMap.jsx +++ b/frontend/src/Components/DashBoard/ActivityHeatMap.jsx @@ -59,4 +59,4 @@ export default function ActivityHeatmap({ ); -} +} \ No newline at end of file diff --git a/frontend/src/Components/DashBoard/GithubRepoCard.jsx b/frontend/src/Components/DashBoard/GithubRepoCard.jsx new file mode 100644 index 0000000..f203fd3 --- /dev/null +++ b/frontend/src/Components/DashBoard/GithubRepoCard.jsx @@ -0,0 +1,117 @@ +import React from 'react'; +import { Github, Star, GitFork, Clock } from 'lucide-react'; +import CardWrapper from './CardWrapper'; + +/** + * Component to display a list of GitHub repositories + */ +export default function GithubRepoCard({ repositories = [], className = '' }) { + // Format the update time to a readable string + const formatUpdateTime = (dateString) => { + if (!dateString) return ''; + + const date = new Date(dateString); + const now = new Date(); + const diffMs = now - date; + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffDays === 0) { + return 'Today'; + } else if (diffDays === 1) { + return 'Yesterday'; + } else if (diffDays < 7) { + return `${diffDays} days ago`; + } else if (diffDays < 30) { + const weeks = Math.floor(diffDays / 7); + return `${weeks} ${weeks === 1 ? 'week' : 'weeks'} ago`; + } else { + const months = Math.floor(diffDays / 30); + return `${months} ${months === 1 ? 'month' : 'months'} ago`; + } + }; + + // Language color mapping + const languageColors = { + JavaScript: '#f1e05a', + TypeScript: '#3178c6', + HTML: '#e34c26', + CSS: '#563d7c', + Python: '#3572A5', + Java: '#b07219', + 'C#': '#178600', + PHP: '#4F5D95', + Ruby: '#701516', + Go: '#00ADD8', + Swift: '#F05138', + Kotlin: '#A97BFF', + Rust: '#dea584', + Dart: '#00B4AB', + // Add more languages as needed + default: '#cccccc' + }; + + return ( + +
+ +

+ Repositories +

+
+ + {repositories.length === 0 ? ( +

+ No repositories found. Connect with GitHub to see your repositories. +

+ ) : ( +
+ {repositories.map((repo) => ( +
+ + {repo.name} + + + {repo.description && ( +

+ {repo.description} +

+ )} + +
+ {repo.language && ( +
+ + {repo.language} +
+ )} + +
+ + {repo.stargazers_count} +
+ +
+ + {repo.forks_count} +
+ +
+ + {formatUpdateTime(repo.updated_at)} +
+
+
+ ))} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/Components/DashBoard/PlatformLinks.jsx b/frontend/src/Components/DashBoard/PlatformLinks.jsx index 3bcd1e8..03a3525 100644 --- a/frontend/src/Components/DashBoard/PlatformLinks.jsx +++ b/frontend/src/Components/DashBoard/PlatformLinks.jsx @@ -15,6 +15,7 @@ const iconMap = { }; function normalizeLeetcodeURL(url) { + if (!url || typeof url !== 'string') return null; const leetcodeRegex = /^https?:\/\/(www\.)?leetcode\.com\/(u\/)?[a-zA-Z0-9_-]+\/?$/; if (!leetcodeRegex.test(url)) return null; @@ -22,7 +23,7 @@ function normalizeLeetcodeURL(url) { } function normalizeGitHubURL(url) { - if (!url || url.trim() === "") return null; + if (!url || typeof url !== 'string' || url.trim() === "") return null; const githubRegex = /^https?:\/\/(www\.)?github\.com\/[a-zA-Z0-9_-]+\/?$/; if (!githubRegex.test(url)) return null; @@ -31,6 +32,7 @@ function normalizeGitHubURL(url) { } const leetcodeUrl = (url) => { + if (!url || typeof url !== 'string') return "#"; const normalized = normalizeLeetcodeURL(url); if (!normalized) return "#"; const username = normalized.trim().split("/").pop(); @@ -38,58 +40,55 @@ const leetcodeUrl = (url) => { }; const githubUrl = (url) => { - if (!url) return "#"; + if (!url || typeof url !== 'string') return "#"; const username = url.replace(/\/$/, "").split("/").pop(); return `/dashboard/github/${username}`; }; -export default function PlatformLinks({ platforms }) { - // Filter out empty or falsy URLs - const platformEntries = Object.entries(platforms).filter( - ([, url]) => url && url.trim() !== "" - ); +export default function PlatformLinks({ platforms = {} }) { + // Get platform entries if they exist, otherwise default to GitHub + let platformEntries = []; + + if (Array.isArray(platforms)) { + // Handle array format (from GitHub auth) + platformEntries = platforms.map(platform => [platform.name.toLowerCase(), platform.url]); + } else { + // Handle object format (from email auth) + platformEntries = Object.entries(platforms || {}).filter( + ([, url]) => url && typeof url === 'string' && url.trim() !== "" + ); + } + + // If no platforms, ensure at least GitHub shows up for consistent UI + if (platformEntries.length === 0) { + platformEntries = [["github", "https://github.com/"]]; + } return (
- {platformEntries.length > 0 ? ( - platformEntries.map(([name, url], i) => { - const Icon = iconMap[name.toLowerCase()] || SiGithub; - - // Determine href based on platform - const href = - name.toLowerCase() === "leetcode" - ? leetcodeUrl(url) - : name.toLowerCase() === "github" - ? githubUrl(normalizeGitHubURL(url)) - : url; + {platformEntries.map(([name, url], i) => { + const Icon = iconMap[name.toLowerCase()] || SiGithub; - return ( - - -
- - {name} - - - Active - -
-
- ); - }) - ) : ( -
- - No platforms linked yet - -
- )} + return ( + + +
+ + {name} + + + Active + +
+
+ ); + })}
); } \ No newline at end of file diff --git a/frontend/src/Components/DashBoard/ProfileCard.jsx b/frontend/src/Components/DashBoard/ProfileCard.jsx index 3b5b509..01cce03 100644 --- a/frontend/src/Components/DashBoard/ProfileCard.jsx +++ b/frontend/src/Components/DashBoard/ProfileCard.jsx @@ -25,10 +25,11 @@ const iconMap = { export default function ProfileCard({ user }) { if (!user) return null; - + + // Keep the real user name but ensure consistent UI structure const socialLinks = user.socialLinks || {}; const entries = Object.entries(socialLinks).filter( - ([_, url]) => url?.trim() !== "" + ([_, url]) => url && typeof url === 'string' && url.trim() !== "" ); const normalizeLeetcodeURL = (url) => { @@ -66,7 +67,7 @@ export default function ProfileCard({ user }) { ? user.avatar.startsWith("http") ? user.avatar : `${import.meta.env.VITE_API_URL}${user.avatar}` - : `https://api.dicebear.com/6.x/micah/svg?seed=fallback` + : `https://api.dicebear.com/6.x/micah/svg?seed=${user.name || 'fallback'}` } alt={user.name} className="w-16 h-16 object-cover rounded-full" @@ -74,9 +75,9 @@ export default function ProfileCard({ user }) {

- {user.name} + {user.name || 'User'}

-

{user.email}

+

{user.email || ''}

diff --git a/frontend/src/Components/DashBoard/StreakCard.jsx b/frontend/src/Components/DashBoard/StreakCard.jsx index 08157b1..08aa32c 100644 --- a/frontend/src/Components/DashBoard/StreakCard.jsx +++ b/frontend/src/Components/DashBoard/StreakCard.jsx @@ -1,9 +1,33 @@ -import React from "react"; +import React, { useEffect } from "react"; import { Flame } from "lucide-react"; import { Card, CardHeader, CardContent } from "@/Components/ui/Card"; export default function StreakCard({ streak }) { const safeStreak = streak ?? 0; + + // Log streak data for debugging + useEffect(() => { + console.log("StreakCard received streak:", streak); + }, [streak]); + + // Create a visual representation of the streak + const renderStreakBoxes = () => { + const boxes = []; + const maxBoxes = 7; // Show up to 7 days + const displayCount = Math.min(safeStreak, maxBoxes); + + for (let i = 0; i < maxBoxes; i++) { + // Active if current index is less than the streak + const isActive = i < displayCount; + boxes.push( +
+ ); + } + return boxes; + }; return ( @@ -13,6 +37,11 @@ export default function StreakCard({ streak }) {

Current Streak

+ + {/* Visual streak representation */} +
+ {renderStreakBoxes()} +
); diff --git a/frontend/src/Components/Dashboard.jsx b/frontend/src/Components/Dashboard.jsx index 1bdc43a..ad02cb2 100644 --- a/frontend/src/Components/Dashboard.jsx +++ b/frontend/src/Components/Dashboard.jsx @@ -19,9 +19,10 @@ export default function Dashboard() { const [error, setError] = useState(null); const [goals, setGoals] = useState([]); const navigate = useNavigate(); - + const token = localStorage.getItem("token"); + // Combined fetch profile function that supports both token and session auth const fetchProfile = async () => { if (!token) { navigate("/login"); @@ -31,13 +32,23 @@ export default function Dashboard() { try { const res = await fetch(`${import.meta.env.VITE_API_URL}/api/profile`, { headers: { "x-auth-token": token }, + credentials: 'include', // Support session-based auth }); const data = await res.json(); if (!res.ok) throw new Error(data.errors?.[0]?.msg || "Failed to load profile"); - setProfile(data); - setGoals(data.goals || []); + // Normalize the profile data structure for consistent UI + const normalizedProfile = { + ...data, + avatar: data.avatar || `https://api.dicebear.com/6.x/micah/svg?seed=${data.name || 'user'}`, + socialLinks: data.socialLinks || {}, + platforms: data.platforms || [], + activity: data.activity || [] + }; + + setProfile(normalizedProfile); + setGoals(normalizedProfile.goals || []); } catch (err) { console.error("Error fetching profile:", err); setError(err.message); @@ -45,25 +56,40 @@ export default function Dashboard() { setLoading(false); } }; - useEffect(() => { const params = new URLSearchParams(window.location.search); const oauthToken = params.get("token"); + const githubToken = params.get("github_token"); + + // Handle token from URL params (for OAuth flows) if (oauthToken) { try { localStorage.setItem("token", oauthToken); + console.log("OAuth token stored in localStorage"); + + // Clean up URL after capturing token (avoid keeping token in address bar) + const cleanUrl = window.location.origin + window.location.pathname; + window.history.replaceState({}, document.title, cleanUrl); } catch (e) { console.error("Failed to persist OAuth token:", e); } - const cleanUrl = window.location.origin + window.location.pathname; - window.history.replaceState({}, document.title, cleanUrl); + } + + // Handle GitHub token separately (for GitHub OAuth flow) + if (githubToken) { + try { + sessionStorage.setItem("github_token", githubToken); + console.log("GitHub token stored in sessionStorage"); + } catch (e) { + console.error("Failed to persist GitHub token:", e); + } } const fetchAndUpdateProfile = async () => { await fetchProfile(); // After profile is loaded, add today to activity if not present - if (profile) { + if (profile && profile.activity) { const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD if (!profile.activity.includes(today)) { handleActivityAdd(today); @@ -72,6 +98,8 @@ export default function Dashboard() { }; fetchAndUpdateProfile(); + + fetchAndUpdateProfile(); }, [navigate]); // --- Handlers for real-time updates --- @@ -162,6 +190,7 @@ export default function Dashboard() { ); } + // Safely destructure with default values const { socialLinks = {}, streak = 0, @@ -179,7 +208,7 @@ export default function Dashboard() {
{/* Row 1 */} - + {/* Row 2 */} diff --git a/frontend/src/Components/Features.jsx b/frontend/src/Components/Features.jsx index 4846db2..62cc785 100644 --- a/frontend/src/Components/Features.jsx +++ b/frontend/src/Components/Features.jsx @@ -29,8 +29,8 @@ export function FeaturesSection() { icon: , }, { - title: "Auto GitHub Sync", - description: "Sync contributions, commits, and streaks automatically.", + title: "GitHub Authentication", + description: "Secure login with your GitHub account.", icon: , }, { diff --git a/frontend/src/Components/GitHubProfile.jsx b/frontend/src/Components/GitHubProfile.jsx index 727b872..8e1c755 100644 --- a/frontend/src/Components/GitHubProfile.jsx +++ b/frontend/src/Components/GitHubProfile.jsx @@ -1,3 +1,4 @@ + import React, { useEffect, useState } from "react"; import { useParams, Link } from "react-router-dom"; import { Card, CardHeader, CardTitle, CardContent } from "@/Components/ui/Card"; diff --git a/frontend/src/Components/auth/Login.jsx b/frontend/src/Components/auth/Login.jsx index 6d1182b..e49a1d1 100644 --- a/frontend/src/Components/auth/Login.jsx +++ b/frontend/src/Components/auth/Login.jsx @@ -78,6 +78,21 @@ const Login = () => { window.location.href = `${import.meta.env.VITE_API_URL}/auth/google`; }; + const handleGithubLogin = () => { + // Clear any existing tokens to avoid conflicts with session-based auth + localStorage.removeItem('token'); + sessionStorage.removeItem('github_token'); + + // Log the redirect URL for debugging + console.log(`Redirecting to: ${import.meta.env.VITE_API_URL}/auth/github?from=login`); + + // Use a timestamp to prevent caching issues + const timestamp = new Date().getTime(); + + // Redirect to the GitHub OAuth endpoint + window.location.href = `${import.meta.env.VITE_API_URL}/auth/github?from=login&t=${timestamp}`; + }; + // Show verification component if user needs to verify email if (showVerification) { return ( @@ -242,6 +257,7 @@ const Login = () => { {/* Social Login */}
diff --git a/frontend/src/Components/profile/Profile.jsx b/frontend/src/Components/profile/Profile.jsx index 69e8301..d7c702f 100644 --- a/frontend/src/Components/profile/Profile.jsx +++ b/frontend/src/Components/profile/Profile.jsx @@ -230,23 +230,30 @@ const Profile = () => { useEffect(() => { const fetchProfile = async () => { try { - const token = localStorage.getItem("token"); - if (!token) { - navigate("/login"); - return; + // First check for JWT token authentication + const token = localStorage.getItem('token'); + + // Create request options for either token or session-based auth + const requestOptions = { + headers: {}, + credentials: 'include' // Always include credentials for session-based auth + }; + + // Add token if available + if (token) { + requestOptions.headers['x-auth-token'] = token; } - - const response = await fetch( - `${import.meta.env.VITE_API_URL}/api/profile`, - { - headers: { - "x-auth-token": token, - }, - } - ); + + // Try to fetch profile with either auth method + const response = await fetch(`${import.meta.env.VITE_API_URL}/api/profile`, requestOptions); if (!response.ok) { - throw new Error("Failed to fetch profile data"); + // If no auth method works, navigate to login + if (response.status === 401) { + navigate('/login'); + return; + } + throw new Error('Failed to fetch profile data'); } const data = await response.json();