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 fae078a..50a1e34 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 { useNavigate } from 'react-router-dom'; -import { toast } from 'react-hot-toast'; -import api from '../utils/api'; +import { createContext, useContext, useState, useEffect, useCallback } from 'react' +import { useNavigate } from 'react-router-dom' +import { toast } from 'react-hot-toast' +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,9 +37,20 @@ export const useAuth = () => { }; export const AuthProvider = ({ children }) => { - const [user, setUser] = useState(null); - const [loading, setLoading] = useState(true); - const navigate = useNavigate(); + 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() useEffect(() => { checkAuth(); @@ -24,43 +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('api/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']; + const errorMessage = error.response?.data?.message || '' + if (errorMessage.includes('token') || errorMessage.includes('expired') || errorMessage.includes('Invalid')) { + clearSession() + setUser(null) } } } finally { @@ -70,66 +90,92 @@ export const AuthProvider = ({ children }) => { const login = async (email, password) => { try { - const response = await api.post('api/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); + const response = await api.post('/auth/login', { email, password }) + const { token, user } = response.data - localStorage.setItem('token', token); - console.log('🔍 AuthContext Debug - Token saved to localStorage'); + saveSession(token, user) + api.defaults.headers.common['Authorization'] = `Bearer ${token}` + setUser(user) - api.defaults.headers.common['Authorization'] = `Bearer ${token}`; - setUser(user); - - toast.success('Welcome back!'); - navigate('/questions'); - return { success: true }; + 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'; - toast.error(message); - return { success: false, error: message }; + 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 } } }; const signup = async userData => { try { - const response = await api.post('api/auth/signup', userData); - const { token, user } = response.data; + const response = await api.post('/auth/signup', userData) + const { token, user } = response.data - localStorage.setItem('token', token); - api.defaults.headers.common['Authorization'] = `Bearer ${token}`; - setUser(user); + saveSession(token, user) + api.defaults.headers.common['Authorization'] = `Bearer ${token}` + setUser(user) - toast.success('Account created successfully!'); - navigate('/questions'); - return { success: true }; + toast.success('Account created successfully!') + navigate('/questions') + return { success: true } } catch (error) { - const message = error.response?.data?.message || 'Signup failed'; - toast.error(message); - return { success: false, error: message }; + 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']; - setUser(null); - toast.success('Logged out successfully'); - navigate('/'); - }; + clearSession() + setUser(null) + toast.success('Logged out successfully') + navigate('/') + } const updateProfile = async userData => { try { - const response = await api.put('api/auth/profile', userData); - setUser(response.data.user); - toast.success('Profile updated successfully'); - return { success: true }; + const response = await api.put('/auth/profile', userData) + const updatedUser = response.data.user + setUser(updatedUser) + localStorage.setItem('cachedUser', JSON.stringify(updatedUser)) + toast.success('Profile updated successfully') + return { success: true } } catch (error) { const message = error.response?.data?.message || 'Update failed'; toast.error(message); @@ -142,11 +188,17 @@ export const AuthProvider = ({ children }) => { loading, login, signup, + startOAuthLogin, + completeOAuthLogin, logout, updateProfile, isAuthenticated: !!user, isAdmin: user?.role === 'admin', }; - return {children}; -}; + return ( + + {children} + + ) +} 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 c632492..af6eaa8 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, @@ -8,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, @@ -63,12 +67,46 @@ const LandingPage = () => { StackIt
- - Sign In - - - Get Started - + + + + {!loading && isAuthenticated ? ( + + {user?.avatar ? ( + {user.username} + ) : ( +
+ {user?.username?.charAt(0).toUpperCase()} +
+ )} + + {user?.username} + + + ) : ( + <> + + Sign In + + + Get Started + + + )}
@@ -89,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 + + + )}
@@ -251,7 +298,7 @@ const LandingPage = () => {
-

© 2024 StackIt. All rights reserved.

+

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

@@ -259,4 +306,4 @@ const LandingPage = () => { ) } -export default LandingPage \ No newline at end of file +export default LandingPage 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 e50d4f7..4977dae 100644 --- a/client/src/pages/QuestionDetailPage.jsx +++ b/client/src/pages/QuestionDetailPage.jsx @@ -112,11 +112,8 @@ 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(`api/answers/${id}`, { content }); + (content) => { + return api.post(`/answers/${id}`, { content }); }, { onSuccess: () => { @@ -559,4 +556,4 @@ const QuestionDetailPage = () => { ); }; -export default QuestionDetailPage; +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 f9279bb..6a53140 100644 --- a/client/src/utils/api.js +++ b/client/src/utils/api.js @@ -1,8 +1,9 @@ import axios from 'axios'; +export const API_BASE_URL = import.meta.env.VITE_API_URL || '/api' + const api = axios.create({ - baseURL: import.meta.env.VITE_API_URL, - withCredentials: true, + baseURL: API_BASE_URL, headers: { 'Content-Type': 'application/json', }, @@ -10,54 +11,31 @@ const api = axios.create({ // Request interceptor to add auth token 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); + (config) => { + const token = localStorage.getItem('token') 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'); + config.headers.Authorization = `Bearer ${token}` } return config; }, - error => { - console.log('❌ API Debug - Request interceptor error:', error); - return Promise.reject(error); + (error) => { + return Promise.reject(error) } ); // Response interceptor to handle errors api.interceptors.response.use( - response => response, - error => { - console.log( - '🔍 API Debug - Response error:', - error.response?.status, - error.response?.data - ); - + (response) => response, + (error) => { if (error.response?.status === 401) { - const errorMessage = - error.response?.data?.message || 'Authentication failed'; - console.log('🔍 API Debug - 401 error message:', errorMessage); + const errorMessage = error.response?.data?.message || 'Authentication failed' // 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'); + if (errorMessage.includes('token') || errorMessage.includes('expired') || errorMessage.includes('Invalid')) { + localStorage.removeItem('token') + localStorage.removeItem('tokenExpiry') + localStorage.removeItem('cachedUser') // Use a flag to prevent multiple redirects if (!window.isRedirecting) { @@ -73,4 +51,4 @@ api.interceptors.response.use( } ); -export default api; +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 71006bd..7505855 100644 --- a/server/index.js +++ b/server/index.js @@ -12,25 +12,17 @@ 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'); - 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'; - process.env.MONGODB_URI = - process.env.MONGODB_URI || 'mongodb://localhost:27017/stackit'; - process.env.CLIENT_URL = process.env.CLIENT_URL || 'http://localhost:3000'; + 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' + process.env.MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/stackit' + 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'); +const connectDB = require('./config/db') +const authRoutes = require('./routes/auth') +const questionRoutes = require('./routes/questions') const answersRoutes = require('./routes/answers'); const userRoutes = require('./routes/users'); const tagRoutes = require('./routes/tags'); @@ -39,8 +31,10 @@ const commentRoutes = require('./routes/comments'); const notificationRoutes = require('./routes/notifications'); const { authenticateSocket } = require('./middleware/auth'); -const app = express(); -const server = createServer(app); +const app = express() +app.set('trust proxy', 1) + +const server = createServer(app) // Socket.io setup const io = new Server(server, { @@ -81,22 +75,19 @@ 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 -app.use(helmet()); -app.use( - cors({ - origin: process.env.CLIENT_URL || 'http://localhost:3000', - credentials: true, - }), -); -app.use(morgan('combined')); +app.use(helmet()) +app.use(cors({ + origin: process.env.CLIENT_URL || "http://localhost:3000", + credentials: true +})) +if (process.env.NODE_ENV === 'production' || process.env.REQUEST_LOGGING_ENABLED === 'true') { + app.use(morgan('combined')) +} // Apply rate limiting conditionally if (shouldEnableRateLimit()) { @@ -106,14 +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); @@ -133,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}`); @@ -164,26 +145,18 @@ io.on('connection', (socket) => { }); socket.on('leave-question', (questionId) => { - socket.leave(`question:${questionId}`); - }); - - socket.on('disconnect', () => { - console.log('User disconnected:', socket.userId); - }); -}); + socket.leave(`question:${questionId}`) + }) +}) // Global error handling middleware app.use((err, req, res, next) => { - console.error('❌ Global error handler caught:', err); - console.error('❌ Error stack:', err.stack); + 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', - }); -}); + error: process.env.NODE_ENV === 'development' ? err.message : 'Something went wrong' + }) +}) // 404 handler app.use('*', (req, res) => { @@ -194,31 +167,11 @@ 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); + await connectDB() 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); process.exit(1); @@ -227,4 +180,4 @@ const startServer = async () => { startServer(); -module.exports = { app, io }; +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