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}`; +}