From f9c97a5c5155a579430efa33242cffbce6f0d3df Mon Sep 17 00:00:00 2001 From: Prerak Yadav Date: Mon, 25 May 2026 10:42:23 +0530 Subject: [PATCH] feat(auth): add OAuth2 login with Google and GitHub Implement Passport OAuth strategies, extend the user model for provider accounts, add auth routes and login UI, and document setup in the README. Co-authored-by: Cursor --- .env.example | 1 + README.md | 97 ++++++++++++++++++++ backend/.env.example | 19 ++++ backend/config/passportConfig.js | 147 ++++++++++++++++++++++++------- backend/models/User.js | 28 ++++-- backend/package.json | 2 + backend/routes/auth.js | 142 +++++++++++++++++++++++------ backend/server.js | 6 ++ backend/utils/oauthUser.js | 94 ++++++++++++++++++++ spec/auth.routes.spec.cjs | 10 +++ spec/oauthUser.spec.cjs | 69 +++++++++++++++ spec/user.model.spec.cjs | 14 +++ src/pages/Login/Login.tsx | 113 ++++++++++++++++++++---- src/pages/Signup/Signup.tsx | 8 +- src/utils/authApi.ts | 38 ++++++++ 15 files changed, 702 insertions(+), 86 deletions(-) create mode 100644 .env.example create mode 100644 backend/.env.example create mode 100644 backend/utils/oauthUser.js create mode 100644 spec/oauthUser.spec.cjs create mode 100644 src/utils/authApi.ts diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..f25b0dd9 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +VITE_BACKEND_URL=http://localhost:5000 diff --git a/README.md b/README.md index a747b53a..9f595dad 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,102 @@ $ npm i $ npm start ``` +### OAuth2 sign-in (Google & GitHub) + +OAuth2 lets users sign in with Google or GitHub. Email/password login still works for local accounts. + +#### 1. Copy environment files + +```bash +cp .env.example .env +cp backend/.env.example backend/.env +``` + +Set these in **`backend/.env`** (required for OAuth redirects and sessions): + +| Variable | Description | +|----------|-------------| +| `SESSION_SECRET` | Long random string for express-session | +| `MONGO_URI` | MongoDB connection string | +| `BACKEND_URL` | Backend base URL (e.g. `http://localhost:5001`) | +| `FRONTEND_URL` | Frontend URL (e.g. `http://localhost:5173`) | + +Set in **`.env`** (project root, for the React app): + +| Variable | Description | +|----------|-------------| +| `VITE_BACKEND_URL` | Same as `BACKEND_URL` (e.g. `http://localhost:5001`) | + +> **Note (macOS):** Port `5000` is often used by AirPlay. If the backend fails to start, set `PORT=5001` in `backend/.env` and use `5001` in `BACKEND_URL` and `VITE_BACKEND_URL`. + +#### 2. Set up Google OAuth2 + +1. Open [Google Cloud Console](https://console.cloud.google.com/) and create or select a project. +2. Go to **APIs & Services → OAuth consent screen**, choose **External**, and complete the required app name and support email fields. +3. Go to **APIs & Services → Credentials → Create Credentials → OAuth client ID**. +4. Application type: **Web application**. +5. **Authorized redirect URIs** — add: + ``` + http://localhost:5001/api/auth/google/callback + ``` + (Use your `BACKEND_URL` host/port in production, e.g. `https://your-api.example.com/api/auth/google/callback`.) +6. Copy the **Client ID** and **Client secret** into `backend/.env`: + ```env + GOOGLE_CLIENT_ID=your-google-client-id + GOOGLE_CLIENT_SECRET=your-google-client-secret + ``` + +#### 3. Set up GitHub OAuth App + +1. Open [GitHub Developer Settings → OAuth Apps](https://github.com/settings/developers) and click **New OAuth App**. +2. Fill in: + - **Application name:** e.g. `GitHub Tracker (local)` + - **Homepage URL:** `http://localhost:5173` (or your deployed frontend URL) + - **Authorization callback URL:** + ``` + http://localhost:5001/api/auth/github/callback + ``` + (Match your `BACKEND_URL` in production.) +3. Click **Register application**, then **Generate a new client secret**. +4. Add to `backend/.env`: + ```env + GITHUB_OAUTH_CLIENT_ID=your-github-client-id + GITHUB_OAUTH_CLIENT_SECRET=your-github-client-secret + ``` + +> Use a **GitHub OAuth App**, not a Personal Access Token (PAT). + +#### 4. Install backend dependencies and restart + +From the `backend` folder: + +```bash +cd backend +npm install +npm run dev +``` + +Restart the frontend after changing `.env`: + +```bash +npm run dev +``` + +#### 5. Verify OAuth login + +1. Open `http://localhost:5173/login`. +2. Click **Continue with Google** or **Continue with GitHub**. +3. Complete the provider sign-in; you should be redirected back and logged in. + +If credentials are missing, the buttons still appear; clicking them shows a message to configure `backend/.env`. After adding secrets and restarting the backend, OAuth sign-in works end-to-end. + +**Callback URLs summary** (replace host/port with your `BACKEND_URL`): + +| Provider | Callback URL | +|----------|----------------| +| Google | `{BACKEND_URL}/api/auth/google/callback` | +| GitHub | `{BACKEND_URL}/api/auth/github/callback` | + ## 🧪 Backend Unit & Integration Testing with Jasmine This project uses the Jasmine framework for backend unit and integration tests. The tests cover: @@ -94,6 +190,7 @@ npm install --save-dev jasmine @types/jasmine supertest express-session passport ### Test Files - `spec/user.model.spec.cjs` — Unit tests for the User model - `spec/auth.routes.spec.cjs` — Integration tests for authentication routes +- `spec/oauthUser.spec.cjs` — Unit tests for OAuth user helpers ### Jasmine Configuration The Jasmine config (`spec/support/jasmine.mjs`) is set to recognize `.cjs`, `.js`, and `.mjs` test files: diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 00000000..b131debb --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,19 @@ +PORT=5000 +MONGO_URI=mongodb://127.0.0.1:27017/github_tracker +SESSION_SECRET=replace-with-a-long-random-secret + +# URLs used for OAuth redirects and CORS +BACKEND_URL=http://localhost:5000 +FRONTEND_URL=http://localhost:5173 + +# Google OAuth2 (https://console.cloud.google.com/apis/credentials) +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +# Optional override; defaults to BACKEND_URL/api/auth/google/callback +GOOGLE_CALLBACK_URL= + +# GitHub OAuth App (https://github.com/settings/developers) +GITHUB_OAUTH_CLIENT_ID= +GITHUB_OAUTH_CLIENT_SECRET= +# Optional override; defaults to BACKEND_URL/api/auth/github/callback +GITHUB_CALLBACK_URL= diff --git a/backend/config/passportConfig.js b/backend/config/passportConfig.js index 842f50ca..c55b2b4c 100644 --- a/backend/config/passportConfig.js +++ b/backend/config/passportConfig.js @@ -1,45 +1,128 @@ const passport = require("passport"); -const LocalStrategy = require('passport-local').Strategy; +const LocalStrategy = require("passport-local").Strategy; +const GoogleStrategy = require("passport-google-oauth20").Strategy; +const GitHubStrategy = require("passport-github2").Strategy; const User = require("../models/User"); +const { + findOrCreateOAuthUser, + toSessionUser, + isOAuthProviderConfigured, +} = require("../utils/oauthUser"); + +function getBackendUrl() { + return process.env.BACKEND_URL || `http://localhost:${process.env.PORT || 5000}`; +} + +function getFrontendUrl() { + return process.env.FRONTEND_URL || "http://localhost:5173"; +} 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: "Email is invalid " }); } - ) + + if (user.provider !== "local" || !user.password) { + return done(null, false, { + message: `Please sign in with ${user.provider}`, + }); + } + + const isMatch = await user.comparePassword(password); + if (!isMatch) { + return done(null, false, { message: "Invalid password" }); + } + + return done(null, toSessionUser(user)); + } catch (err) { + return done(err); + } + } + ) ); -// Serialize user (store user info in session) +if (isOAuthProviderConfigured("google")) { + passport.use( + new GoogleStrategy( + { + clientID: process.env.GOOGLE_CLIENT_ID, + clientSecret: process.env.GOOGLE_CLIENT_SECRET, + callbackURL: + process.env.GOOGLE_CALLBACK_URL || `${getBackendUrl()}/api/auth/google/callback`, + }, + async (_accessToken, _refreshToken, profile, done) => { + try { + const email = profile.emails?.[0]?.value; + const sessionUser = await findOrCreateOAuthUser({ + provider: "google", + providerId: profile.id, + email, + displayName: profile.displayName, + username: profile.displayName, + }); + return done(null, sessionUser); + } catch (err) { + return done(err, null); + } + } + ) + ); +} + +if (isOAuthProviderConfigured("github")) { + passport.use( + new GitHubStrategy( + { + clientID: process.env.GITHUB_OAUTH_CLIENT_ID, + clientSecret: process.env.GITHUB_OAUTH_CLIENT_SECRET, + callbackURL: + process.env.GITHUB_CALLBACK_URL || `${getBackendUrl()}/api/auth/github/callback`, + scope: ["user:email"], + }, + async (_accessToken, _refreshToken, profile, done) => { + try { + const email = profile.emails?.find((entry) => entry.primary)?.value + || profile.emails?.[0]?.value; + + const sessionUser = await findOrCreateOAuthUser({ + provider: "github", + providerId: profile.id, + email, + username: profile.username, + displayName: profile.displayName, + }); + return done(null, sessionUser); + } catch (err) { + return done(err, null); + } + } + ) + ); +} + passport.serializeUser((user, done) => { - done(null, user.id); + done(null, user.id); }); -// Deserialize user (retrieve user from session) 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); + if (!user) { + return done(null, false); } + done(null, toSessionUser(user)); + } catch (err) { + done(err, null); + } }); + +module.exports = { + getFrontendUrl, + isOAuthProviderConfigured, +}; diff --git a/backend/models/User.js b/backend/models/User.js index eb506ed5..008133ed 100644 --- a/backend/models/User.js +++ b/backend/models/User.js @@ -1,6 +1,8 @@ const mongoose = require("mongoose"); const bcrypt = require("bcryptjs"); +const PROVIDERS = ["local", "google", "github"]; + const UserSchema = new mongoose.Schema({ username: { type: String, @@ -14,21 +16,33 @@ const UserSchema = new mongoose.Schema({ }, password: { type: String, - required: true, + required: function requiredPassword() { + return this.provider === "local"; + }, + }, + provider: { + type: String, + enum: PROVIDERS, + default: "local", + }, + providerId: { + type: String, + sparse: true, }, }); -// ✅ FIXED: no next() -UserSchema.pre('save', async function () { - if (!this.isModified('password')) return; +UserSchema.index({ provider: 1, providerId: 1 }, { unique: true, sparse: true }); + +UserSchema.pre("save", async function hashPasswordIfPresent() { + if (!this.isModified("password") || !this.password) return; const salt = await bcrypt.genSalt(10); this.password = await bcrypt.hash(this.password, salt); }); -// ✅ password comparison -UserSchema.methods.comparePassword = async function (enteredPassword) { +UserSchema.methods.comparePassword = async function comparePassword(enteredPassword) { + if (!this.password) return false; return bcrypt.compare(enteredPassword, this.password); }; -module.exports = mongoose.model("User", UserSchema); \ No newline at end of file +module.exports = mongoose.model("User", UserSchema); diff --git a/backend/package.json b/backend/package.json index 74ab9dd7..1bf39baa 100644 --- a/backend/package.json +++ b/backend/package.json @@ -20,6 +20,8 @@ "express-session": "^1.18.1", "mongoose": "^8.8.2", "passport": "^0.7.0", + "passport-github2": "^0.1.12", + "passport-google-oauth20": "^2.0.0", "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..74d7b25e 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -3,48 +3,138 @@ const passport = require("passport"); const User = require("../models/User"); const { signupSchema, loginSchema } = require("../validators/authValidator"); const { validateRequest } = require("../validators/validationRequest"); +const { + getFrontendUrl, + isOAuthProviderConfigured, +} = require("../config/passportConfig"); const router = express.Router(); +function handleOAuthCallback(req, res, next) { + const frontendUrl = getFrontendUrl(); + + passport.authenticate(req.oauthProvider, (err, user, info) => { + if (err) { + const message = encodeURIComponent(err.message || "OAuth authentication failed"); + return res.redirect(`${frontendUrl}/login?oauth=error&message=${message}`); + } + + if (!user) { + const message = encodeURIComponent(info?.message || "OAuth authentication failed"); + return res.redirect(`${frontendUrl}/login?oauth=error&message=${message}`); + } + + req.logIn(user, (loginErr) => { + if (loginErr) { + const message = encodeURIComponent(loginErr.message || "Failed to create session"); + return res.redirect(`${frontendUrl}/login?oauth=error&message=${message}`); + } + return res.redirect(`${frontendUrl}/login?oauth=success`); + }); + })(req, res, next); +} + +function guardOAuthProvider(provider) { + return (req, res, next) => { + if (!isOAuthProviderConfigured(provider)) { + return res.status(503).json({ + message: `${provider} OAuth is not configured on the server`, + }); + } + next(); + }; +} + // Signup route router.post("/signup", validateRequest(signupSchema), async (req, res) => { + const { username, email, password } = req.body; + + try { + const existingUser = await User.findOne({ + $or: [{ email }, { username }], + }); - const { username, email, password } = req.body; + if (existingUser) { + return res.status(400).json({ message: "User already exists" }); + } - try { - const existingUser = await User.findOne({ - $or: [{ email }, { username }], - }); + const newUser = new User({ username, email, password, provider: "local" }); + await newUser.save(); + res.status(201).json({ message: "User created successfully" }); + } catch (err) { + if (err && err.code === 11000) { + return res.status(400).json({ message: "User already exists" }); + } - if (existingUser) - return res.status(400).json({ message: 'User already exists' }); + res.status(500).json({ message: "Error creating user", error: err.message }); + } +}); - const newUser = new User({ username, email, password }); - await newUser.save(); - res.status(201).json({ message: 'User created successfully' }); - } catch (err) { - if (err && err.code === 11000) { - return res.status(400).json({ message: 'User already exists' }); - } +// Login route +router.post("/login", validateRequest(loginSchema), (req, res, next) => { + passport.authenticate("local", (err, user, info) => { + if (err) { + return res.status(500).json({ message: "Login failed", error: err.message }); + } - res.status(500).json({ message: 'Error creating user', error: err.message }); + if (!user) { + return res.status(401).json({ message: info?.message || "Invalid credentials" }); } + + req.logIn(user, (loginErr) => { + if (loginErr) { + return res.status(500).json({ message: "Login failed", error: loginErr.message }); + } + return res.status(200).json({ message: "Login successful", user: req.user }); + }); + })(req, res, next); }); -// Login route -router.post("/login", validateRequest(loginSchema), passport.authenticate('local'), (req, res) => { - res.status(200).json( { message: 'Login successful', user: req.user } ); +// Current session +router.get("/me", (req, res) => { + if (!req.isAuthenticated()) { + return res.status(401).json({ message: "Not authenticated" }); + } + return res.status(200).json({ user: req.user }); }); -// Logout route -router.get("/logout", (req, res) => { +// Available OAuth providers +router.get("/oauth/providers", (_req, res) => { + res.status(200).json({ + google: isOAuthProviderConfigured("google"), + github: isOAuthProviderConfigured("github"), + }); +}); - req.logout((err) => { +// Google OAuth2 +router.get("/google", guardOAuthProvider("google"), passport.authenticate("google", { + scope: ["profile", "email"], + session: true, +})); - if (err) - return res.status(500).json({ message: 'Logout failed', error: err.message }); - else - res.status(200).json({ message: 'Logged out successfully' }); - }); +router.get("/google/callback", guardOAuthProvider("google"), (req, res, next) => { + req.oauthProvider = "google"; + handleOAuthCallback(req, res, next); +}); + +// GitHub OAuth2 +router.get("/github", guardOAuthProvider("github"), passport.authenticate("github", { + scope: ["user:email"], + session: true, +})); + +router.get("/github/callback", guardOAuthProvider("github"), (req, res, next) => { + req.oauthProvider = "github"; + handleOAuthCallback(req, res, next); +}); + +// Logout route +router.get("/logout", (req, res) => { + req.logout((err) => { + if (err) { + return res.status(500).json({ message: "Logout failed", error: err.message }); + } + return res.status(200).json({ message: "Logged out successfully" }); + }); }); module.exports = router; diff --git a/backend/server.js b/backend/server.js index 48d6ccfb..8c9fdf1c 100644 --- a/backend/server.js +++ b/backend/server.js @@ -32,6 +32,12 @@ app.use(session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false, + cookie: { + secure: process.env.NODE_ENV === 'production', + httpOnly: true, + sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax', + maxAge: 7 * 24 * 60 * 60 * 1000, + }, })); app.use(passport.initialize()); app.use(passport.session()); diff --git a/backend/utils/oauthUser.js b/backend/utils/oauthUser.js new file mode 100644 index 00000000..10e8e899 --- /dev/null +++ b/backend/utils/oauthUser.js @@ -0,0 +1,94 @@ +const User = require("../models/User"); + +function toSessionUser(user) { + return { + id: user._id.toString(), + username: user.username, + email: user.email, + provider: user.provider, + }; +} + +async function ensureUniqueUsername(baseUsername) { + const sanitized = (baseUsername || "user") + .replace(/[^a-zA-Z0-9_]/g, "_") + .slice(0, 30) || "user"; + + let username = sanitized; + let suffix = 0; + + while (await User.findOne({ username })) { + suffix += 1; + username = `${sanitized.slice(0, 24)}_${suffix}`; + } + + return username; +} + +/** + * Find or create a user from an OAuth provider profile. + */ +async function findOrCreateOAuthUser({ provider, providerId, email, username, displayName }) { + const normalizedEmail = email?.trim().toLowerCase(); + + if (!providerId) { + throw new Error("OAuth profile is missing a provider id"); + } + + let user = await User.findOne({ provider, providerId }); + + if (user) { + return toSessionUser(user); + } + + if (normalizedEmail) { + user = await User.findOne({ email: normalizedEmail }); + + if (user) { + if (user.provider === "local" && user.password) { + user.provider = provider; + user.providerId = providerId; + await user.save(); + return toSessionUser(user); + } + + if (user.provider === provider) { + user.providerId = providerId; + await user.save(); + return toSessionUser(user); + } + + throw new Error("An account with this email already exists using a different sign-in method"); + } + } + + const uniqueUsername = await ensureUniqueUsername( + username || displayName || normalizedEmail?.split("@")[0] + ); + + user = await User.create({ + username: uniqueUsername, + email: normalizedEmail || `${providerId}@${provider}.oauth.local`, + provider, + providerId, + }); + + return toSessionUser(user); +} + +function isOAuthProviderConfigured(provider) { + if (provider === "google") { + return Boolean(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET); + } + if (provider === "github") { + return Boolean(process.env.GITHUB_OAUTH_CLIENT_ID && process.env.GITHUB_OAUTH_CLIENT_SECRET); + } + return false; +} + +module.exports = { + findOrCreateOAuthUser, + toSessionUser, + isOAuthProviderConfigured, + ensureUniqueUsername, +}; diff --git a/spec/auth.routes.spec.cjs b/spec/auth.routes.spec.cjs index 2b29c03a..22b4896a 100644 --- a/spec/auth.routes.spec.cjs +++ b/spec/auth.routes.spec.cjs @@ -124,6 +124,16 @@ describe('Auth Routes', () => { expect(res.status).toBe(401); }); + it('should list configured OAuth providers', async () => { + const res = await request(app).get('/auth/oauth/providers'); + + expect(res.status).toBe(200); + expect(res.body).toEqual(jasmine.objectContaining({ + google: jasmine.any(Boolean), + github: jasmine.any(Boolean), + })); + }); + // ---------------- LOGOUT ---------------- it('should logout a logged-in user', async () => { const agent = request.agent(app); diff --git a/spec/oauthUser.spec.cjs b/spec/oauthUser.spec.cjs new file mode 100644 index 00000000..f2d3345f --- /dev/null +++ b/spec/oauthUser.spec.cjs @@ -0,0 +1,69 @@ +const mongoose = require('mongoose'); +const User = require('../backend/models/User'); +const { + findOrCreateOAuthUser, + ensureUniqueUsername, +} = require('../backend/utils/oauthUser'); + +describe('OAuth user utilities', () => { + beforeAll(async () => { + await mongoose.connect('mongodb://127.0.0.1:27017/github_tracker_test'); + }); + + afterAll(async () => { + if (mongoose.connection.readyState === 1) { + await mongoose.connection.db.dropDatabase(); + await mongoose.disconnect(); + } + }); + + afterEach(async () => { + await User.deleteMany({}); + }); + + it('should create a new OAuth user without a password', async () => { + const sessionUser = await findOrCreateOAuthUser({ + provider: 'github', + providerId: '12345', + email: 'oauth@example.com', + username: 'oauth_user', + }); + + expect(sessionUser.email).toBe('oauth@example.com'); + expect(sessionUser.provider).toBe('github'); + + const stored = await User.findOne({ provider: 'github', providerId: '12345' }); + expect(stored).toBeTruthy(); + expect(stored.password).toBeUndefined(); + }); + + it('should return the same user for repeated OAuth logins', async () => { + const first = await findOrCreateOAuthUser({ + provider: 'google', + providerId: 'google-1', + email: 'google@example.com', + username: 'google_user', + }); + + const second = await findOrCreateOAuthUser({ + provider: 'google', + providerId: 'google-1', + email: 'google@example.com', + username: 'google_user', + }); + + expect(second.id).toBe(first.id); + }); + + it('should generate unique usernames when collisions occur', async () => { + await User.create({ + username: 'duplicate', + email: 'first@example.com', + password: 'Password1!', + provider: 'local', + }); + + const username = await ensureUniqueUsername('duplicate'); + expect(username).not.toBe('duplicate'); + }); +}); diff --git a/spec/user.model.spec.cjs b/spec/user.model.spec.cjs index e256e638..6cba35d1 100644 --- a/spec/user.model.spec.cjs +++ b/spec/user.model.spec.cjs @@ -51,6 +51,20 @@ describe('User Model', () => { expect(user.password).toBe(originalHash); }); + // -------- OAUTH USER -------- + it('should allow OAuth users without a password', async () => { + const user = await User.create({ + username: 'oauthuser', + email: 'oauth@example.com', + provider: 'github', + providerId: 'gh-123', + }); + + expect(user.password).toBeUndefined(); + const isMatch = await user.comparePassword('anything'); + expect(isMatch).toBe(false); + }); + // -------- COMPARE PASSWORD -------- it('should correctly compare passwords', async () => { const user = await User.create({ diff --git a/src/pages/Login/Login.tsx b/src/pages/Login/Login.tsx index 92b7073e..a45400bc 100644 --- a/src/pages/Login/Login.tsx +++ b/src/pages/Login/Login.tsx @@ -1,10 +1,15 @@ -import React, { useState, ChangeEvent, FormEvent, useContext } from "react"; +import React, { useState, ChangeEvent, FormEvent, useContext, useEffect } from "react"; import axios from "axios"; -import { useNavigate, Link } from "react-router-dom"; +import { useNavigate, Link, useSearchParams } from "react-router-dom"; +import { FaGithub, FaGoogle } from "react-icons/fa"; import { ThemeContext } from "../../context/ThemeContext"; import type { ThemeContextType } from "../../context/ThemeContext"; - -const backendUrl = import.meta.env.VITE_BACKEND_URL; +import { + authApi, + fetchOAuthProviders, + getOAuthLoginUrl, + type OAuthProviders, +} from "../../utils/authApi"; interface LoginFormData { email: string; @@ -15,25 +20,65 @@ const Login: React.FC = () => { const [formData, setFormData] = useState({ email: "", password: "" }); const [message, setMessage] = useState(""); const [isLoading, setIsLoading] = useState(false); + const [oauthProviders, setOauthProviders] = useState({ + google: false, + github: false, + }); const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); const themeContext = useContext(ThemeContext) as ThemeContextType; const { mode } = themeContext; + useEffect(() => { + fetchOAuthProviders() + .then(setOauthProviders) + .catch(() => setOauthProviders({ google: false, github: false })); + }, []); + + useEffect(() => { + const oauthStatus = searchParams.get("oauth"); + const oauthMessage = searchParams.get("message"); + + if (oauthStatus === "success") { + setMessage("Login successful"); + setSearchParams({}, { replace: true }); + navigate("/"); + return; + } + + if (oauthStatus === "error") { + setMessage(oauthMessage ? decodeURIComponent(oauthMessage) : "OAuth sign-in failed"); + setSearchParams({}, { replace: true }); + } + }, [searchParams, setSearchParams, navigate]); + const handleChange = (e: ChangeEvent) => { const { name, value } = e.target; setFormData({ ...formData, [name]: value }); }; + const handleOAuthLogin = (provider: "google" | "github") => { + if (!oauthProviders[provider]) { + const envKeys = + provider === "google" + ? "GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET" + : "GITHUB_OAUTH_CLIENT_ID and GITHUB_OAUTH_CLIENT_SECRET"; + setMessage(`${provider} sign-in is not configured yet. Add ${envKeys} to backend/.env and restart the server.`); + return; + } + window.location.href = getOAuthLoginUrl(provider); + }; + const handleSubmit = async (e: FormEvent) => { e.preventDefault(); setIsLoading(true); try { - const response = await axios.post(`${backendUrl}/api/auth/login`, formData); + const response = await authApi.post("/login", formData); setMessage(response.data.message); - if (response.data.message === 'Login successful') { + if (response.data.message === "Login successful") { navigate("/"); } } catch (error: unknown) { @@ -47,6 +92,9 @@ const Login: React.FC = () => { } }; + const isSuccessMessage = message === "Login successful"; + const isOAuthSetupMessage = message.includes("not configured yet"); + return (
{ : "bg-gradient-to-br from-slate-100 via-purple-100 to-slate-100" }`} > - {/* Animated background elements */}
@@ -64,7 +111,6 @@ const Login: React.FC = () => {
- {/* Branding */}
Logo @@ -82,12 +128,47 @@ const Login: React.FC = () => {

- {/* Form Card */}

Welcome Back

+
+ + + + +
+
+ + or sign in with email + +
+
+
+
{ - {/* Message */} {message && (
{message}
)} - {/* Footer Text */}

- Don't have an account? + Don't have an account? { ); }; -export default Login; \ No newline at end of file +export default Login; diff --git a/src/pages/Signup/Signup.tsx b/src/pages/Signup/Signup.tsx index 2ac51dcc..56d8e949 100644 --- a/src/pages/Signup/Signup.tsx +++ b/src/pages/Signup/Signup.tsx @@ -1,13 +1,11 @@ import React, { useState, useContext } from "react"; -import axios from "axios"; import { useNavigate, Link } from "react-router-dom"; +import { authApi } from "../../utils/authApi"; import { motion } from "framer-motion"; import { User, Mail, Lock, Eye, EyeOff } from "lucide-react"; import { ThemeContext } from "../../context/ThemeContext"; import type { ThemeContextType } from "../../context/ThemeContext"; -const backendUrl = import.meta.env.VITE_BACKEND_URL; - interface SignUpFormData { username: string; email: string; @@ -83,9 +81,7 @@ const SignUp: React.FC = () => { } setIsLoading(true); try { - const response = await axios.post(`${backendUrl}/api/auth/signup`, - formData // Include cookies for session - ); + const response = await authApi.post("/signup", formData); setMessage(response.data.message); // Show success message from backend // Navigate to login page after successful signup diff --git a/src/utils/authApi.ts b/src/utils/authApi.ts new file mode 100644 index 00000000..43cd1805 --- /dev/null +++ b/src/utils/authApi.ts @@ -0,0 +1,38 @@ +import axios from "axios"; + +const backendUrl = import.meta.env.VITE_BACKEND_URL; + +export const authApi = axios.create({ + baseURL: `${backendUrl}/api/auth`, + withCredentials: true, +}); + +export interface AuthUser { + id: string; + username: string; + email: string; + provider?: string; +} + +export interface OAuthProviders { + google: boolean; + github: boolean; +} + +export async function fetchOAuthProviders(): Promise { + const { data } = await authApi.get("/oauth/providers"); + return data; +} + +export async function fetchCurrentUser(): Promise { + try { + const { data } = await authApi.get<{ user: AuthUser }>("/me"); + return data.user; + } catch { + return null; + } +} + +export function getOAuthLoginUrl(provider: "google" | "github"): string { + return `${backendUrl}/api/auth/${provider}`; +}