diff --git a/backend/.env.sample b/backend/.env.sample new file mode 100644 index 00000000..ee980836 --- /dev/null +++ b/backend/.env.sample @@ -0,0 +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 842f50ca..98a1c1fa 100644 --- a/backend/config/passportConfig.js +++ b/backend/config/passportConfig.js @@ -1,45 +1,126 @@ const passport = require("passport"); -const LocalStrategy = require('passport-local').Strategy; +const LocalStrategy = require("passport-local").Strategy; +const GitHubStrategy = require("passport-github2").Strategy; const User = require("../models/User"); passport.use( - new LocalStrategy( - { usernameField: "email" }, - async (email, password, done) => { - try { - const user = await User.findOne( {email} ); - if (!user) { - return done(null, false, { message: 'Email is invalid '}); - } - - const isMatch = await user.comparePassword(password); - if (!isMatch) { - return done(null, false, { message: 'Invalid password' }); - } - - return done(null, { - id : user._id.toString(), - username: user.username, - email: user.email - }); - } catch (err) { - return done(err); + new LocalStrategy( + { usernameField: "email" }, + async (email, password, done) => { + try { + const user = await User.findOne({ email }); + + if (!user) { + return done(null, false, { + message: "Invalid email or password", + }); + } + + if (!user.password) { + return done(null, false, { + message: "Use GitHub sign in for this account", + }); + } + + const isMatch = await user.comparePassword(password); + + if (!isMatch) { + return done(null, false, { + message: "Invalid email or password", + }); + } + + return done(null, { + id: user._id.toString(), + username: user.username, + email: user.email, + }); + } catch (err) { + return done(err); + } + } + ) +); + +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"], + state: true, + }, + + async (accessToken, refreshToken, profile, done) => { + try { + const primaryEmail = profile.emails?.[0]?.value; + 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); + + const userData = { + githubId: profile.id, + username: `${loginName}_${uniqueSuffix}`, + avatar, + }; + + if (primaryEmail) { + userData.email = primaryEmail; + } + + user = new User(userData); + + } else { + user.githubId = user.githubId || profile.id; + + if (primaryEmail) { + 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) +// Serialize user passport.serializeUser((user, done) => { - done(null, user.id); + done(null, user.id); }); -// Deserialize user (retrieve user from session) +// Deserialize user passport.deserializeUser(async (id, done) => { - try { - const user = await User.findById(id); - done(null, user); - } catch (err) { - done(err, null); - } -}); + try { + const user = await User.findById(id); + done(null, user); + } catch (err) { + done(err, null); + } +}); \ No newline at end of file diff --git a/backend/models/User.js b/backend/models/User.js index eb506ed5..eff51d0c 100644 --- a/backend/models/User.js +++ b/backend/models/User.js @@ -7,27 +7,46 @@ const UserSchema = new mongoose.Schema({ required: true, unique: true, }, + 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, }, }); -// ✅ FIXED: no next() -UserSchema.pre('save', async function () { - if (!this.isModified('password')) return; +// password hashing +UserSchema.pre("save", async function () { + if (!this.isModified("password")) return; const salt = await bcrypt.genSalt(10); this.password = await bcrypt.hash(this.password, salt); }); -// ✅ password comparison +// password comparison UserSchema.methods.comparePassword = async function (enteredPassword) { + if (!this.password) return false; + return bcrypt.compare(enteredPassword, this.password); }; diff --git a/backend/package.json b/backend/package.json index 74ab9dd7..a44db56d 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", "winston": "^3.19.0", "zod": "^4.4.3" diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 7c2cda78..6cb38dd5 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 e9b43f83..54cf64b5 100644 --- a/backend/server.js +++ b/backend/server.js @@ -12,16 +12,25 @@ require('./config/passportConfig'); const logger = require('./logger'); 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 92b7073e..7725e490 100644 --- a/src/pages/Login/Login.tsx +++ b/src/pages/Login/Login.tsx @@ -3,8 +3,12 @@ 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; +const backendUrl = + import.meta.env.VITE_BACKEND_URL || "http://localhost:5000"; interface LoginFormData { email: string; @@ -15,6 +19,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; @@ -47,13 +52,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 */}
@@ -70,11 +105,10 @@ const Login: React.FC = () => { Logo
-

+

GitHubTracker

@@ -98,11 +132,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" + }`} />

@@ -115,11 +148,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" + }`} /> @@ -130,15 +162,54 @@ const Login: React.FC = () => { > {isLoading ? "Signing in..." : "Sign In"} + + {/* Message */} {message && ( -
+
{message}
)}