From 308dec33dd75047cb4097fab80c4e36df6ed10ae Mon Sep 17 00:00:00 2001 From: PR Date: Sun, 31 May 2026 19:25:17 +0530 Subject: [PATCH 1/2] feat: add github icon in navbar --- client/src/pages/LandingPage.jsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/client/src/pages/LandingPage.jsx b/client/src/pages/LandingPage.jsx index c632492..a9d3a93 100644 --- a/client/src/pages/LandingPage.jsx +++ b/client/src/pages/LandingPage.jsx @@ -1,5 +1,6 @@ import { Link } from 'react-router-dom' import { motion } from 'framer-motion' +import { FaGithub } from 'react-icons/fa' import { FiSearch, FiUsers, @@ -63,6 +64,15 @@ const LandingPage = () => { StackIt
+ + + Sign In @@ -251,7 +261,7 @@ const LandingPage = () => {
-

© 2024 StackIt. All rights reserved.

+

© {new Date().getFullYear()} StackIt. All rights reserved.

@@ -259,4 +269,4 @@ const LandingPage = () => { ) } -export default LandingPage \ No newline at end of file +export default LandingPage From 7f414ba0cb97a903ae87f2a72a6d25d60db14347 Mon Sep 17 00:00:00 2001 From: PR Date: Tue, 2 Jun 2026 19:24:04 +0530 Subject: [PATCH 2/2] feat: Add OAuth with Google & X and Fixed credentials based login & made some UI/UX changes --- .env.example | 31 ++ client/src/components/AppProviders.jsx | 22 +- client/src/components/ProfileRedirect.jsx | 22 ++ client/src/contexts/AuthContext.jsx | 142 +++++++--- client/src/pages/AuthCallbackPage.jsx | 77 +++++ client/src/pages/LandingPage.jsx | 63 +++- client/src/pages/LoginPage.jsx | 109 ++++--- client/src/pages/QuestionDetailPage.jsx | 5 +- client/src/pages/SignUpPage.jsx | 91 ++++-- client/src/routes.jsx | 12 +- client/src/utils/api.js | 25 +- server/env.example | 15 +- server/index.js | 57 +--- server/middleware/auth.js | 29 +- server/models/User.js | 16 +- server/render.yaml | 16 +- server/routes/auth.js | 331 +++++++++++++++++++++- 17 files changed, 859 insertions(+), 204 deletions(-) create mode 100644 .env.example create mode 100644 client/src/components/ProfileRedirect.jsx create mode 100644 client/src/pages/AuthCallbackPage.jsx diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bc49d13 --- /dev/null +++ b/.env.example @@ -0,0 +1,31 @@ +# Server Configuration +PORT=5000 +NODE_ENV=development +SERVER_URL=http://localhost:5000 +CLIENT_URL=http://localhost:3000 + +# Database +MONGODB_URI=mongodb://localhost:27017/stackit +MONGODB_URI_TEST=mongodb://localhost:27017/stackit-test + +# Authentication +JWT_SECRET=your-secret-jwt-string + +# Google OAuth +GOOGLE_CLIENT_ID=your-google-oauth-client-id +GOOGLE_CLIENT_SECRET=your-google-oauth-client-secret +GOOGLE_CALLBACK_URL=http://localhost:5000/api/auth/google/callback + +# Twitter/X OAuth +TWITTER_CLIENT_ID=your-twitter-oauth-client-id +TWITTER_CLIENT_SECRET=your-twitter-oauth-client-secret +TWITTER_CALLBACK_URL=http://localhost:5000/api/auth/twitter/callback + +# Rate Limiting +RATE_LIMIT_ENABLED=false +RATE_LIMIT_MAX=100 + +# Client Configuration +VITE_API_URL=/api +VITE_ENABLE_ANALYTICS=false +VITE_ENABLE_DEBUG=false diff --git a/client/src/components/AppProviders.jsx b/client/src/components/AppProviders.jsx index 9894e05..f152152 100644 --- a/client/src/components/AppProviders.jsx +++ b/client/src/components/AppProviders.jsx @@ -14,8 +14,26 @@ const AppProviders = ({ children }) => { toastOptions={{ duration: 4000, style: { - background: 'var(--toast-bg)', - color: 'var(--toast-color)', + background: '#1e293b', + color: '#f1f5f9', + borderRadius: '12px', + border: '1px solid rgba(148, 163, 184, 0.15)', + boxShadow: '0 10px 25px rgba(0, 0, 0, 0.3)', + fontSize: '14px', + fontWeight: '500', + padding: '12px 16px', + }, + success: { + iconTheme: { + primary: '#22c55e', + secondary: '#f0fdf4', + }, + }, + error: { + iconTheme: { + primary: '#ef4444', + secondary: '#fef2f2', + }, }, }} /> diff --git a/client/src/components/ProfileRedirect.jsx b/client/src/components/ProfileRedirect.jsx new file mode 100644 index 0000000..10266ca --- /dev/null +++ b/client/src/components/ProfileRedirect.jsx @@ -0,0 +1,22 @@ +import { Navigate } from 'react-router-dom' +import { useAuth } from '../contexts/AuthContext' + +const ProfileRedirect = () => { + const { user, isAuthenticated, loading } = useAuth() + + if (loading) { + return ( +
+
+
+ ) + } + + if (!isAuthenticated || !user) { + return + } + + return +} + +export default ProfileRedirect diff --git a/client/src/contexts/AuthContext.jsx b/client/src/contexts/AuthContext.jsx index 1e6c778..5cb6d81 100644 --- a/client/src/contexts/AuthContext.jsx +++ b/client/src/contexts/AuthContext.jsx @@ -1,10 +1,33 @@ -import { createContext, useContext, useState, useEffect } from 'react' +import { createContext, useContext, useState, useEffect, useCallback } from 'react' import { useNavigate } from 'react-router-dom' import { toast } from 'react-hot-toast' -import api from '../utils/api' +import api, { API_BASE_URL } from '../utils/api' const AuthContext = createContext() +// Session duration: 7 days in milliseconds +const SESSION_DURATION_MS = 7 * 24 * 60 * 60 * 1000 + +const saveSession = (token, userData) => { + localStorage.setItem('token', token) + localStorage.setItem('tokenExpiry', String(Date.now() + SESSION_DURATION_MS)) + localStorage.setItem('cachedUser', JSON.stringify(userData)) +} + +const clearSession = () => { + localStorage.removeItem('token') + localStorage.removeItem('tokenExpiry') + localStorage.removeItem('cachedUser') + delete api.defaults.headers.common['Authorization'] +} + +const isSessionValid = () => { + const token = localStorage.getItem('token') + const expiry = localStorage.getItem('tokenExpiry') + if (!token || !expiry) return false + return Date.now() < Number(expiry) +} + export const useAuth = () => { const context = useContext(AuthContext) if (!context) { @@ -14,7 +37,18 @@ export const useAuth = () => { } export const AuthProvider = ({ children }) => { - const [user, setUser] = useState(null) + const [user, setUser] = useState(() => { + // Restore user from cache immediately to prevent flash of logged-out state + if (isSessionValid()) { + try { + const cached = localStorage.getItem('cachedUser') + return cached ? JSON.parse(cached) : null + } catch { + return null + } + } + return null + }) const [loading, setLoading] = useState(true) const navigate = useNavigate() @@ -24,26 +58,29 @@ export const AuthProvider = ({ children }) => { const checkAuth = async () => { try { - const token = localStorage.getItem('token') - console.log('🔍 AuthContext Debug - Checking auth, token exists:', !!token) - - if (token) { - api.defaults.headers.common['Authorization'] = `Bearer ${token}` - const response = await api.get('/auth/me') - console.log('🔍 AuthContext Debug - Auth check successful:', response.data.user.username) - setUser(response.data.user) - } else { - console.log('🔍 AuthContext Debug - No token found, user not authenticated') + if (!isSessionValid()) { + clearSession() + setUser(null) + setLoading(false) + return } + + const token = localStorage.getItem('token') + api.defaults.headers.common['Authorization'] = `Bearer ${token}` + + // Validate token with server in the background + const response = await api.get('/auth/me') + const freshUser = response.data.user + setUser(freshUser) + // Update the cached user with fresh data + localStorage.setItem('cachedUser', JSON.stringify(freshUser)) } catch (error) { - console.log('🔍 AuthContext Debug - Auth check failed:', error.response?.data?.message) - // Only clear token if it's a token-related error + // Only clear session if it's a token-related error if (error.response?.status === 401) { const errorMessage = error.response?.data?.message || '' if (errorMessage.includes('token') || errorMessage.includes('expired') || errorMessage.includes('Invalid')) { - console.log('🔍 AuthContext Debug - Token error, clearing localStorage') - localStorage.removeItem('token') - delete api.defaults.headers.common['Authorization'] + clearSession() + setUser(null) } } } finally { @@ -55,22 +92,19 @@ export const AuthProvider = ({ children }) => { try { const response = await api.post('/auth/login', { email, password }) const { token, user } = response.data - - console.log('🔍 AuthContext Debug - Login response token:', token ? token.substring(0, 20) + '...' : 'No token') - console.log('🔍 AuthContext Debug - Login response user:', user.username) - - localStorage.setItem('token', token) - console.log('🔍 AuthContext Debug - Token saved to localStorage') - + + saveSession(token, user) api.defaults.headers.common['Authorization'] = `Bearer ${token}` setUser(user) - + toast.success('Welcome back!') navigate('/questions') return { success: true } } catch (error) { - console.log('❌ AuthContext Debug - Login error:', error.response?.data) - const message = error.response?.data?.message || 'Login failed' + let message = error.response?.data?.message || 'Login failed' + if (message === 'Validation failed' && error.response?.data?.errors?.length > 0) { + message = error.response.data.errors[0].msg + } toast.error(message) return { success: false, error: message } } @@ -80,25 +114,55 @@ export const AuthProvider = ({ children }) => { try { const response = await api.post('/auth/signup', userData) const { token, user } = response.data - - localStorage.setItem('token', token) + + saveSession(token, user) api.defaults.headers.common['Authorization'] = `Bearer ${token}` setUser(user) - + toast.success('Account created successfully!') navigate('/questions') return { success: true } } catch (error) { - const message = error.response?.data?.message || 'Signup failed' + let message = error.response?.data?.message || 'Signup failed' + if (message === 'Validation failed' && error.response?.data?.errors?.length > 0) { + message = error.response.data.errors[0].msg + } toast.error(message) return { success: false, error: message } } } + const startOAuthLogin = useCallback((provider) => { + const apiBaseUrl = API_BASE_URL.replace(/\/$/, '') + window.location.assign(`${apiBaseUrl}/auth/${provider}`) + }, []) + + const completeOAuthLogin = useCallback(async (token, provider) => { + try { + api.defaults.headers.common['Authorization'] = `Bearer ${token}` + + const response = await api.get('/auth/me') + const userData = response.data.user + saveSession(token, userData) + setUser(userData) + + const providerName = provider === 'twitter' ? 'Twitter' : 'Google' + toast.success(`Signed in with ${providerName}`) + navigate('/questions') + return { success: true } + } catch (error) { + clearSession() + setUser(null) + + const message = error.response?.data?.message || 'OAuth login failed' + toast.error(message) + navigate('/login') + return { success: false, error: message } + } + }, [navigate]) + const logout = () => { - console.log('🔍 AuthContext Debug - Logging out user') - localStorage.removeItem('token') - delete api.defaults.headers.common['Authorization'] + clearSession() setUser(null) toast.success('Logged out successfully') navigate('/') @@ -107,7 +171,9 @@ export const AuthProvider = ({ children }) => { const updateProfile = async (userData) => { try { const response = await api.put('/auth/profile', userData) - setUser(response.data.user) + const updatedUser = response.data.user + setUser(updatedUser) + localStorage.setItem('cachedUser', JSON.stringify(updatedUser)) toast.success('Profile updated successfully') return { success: true } } catch (error) { @@ -122,6 +188,8 @@ export const AuthProvider = ({ children }) => { loading, login, signup, + startOAuthLogin, + completeOAuthLogin, logout, updateProfile, isAuthenticated: !!user, @@ -133,4 +201,4 @@ export const AuthProvider = ({ children }) => { {children} ) -} \ No newline at end of file +} diff --git a/client/src/pages/AuthCallbackPage.jsx b/client/src/pages/AuthCallbackPage.jsx new file mode 100644 index 0000000..b927f43 --- /dev/null +++ b/client/src/pages/AuthCallbackPage.jsx @@ -0,0 +1,77 @@ +import { useEffect, useState } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import { motion } from 'framer-motion' +import { FiArrowLeft } from 'react-icons/fi' +import { useAuth } from '../contexts/AuthContext' + +const AuthCallbackPage = () => { + const [error, setError] = useState('') + const { completeOAuthLogin } = useAuth() + const navigate = useNavigate() + + useEffect(() => { + const hashParams = new URLSearchParams(window.location.hash.replace(/^#/, '')) + const queryParams = new URLSearchParams(window.location.search) + const token = hashParams.get('token') + const provider = hashParams.get('provider') || queryParams.get('provider') + const oauthError = queryParams.get('error') + + if (oauthError) { + setError('OAuth sign in failed. Please try again.') + return + } + + if (!token) { + setError('OAuth sign in did not return a token.') + return + } + + completeOAuthLogin(token, provider) + }, [completeOAuthLogin, navigate]) + + return ( +
+ + + StackIt + + + {error ? ( + <> +

+ Sign in failed +

+

+ {error} +

+ + + ) : ( + <> +
+

+ Completing sign in +

+ + )} +
+
+ ) +} + +export default AuthCallbackPage diff --git a/client/src/pages/LandingPage.jsx b/client/src/pages/LandingPage.jsx index a9d3a93..af6eaa8 100644 --- a/client/src/pages/LandingPage.jsx +++ b/client/src/pages/LandingPage.jsx @@ -9,8 +9,11 @@ import { FiArrowRight, FiCheckCircle } from 'react-icons/fi' +import { useAuth } from '../contexts/AuthContext' const LandingPage = () => { + const { user, isAuthenticated, loading } = useAuth() + const features = [ { icon: FiSearch, @@ -73,12 +76,37 @@ const LandingPage = () => { > - - Sign In - - - Get Started - + {!loading && isAuthenticated ? ( + + {user?.avatar ? ( + {user.username} + ) : ( +
+ {user?.username?.charAt(0).toUpperCase()} +
+ )} + + {user?.username} + + + ) : ( + <> + + Sign In + + + Get Started + + + )} @@ -99,13 +127,22 @@ const LandingPage = () => { Ask questions, share knowledge, and build your reputation in a community of experts and learners.

- - Start Asking Questions - - - - Browse Questions - + {isAuthenticated ? ( + + Go to Dashboard + + + ) : ( + <> + + Start Asking Questions + + + + Browse Questions + + + )}
diff --git a/client/src/pages/LoginPage.jsx b/client/src/pages/LoginPage.jsx index 47c4193..14bd74e 100644 --- a/client/src/pages/LoginPage.jsx +++ b/client/src/pages/LoginPage.jsx @@ -1,35 +1,35 @@ -import { useState } from 'react' -import { Link, useNavigate } from 'react-router-dom' -import { useForm } from 'react-hook-form' -import { motion } from 'framer-motion' -import { FiMail, FiLock, FiEye, FiEyeOff, FiArrowLeft } from 'react-icons/fi' -import { useAuth } from '../contexts/AuthContext' +import { useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { useForm } from 'react-hook-form'; +import { motion } from 'framer-motion'; +import { FiMail, FiLock, FiEye, FiEyeOff, FiArrowLeft } from 'react-icons/fi'; +import { useAuth } from '../contexts/AuthContext'; const LoginPage = () => { - const [showPassword, setShowPassword] = useState(false) - const [isLoading, setIsLoading] = useState(false) - const { login } = useAuth() - const navigate = useNavigate() + const [showPassword, setShowPassword] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const { login, startOAuthLogin } = useAuth(); + const navigate = useNavigate(); const { register, handleSubmit, formState: { errors }, - } = useForm() + } = useForm(); - const onSubmit = async (data) => { - setIsLoading(true) + const onSubmit = async data => { + setIsLoading(true); try { - const result = await login(data.email, data.password) + const result = await login(data.email, data.password); if (result.success) { - navigate('/questions') + navigate('/questions'); } } catch (error) { - console.error('Login error:', error) + console.error('Login error:', error); } finally { - setIsLoading(false) + setIsLoading(false); } - } + }; return (
@@ -50,7 +50,10 @@ const LoginPage = () => { className="bg-white dark:bg-navy-800 rounded-2xl shadow-xl p-8" >
- + StackIt

@@ -63,7 +66,10 @@ const LoginPage = () => {
-
- ) -} + ); +}; -export default LoginPage \ No newline at end of file +export default LoginPage; diff --git a/client/src/pages/QuestionDetailPage.jsx b/client/src/pages/QuestionDetailPage.jsx index c2e11c6..abf0e76 100644 --- a/client/src/pages/QuestionDetailPage.jsx +++ b/client/src/pages/QuestionDetailPage.jsx @@ -109,9 +109,6 @@ const QuestionDetailPage = () => { const submitAnswerMutation = useMutation( (content) => { - console.log('🔍 Frontend Debug - Submitting answer for question ID:', id); - console.log('🔍 Frontend Debug - Answer content:', content); - console.log('🔍 Frontend Debug - API call:', `/answers/${id}`); return api.post(`/answers/${id}`, { content }); }, { @@ -534,4 +531,4 @@ const QuestionDetailPage = () => { ) } -export default QuestionDetailPage \ No newline at end of file +export default QuestionDetailPage diff --git a/client/src/pages/SignUpPage.jsx b/client/src/pages/SignUpPage.jsx index 87578aa..0a66a2f 100644 --- a/client/src/pages/SignUpPage.jsx +++ b/client/src/pages/SignUpPage.jsx @@ -5,11 +5,25 @@ import { motion } from 'framer-motion' import { FiMail, FiLock, FiEye, FiEyeOff, FiUser, FiArrowLeft } from 'react-icons/fi' import { useAuth } from '../contexts/AuthContext' +const calculatePasswordStrength = (password) => { + if (!password) return { score: 0, label: '', color: 'bg-gray-200 dark:bg-navy-600' } + let score = 0 + if (password.length >= 8) score += 1 + if (/[a-z]/.test(password)) score += 1 + if (/[A-Z]/.test(password)) score += 1 + if (/\d/.test(password)) score += 1 + if (/[^A-Za-z0-9]/.test(password)) score += 1 + + if (score < 3) return { score, label: 'Weak', color: 'bg-red-500' } + if (score < 5) return { score, label: 'Medium', color: 'bg-yellow-500' } + return { score, label: 'Strong', color: 'bg-green-500' } +} + const SignUpPage = () => { const [showPassword, setShowPassword] = useState(false) const [showConfirmPassword, setShowConfirmPassword] = useState(false) const [isLoading, setIsLoading] = useState(false) - const { signup } = useAuth() + const { signup, startOAuthLogin } = useAuth() const navigate = useNavigate() const { @@ -20,6 +34,7 @@ const SignUpPage = () => { } = useForm() const password = watch('password') + const strength = calculatePasswordStrength(password) const onSubmit = async (data) => { setIsLoading(true) @@ -163,6 +178,30 @@ const SignUpPage = () => { {showPassword ? : }

+ + {/* Password strength meter */} + {password && ( +
+
+ Password strength: + + {strength.label} + +
+
+
= 1 ? strength.color : 'bg-gray-200 dark:bg-navy-600'}`} /> +
= 2 ? strength.color : 'bg-gray-200 dark:bg-navy-600'}`} /> +
= 3 ? strength.color : 'bg-gray-200 dark:bg-navy-600'}`} /> +
= 4 ? strength.color : 'bg-gray-200 dark:bg-navy-600'}`} /> +
= 5 ? strength.color : 'bg-gray-200 dark:bg-navy-600'}`} /> +
+
+ )} + {errors.password && (

{errors.password.message} @@ -262,47 +301,65 @@ const SignUpPage = () => {

- {/* Social signup options */} + {/* Social login options */}
-
- - Or sign up with + +
+ + Continue with
-
- -
@@ -312,4 +369,4 @@ const SignUpPage = () => { ) } -export default SignUpPage \ No newline at end of file +export default SignUpPage diff --git a/client/src/routes.jsx b/client/src/routes.jsx index 702f48a..0f69431 100644 --- a/client/src/routes.jsx +++ b/client/src/routes.jsx @@ -4,6 +4,7 @@ import Layout from './components/Layout' import LandingPage from './pages/LandingPage' import LoginPage from './pages/LoginPage' import SignUpPage from './pages/SignUpPage' +import AuthCallbackPage from './pages/AuthCallbackPage' import QuestionsFeed from './pages/QuestionsFeed' import AskQuestionPage from './pages/AskQuestionPage' import QuestionDetailPage from './pages/QuestionDetailPage' @@ -13,6 +14,7 @@ import TagManagementPage from './pages/TagManagementPage' import SettingsPage from './pages/SettingsPage' import ProtectedRoute from './components/ProtectedRoute' import AdminRoute from './components/AdminRoute' +import ProfileRedirect from './components/ProfileRedirect' const routes = [ { @@ -35,6 +37,10 @@ const routes = [ path: 'signup', element: }, + { + path: 'auth/callback', + element: + }, { path: 'questions', element: @@ -47,6 +53,10 @@ const routes = [ path: 'ask', element: }, + { + path: 'profile', + element: + }, { path: 'profile/:username', element: @@ -67,4 +77,4 @@ const routes = [ } ] -export default routes \ No newline at end of file +export default routes diff --git a/client/src/utils/api.js b/client/src/utils/api.js index 4d43f6e..0ae6bc6 100644 --- a/client/src/utils/api.js +++ b/client/src/utils/api.js @@ -1,7 +1,9 @@ import axios from 'axios' +export const API_BASE_URL = import.meta.env.VITE_API_URL || '/api' + const api = axios.create({ - baseURL: '/api', + baseURL: API_BASE_URL, headers: { 'Content-Type': 'application/json', }, @@ -11,20 +13,13 @@ const api = axios.create({ api.interceptors.request.use( (config) => { const token = localStorage.getItem('token') - console.log('🔍 API Debug - Token exists:', !!token) - console.log('🔍 API Debug - Request URL:', config.url) - console.log('🔍 API Debug - Request method:', config.method) - + if (token) { config.headers.Authorization = `Bearer ${token}` - console.log('🔍 API Debug - Authorization header set:', `Bearer ${token.substring(0, 20)}...`) - } else { - console.log('⚠️ API Debug - No token found in localStorage') } return config }, (error) => { - console.log('❌ API Debug - Request interceptor error:', error) return Promise.reject(error) } ) @@ -33,17 +28,15 @@ api.interceptors.request.use( api.interceptors.response.use( (response) => response, (error) => { - console.log('🔍 API Debug - Response error:', error.response?.status, error.response?.data) - if (error.response?.status === 401) { const errorMessage = error.response?.data?.message || 'Authentication failed' - console.log('🔍 API Debug - 401 error message:', errorMessage) - + // Only redirect if it's a token-related error, not other auth issues if (errorMessage.includes('token') || errorMessage.includes('expired') || errorMessage.includes('Invalid')) { - console.log('🔍 API Debug - Token error detected, clearing localStorage and redirecting') localStorage.removeItem('token') - + localStorage.removeItem('tokenExpiry') + localStorage.removeItem('cachedUser') + // Use a flag to prevent multiple redirects if (!window.isRedirecting) { window.isRedirecting = true @@ -58,4 +51,4 @@ api.interceptors.response.use( } ) -export default api \ No newline at end of file +export default api diff --git a/server/env.example b/server/env.example index 39385aa..1e14426 100644 --- a/server/env.example +++ b/server/env.example @@ -1,6 +1,7 @@ # Server Configuration PORT=5000 NODE_ENV=development +SERVER_URL=http://localhost:5000 # Database MONGODB_URI=mongodb://localhost:27017/stackit @@ -12,9 +13,21 @@ JWT_SECRET=your-super-secret-jwt-key-change-this-in-production # Client URL CLIENT_URL=http://localhost:3000 +# Google OAuth +# Authorized redirect URI in Google Cloud must match GOOGLE_CALLBACK_URL. +GOOGLE_CLIENT_ID=your-google-oauth-client-id +GOOGLE_CLIENT_SECRET=your-google-oauth-client-secret +GOOGLE_CALLBACK_URL=http://localhost:5000/api/auth/google/callback + +# Twitter/X OAuth +# Callback URI in the X Developer Portal must match TWITTER_CALLBACK_URL. +TWITTER_CLIENT_ID=your-twitter-oauth-client-id +TWITTER_CLIENT_SECRET=your-twitter-oauth-client-secret +TWITTER_CALLBACK_URL=http://localhost:5000/api/auth/twitter/callback + # Rate Limiting (optional - overrides NODE_ENV behavior) # RATE_LIMIT_ENABLED=false # Set to false to disable rate limiting completely # RATE_LIMIT_MAX=100 # Override the default max requests per window # Optional: MongoDB Atlas (for production) -# MONGODB_URI=mongodb+srv://username:password@cluster.mongodb.net/stackit?retryWrites=true&w=majority \ No newline at end of file +# MONGODB_URI=mongodb+srv://username:password@cluster.mongodb.net/stackit?retryWrites=true&w=majority diff --git a/server/index.js b/server/index.js index 3d89760..3a831a7 100644 --- a/server/index.js +++ b/server/index.js @@ -12,7 +12,7 @@ require('dotenv').config({ path: path.resolve(__dirname, '.env') }) // Fallback: manually set JWT_SECRET if .env loading failed if (!process.env.JWT_SECRET) { - console.log('🔧 JWT_SECRET not loaded from .env, setting manually') + console.warn('JWT_SECRET not loaded from .env, using development fallback') process.env.JWT_SECRET = 'stackit-super-secret-jwt-key-2024-change-in-production' process.env.PORT = process.env.PORT || '5000' process.env.NODE_ENV = process.env.NODE_ENV || 'development' @@ -20,9 +20,6 @@ if (!process.env.JWT_SECRET) { process.env.CLIENT_URL = process.env.CLIENT_URL || 'http://localhost:3000' } -console.log('✅ JWT_SECRET loaded:', !!process.env.JWT_SECRET) -console.log('✅ JWT_SECRET length:', process.env.JWT_SECRET ? process.env.JWT_SECRET.length : 0) - const connectDB = require('./config/db') const authRoutes = require('./routes/auth') const questionRoutes = require('./routes/questions') @@ -35,6 +32,8 @@ const notificationRoutes = require('./routes/notifications') const { authenticateSocket } = require('./middleware/auth') const app = express() +app.set('trust proxy', 1) + const server = createServer(app) // Socket.io setup @@ -51,7 +50,7 @@ const shouldEnableRateLimit = () => { if (process.env.RATE_LIMIT_ENABLED !== undefined) { return process.env.RATE_LIMIT_ENABLED === 'true' } - + // Production: always enabled // Development: disabled by default return process.env.NODE_ENV === 'production' @@ -62,7 +61,7 @@ const getRateLimitMax = () => { if (process.env.RATE_LIMIT_MAX) { return parseInt(process.env.RATE_LIMIT_MAX) } - + // Production: 100 requests per 15 minutes // Development: 1,000,000 requests (effectively unlimited) return process.env.NODE_ENV === 'production' ? 100 : 1000000 @@ -76,11 +75,8 @@ const limiter = rateLimit({ legacyHeaders: false, // Disable the `X-RateLimit-*` headers }) -// Log rate limiting configuration if (shouldEnableRateLimit()) { - console.log(`🔒 Rate limiting: ${getRateLimitMax()} requests per 15 minutes`) -} else { - console.log('🔓 Rate limiting: DISABLED') + console.log(`Rate limiting enabled: ${getRateLimitMax()} requests per 15 minutes`) } // Middleware @@ -89,7 +85,9 @@ app.use(cors({ origin: process.env.CLIENT_URL || "http://localhost:3000", credentials: true })) -app.use(morgan('combined')) +if (process.env.NODE_ENV === 'production' || process.env.REQUEST_LOGGING_ENABLED === 'true') { + app.use(morgan('combined')) +} // Apply rate limiting conditionally if (shouldEnableRateLimit()) { @@ -99,12 +97,6 @@ if (shouldEnableRateLimit()) { app.use(express.json({ limit: '10mb' })) app.use(express.urlencoded({ extended: true })) -// Debug middleware to log all requests -app.use((req, res, next) => { - console.log(`🔍 ${req.method} ${req.originalUrl} - ${new Date().toISOString()}`); - next(); -}); - // Routes app.use('/api/auth', authRoutes) app.use('/api/questions', questionRoutes) @@ -124,8 +116,6 @@ app.get('/api/health', (req, res) => { io.use(authenticateSocket) io.on('connection', (socket) => { - console.log('User connected:', socket.userId) - // Join user to their personal room socket.join(`user:${socket.userId}`) @@ -157,18 +147,13 @@ io.on('connection', (socket) => { socket.on('leave-question', (questionId) => { socket.leave(`question:${questionId}`) }) - - socket.on('disconnect', () => { - console.log('User disconnected:', socket.userId) - }) }) // Global error handling middleware app.use((err, req, res, next) => { - console.error('❌ Global error handler caught:', err) - console.error('❌ Error stack:', err.stack) - res.status(500).json({ - message: 'Internal Server Error', + console.error('Unhandled server error:', err) + res.status(500).json({ + message: 'Internal Server Error', error: process.env.NODE_ENV === 'development' ? err.message : 'Something went wrong' }) }) @@ -183,21 +168,9 @@ const PORT = process.env.PORT || 5000 const startServer = async () => { try { await connectDB() - - // Debug environment variables - console.log('🔍 Server Debug - JWT_SECRET exists:', !!process.env.JWT_SECRET) - console.log('🔍 Server Debug - JWT_SECRET length:', process.env.JWT_SECRET ? process.env.JWT_SECRET.length : 0) - console.log('🔍 Server Debug - NODE_ENV:', process.env.NODE_ENV) - console.log('🔍 Server Debug - PORT:', process.env.PORT || 5000) - + server.listen(PORT, () => { - console.log(`✅ Server running at http://localhost:${PORT}`) - console.log('✅ Ensure Vite proxy forwards /api calls correctly to the server.') - console.log('✅ Available routes:') - console.log(' - POST /api/answers/:questionId (create answer)') - console.log(' - GET /api/answers/question/:questionId (get answers)') - console.log(' - PUT /api/answers/:answerId (update answer)') - console.log(' - DELETE /api/answers/:answerId (delete answer)') + console.log(`Server running at http://localhost:${PORT}`) }) } catch (error) { console.error('Failed to start server:', error) @@ -207,4 +180,4 @@ const startServer = async () => { startServer() -module.exports = { app, io } \ No newline at end of file +module.exports = { app, io } diff --git a/server/middleware/auth.js b/server/middleware/auth.js index f5ce681..0375483 100644 --- a/server/middleware/auth.js +++ b/server/middleware/auth.js @@ -4,46 +4,33 @@ const User = require('../models/User') const authenticateToken = async (req, res, next) => { try { const authHeader = req.headers['authorization'] - console.log('🔍 Auth Debug - Header:', authHeader) - const token = authHeader && authHeader.split(' ')[1] - console.log('🔍 Auth Debug - Token:', token ? token.substring(0, 20) + '...' : 'No token') if (!token) { - console.log('❌ No token provided') return res.status(401).json({ message: 'Access token required' }) } - console.log('🔍 Auth Debug - JWT_SECRET exists:', !!process.env.JWT_SECRET) - console.log('🔍 Auth Debug - JWT_SECRET length:', process.env.JWT_SECRET ? process.env.JWT_SECRET.length : 0) - if (!process.env.JWT_SECRET) { - console.log('❌ JWT_SECRET is not configured') + console.error('JWT_SECRET is not configured') return res.status(500).json({ message: 'Server configuration error' }) } - + const decoded = jwt.verify(token, process.env.JWT_SECRET) - console.log('🔍 Auth Debug - Decoded token:', { userId: decoded.userId, exp: decoded.exp }) - + // Check if token is expired if (decoded.exp && Date.now() >= decoded.exp * 1000) { - console.log('❌ Token expired') return res.status(401).json({ message: 'Token expired' }) } - + const user = await User.findById(decoded.userId).select('-password') - + if (!user) { - console.log('❌ User not found for token') return res.status(401).json({ message: 'Invalid token' }) } - console.log('✅ Auth successful for user:', user.username) req.user = user next() } catch (error) { - console.log('❌ Auth error:', error.name, error.message) - if (error.name === 'JsonWebTokenError') { return res.status(401).json({ message: 'Invalid token' }) } @@ -53,8 +40,8 @@ const authenticateToken = async (req, res, next) => { if (error.name === 'NotBeforeError') { return res.status(401).json({ message: 'Token not active' }) } - - console.log('❌ Unexpected auth error:', error) + + console.error('Unexpected auth error:', error) res.status(500).json({ message: 'Server error' }) } } @@ -93,4 +80,4 @@ module.exports = { authenticateToken, authenticateSocket, requireAdmin -} \ No newline at end of file +} diff --git a/server/models/User.js b/server/models/User.js index 9ecce02..eacd351 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -23,6 +23,16 @@ const userSchema = new mongoose.Schema({ required: true, minlength: 6 }, + oauthProviders: { + google: { + id: String, + email: String + }, + twitter: { + id: String, + username: String + } + }, bio: { type: String, maxlength: 500, @@ -81,6 +91,8 @@ const userSchema = new mongoose.Schema({ userSchema.index({ username: 1 }) userSchema.index({ email: 1 }) userSchema.index({ reputation: -1 }) +userSchema.index({ 'oauthProviders.google.id': 1 }, { sparse: true }) +userSchema.index({ 'oauthProviders.twitter.id': 1 }, { sparse: true }) // Virtual for question count userSchema.virtual('questionCount', { @@ -101,7 +113,7 @@ userSchema.virtual('answerCount', { // Hash password before saving userSchema.pre('save', async function(next) { if (!this.isModified('password')) return next() - + try { const salt = await bcrypt.genSalt(12) this.password = await bcrypt.hash(this.password, salt) @@ -146,4 +158,4 @@ userSchema.methods.toJSON = function() { return user } -module.exports = mongoose.model('User', userSchema) \ No newline at end of file +module.exports = mongoose.model('User', userSchema) diff --git a/server/render.yaml b/server/render.yaml index 20731a9..2556fe9 100644 --- a/server/render.yaml +++ b/server/render.yaml @@ -14,6 +14,20 @@ services: sync: false - key: JWT_SECRET sync: false + - key: SERVER_URL + sync: false - key: CLIENT_URL sync: false - healthCheckPath: /api/health \ No newline at end of file + - key: GOOGLE_CLIENT_ID + sync: false + - key: GOOGLE_CLIENT_SECRET + sync: false + - key: GOOGLE_CALLBACK_URL + sync: false + - key: TWITTER_CLIENT_ID + sync: false + - key: TWITTER_CLIENT_SECRET + sync: false + - key: TWITTER_CALLBACK_URL + sync: false + healthCheckPath: /api/health diff --git a/server/routes/auth.js b/server/routes/auth.js index d94ce7f..b0db3a1 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -1,5 +1,7 @@ const express = require('express') const jwt = require('jsonwebtoken') +const crypto = require('crypto') +const axios = require('axios') const { body, validationResult } = require('express-validator') const User = require('../models/User') const { authenticateToken } = require('../middleware/auth') @@ -13,6 +15,319 @@ const generateToken = (userId) => { }) } +const getServerBaseUrl = (req) => { + return (process.env.SERVER_URL || `${req.protocol}://${req.get('host')}`).replace(/\/$/, '') +} + +const getClientBaseUrl = () => { + return (process.env.CLIENT_URL || 'http://localhost:3000').replace(/\/$/, '') +} + +const getRedirectUri = (provider, req) => { + const envKey = `${provider.toUpperCase()}_CALLBACK_URL` + return process.env[envKey] || `${getServerBaseUrl(req)}/api/auth/${provider}/callback` +} + +const createOAuthState = (provider, extra = {}) => { + return jwt.sign({ + provider, + nonce: crypto.randomBytes(16).toString('hex'), + ...extra + }, process.env.JWT_SECRET || 'your-secret-key', { + expiresIn: '10m' + }) +} + +const verifyOAuthState = (state, provider) => { + const decoded = jwt.verify(state, process.env.JWT_SECRET || 'your-secret-key') + if (decoded.provider !== provider) { + throw new Error('OAuth state provider mismatch') + } + return decoded +} + +const base64Url = (buffer) => { + return buffer + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') +} + +const createPkcePair = () => { + const verifier = base64Url(crypto.randomBytes(32)) + const challenge = base64Url(crypto.createHash('sha256').update(verifier).digest()) + return { verifier, challenge } +} + +const getProviderConfig = (provider, req) => { + const configs = { + google: { + clientId: process.env.GOOGLE_CLIENT_ID, + clientSecret: process.env.GOOGLE_CLIENT_SECRET, + redirectUri: getRedirectUri('google', req), + authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + tokenUrl: 'https://oauth2.googleapis.com/token', + scope: 'openid email profile' + }, + twitter: { + clientId: process.env.TWITTER_CLIENT_ID, + clientSecret: process.env.TWITTER_CLIENT_SECRET, + redirectUri: getRedirectUri('twitter', req), + authorizationUrl: 'https://x.com/i/oauth2/authorize', + tokenUrl: 'https://api.x.com/2/oauth2/token', + scope: 'users.read tweet.read' + } + } + + return configs[provider] +} + +const requireProviderConfig = (provider, req, res) => { + const config = getProviderConfig(provider, req) + if (!config || !config.clientId || !config.clientSecret) { + const callbackUrl = getRedirectUri(provider, req) + console.error(`OAuth ${provider} is missing credentials. Expected callback URL: ${callbackUrl}`) + res.status(503).json({ + message: `${provider} OAuth is not configured`, + required: [ + `${provider.toUpperCase()}_CLIENT_ID`, + `${provider.toUpperCase()}_CLIENT_SECRET`, + `${provider.toUpperCase()}_CALLBACK_URL` + ], + callbackUrl + }) + return null + } + return config +} + +const sanitizeUsername = (value, fallback) => { + const source = value || fallback || 'oauth_user' + let username = source + .toString() + .split('@')[0] + .replace(/[^a-zA-Z0-9_]/g, '_') + .replace(/_+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 20) + + if (username.length < 3) { + username = `user_${username}`.slice(0, 20) + } + + return username || `user_${crypto.randomBytes(4).toString('hex')}` +} + +const getAvailableUsername = async (baseUsername) => { + const base = sanitizeUsername(baseUsername) + + for (let index = 0; index < 100; index += 1) { + const suffix = index === 0 ? '' : `_${index}` + const candidate = `${base.slice(0, 20 - suffix.length)}${suffix}` + const existingUser = await User.findOne({ username: candidate }) + + if (!existingUser) { + return candidate + } + } + + return `user_${crypto.randomBytes(7).toString('hex')}`.slice(0, 20) +} + +const findOrCreateOAuthUser = async (provider, profile) => { + const providerIdPath = `oauthProviders.${provider}.id` + let user = await User.findOne({ [providerIdPath]: profile.id }) + + if (!user && profile.email && !profile.email.endsWith('@oauth.stackit.local')) { + user = await User.findOne({ email: profile.email.toLowerCase() }) + } + + if (!user) { + const oauthProviders = provider === 'google' + ? { google: { id: profile.id, email: profile.email } } + : { twitter: { id: profile.id, username: profile.username } } + + user = await User.create({ + username: await getAvailableUsername(profile.username || profile.name || profile.email), + email: profile.email.toLowerCase(), + password: crypto.randomBytes(32).toString('hex'), + avatar: profile.avatar || '', + isVerified: !!profile.isVerified, + oauthProviders + }) + return user + } + + user.oauthProviders = user.oauthProviders || {} + if (provider === 'google') { + user.oauthProviders.google = { + id: profile.id, + email: profile.email + } + } else { + user.oauthProviders.twitter = { + id: profile.id, + username: profile.username + } + } + + if (!user.avatar && profile.avatar) { + user.avatar = profile.avatar + } + + if (profile.isVerified) { + user.isVerified = true + } + + user.lastSeen = new Date() + await user.save() + return user +} + +const exchangeGoogleCode = async (config, code) => { + const tokenResponse = await axios.post(config.tokenUrl, new URLSearchParams({ + code, + client_id: config.clientId, + client_secret: config.clientSecret, + redirect_uri: config.redirectUri, + grant_type: 'authorization_code' + }), { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' } + }) + + const profileResponse = await axios.get('https://www.googleapis.com/oauth2/v2/userinfo', { + headers: { Authorization: `Bearer ${tokenResponse.data.access_token}` } + }) + + const profile = profileResponse.data + return { + id: profile.id, + email: profile.email, + name: profile.name, + username: profile.email || profile.name, + avatar: profile.picture, + isVerified: profile.verified_email + } +} + +const exchangeTwitterCode = async (config, code, codeVerifier) => { + const basicAuth = Buffer + .from(`${config.clientId}:${config.clientSecret}`) + .toString('base64') + + const tokenResponse = await axios.post(config.tokenUrl, new URLSearchParams({ + client_id: config.clientId, + code, + grant_type: 'authorization_code', + redirect_uri: config.redirectUri, + code_verifier: codeVerifier + }), { + headers: { + Authorization: `Basic ${basicAuth}`, + 'Content-Type': 'application/x-www-form-urlencoded' + } + }) + + const profileResponse = await axios.get('https://api.x.com/2/users/me', { + params: { + 'user.fields': 'profile_image_url' + }, + headers: { Authorization: `Bearer ${tokenResponse.data.access_token}` } + }) + + const profile = profileResponse.data.data + return { + id: profile.id, + email: `twitter_${profile.id}@oauth.stackit.local`, + name: profile.name, + username: profile.username, + avatar: profile.profile_image_url, + isVerified: false + } +} + +const redirectWithOAuthError = (res, provider, error) => { + const callbackUrl = new URL('/auth/callback', getClientBaseUrl()) + callbackUrl.searchParams.set('provider', provider) + callbackUrl.searchParams.set('error', error) + return res.redirect(callbackUrl.toString()) +} + +// @route GET /api/auth/:provider +// @desc Start OAuth login with Google or Twitter/X +// @access Public +router.get('/:provider(google|twitter)', (req, res) => { + const { provider } = req.params + const config = requireProviderConfig(provider, req, res) + if (!config) return + + const pkce = provider === 'twitter' ? createPkcePair() : null + const state = createOAuthState(provider, pkce ? { codeVerifier: pkce.verifier } : {}) + const authorizationUrl = new URL(config.authorizationUrl) + + authorizationUrl.searchParams.set('client_id', config.clientId) + authorizationUrl.searchParams.set('redirect_uri', config.redirectUri) + authorizationUrl.searchParams.set('response_type', 'code') + authorizationUrl.searchParams.set('scope', config.scope) + authorizationUrl.searchParams.set('state', state) + + if (provider === 'google') { + authorizationUrl.searchParams.set('access_type', 'offline') + authorizationUrl.searchParams.set('prompt', 'select_account') + } + + if (pkce) { + authorizationUrl.searchParams.set('code_challenge', pkce.challenge) + authorizationUrl.searchParams.set('code_challenge_method', 'S256') + } + + res.redirect(authorizationUrl.toString().replace(/\+/g, '%20')) +}) + +// @route GET /api/auth/:provider/callback +// @desc Complete OAuth login with Google or Twitter/X +// @access Public +router.get('/:provider(google|twitter)/callback', async (req, res) => { + const { provider } = req.params + const { code, state, error } = req.query + + if (error) { + return redirectWithOAuthError(res, provider, error) + } + + if (!code || !state) { + return redirectWithOAuthError(res, provider, 'missing_oauth_response') + } + + const config = requireProviderConfig(provider, req, res) + if (!config) return + + try { + const decodedState = verifyOAuthState(state, provider) + const profile = provider === 'google' + ? await exchangeGoogleCode(config, code) + : await exchangeTwitterCode(config, code, decodedState.codeVerifier) + + if (!profile.email) { + return redirectWithOAuthError(res, provider, 'missing_email') + } + + const user = await findOrCreateOAuthUser(provider, profile) + + if (user.isBanned) { + return redirectWithOAuthError(res, provider, 'account_banned') + } + + const token = generateToken(user._id) + const callbackUrl = `${getClientBaseUrl()}/auth/callback#token=${encodeURIComponent(token)}&provider=${encodeURIComponent(provider)}` + return res.redirect(callbackUrl) + } catch (callbackError) { + console.error(`${provider} OAuth callback error:`, callbackError.response?.data || callbackError) + return redirectWithOAuthError(res, provider, 'oauth_callback_failed') + } +}) + // @route POST /api/auth/signup // @desc Register a new user // @access Public @@ -32,7 +347,7 @@ router.post('/signup', [ try { const errors = validationResult(req) if (!errors.isEmpty()) { - return res.status(400).json({ + return res.status(400).json({ message: 'Validation failed', errors: errors.array() }) @@ -100,7 +415,7 @@ router.post('/login', [ try { const errors = validationResult(req) if (!errors.isEmpty()) { - return res.status(400).json({ + return res.status(400).json({ message: 'Validation failed', errors: errors.array() }) @@ -111,12 +426,12 @@ router.post('/login', [ // Check if user exists const user = await User.findOne({ email }) if (!user) { - return res.status(401).json({ message: 'Invalid credentials' }) + return res.status(401).json({ message: 'Email not registered' }) } // Check if user is banned if (user.isBanned) { - return res.status(403).json({ + return res.status(403).json({ message: 'Account is banned', reason: user.banReason }) @@ -125,7 +440,7 @@ router.post('/login', [ // Check password const isMatch = await user.comparePassword(password) if (!isMatch) { - return res.status(401).json({ message: 'Invalid credentials' }) + return res.status(401).json({ message: 'Password is incorrect' }) } // Update last seen @@ -218,7 +533,7 @@ router.put('/profile', [ try { const errors = validationResult(req) if (!errors.isEmpty()) { - return res.status(400).json({ + return res.status(400).json({ message: 'Validation failed', errors: errors.array() }) @@ -288,7 +603,7 @@ router.post('/change-password', [ try { const errors = validationResult(req) if (!errors.isEmpty()) { - return res.status(400).json({ + return res.status(400).json({ message: 'Validation failed', errors: errors.array() }) @@ -318,4 +633,4 @@ router.post('/change-password', [ } }) -module.exports = router \ No newline at end of file +module.exports = router