diff --git a/backend/.env.example b/backend/.env.example index 87215cc..5e300bd 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,6 +1,21 @@ +# Environment Configuration +NODE_ENV=development + +# Server Configuration PORT=5000 -MONGODB_URI=mongodb+srv://:@cluster0.thi5oil.mongodb.net/ -JWT_SECRET=123456789 -JWT_REFRESH_SECRET=987654321 + +# Database Configuration +MONGODB_URI=mongodb://localhost:27017/wikisource_verifier + +# JWT Configuration +JWT_SECRET=your_super_secure_jwt_secret_key_here +JWT_REFRESH_SECRET=your_super_secure_refresh_secret_key_here +JWT_EXPIRE=15m +JWT_REFRESH_EXPIRE=7d + +# Frontend URL (for CORS) FRONTEND_URL=http://localhost:5173 -NODE_ENV=development \ No newline at end of file + +# Optional: Rate limiting +RATE_LIMIT_WINDOW=15 +RATE_LIMIT_MAX_REQUESTS=100 diff --git a/backend/src/config/config.js b/backend/src/config/config.js index a8c9f51..8d9ba82 100644 --- a/backend/src/config/config.js +++ b/backend/src/config/config.js @@ -1,14 +1,17 @@ + + import dotenv from 'dotenv'; dotenv.config(); const config = { port: process.env.PORT || 5000, - // Fallback to local Mongo for development if env var is not set - mongodbUri: process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/wsv', - // Development defaults; override in production via environment variables - jwtSecret: process.env.JWT_SECRET || 'dev_jwt_secret_change_me', - jwtExpire: process.env.JWT_EXPIRE || '30d', + mongodbUri: process.env.MONGODB_URI, + jwtSecret: process.env.JWT_SECRET, + jwtRefreshSecret: process.env.JWT_REFRESH_SECRET, + jwtExpire: process.env.JWT_EXPIRE || '15m', + jwtRefreshExpire: process.env.JWT_REFRESH_EXPIRE || '7d', + nodeEnv: process.env.NODE_ENV || 'development', }; -export default config; \ No newline at end of file +export default config; diff --git a/backend/src/server.js b/backend/src/server.js index 4ef5e92..4353169 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -36,24 +36,11 @@ app.use((req, res, next) => { // Security middleware app.use(helmet()); -app.use( - helmet.contentSecurityPolicy({ - directives: { - defaultSrc: ["'self'"], - scriptSrc: ["'self'"], - styleSrc: ["'self'", "'unsafe-inline'"], - imgSrc: ["'self'", "data:"], - connectSrc: ["'self'"], - objectSrc: ["'none'"], - frameAncestors: ["'none'"] - } - }) -); - // CORS configuration const allowedOrigins = [ 'http://localhost:3000', + 'http://localhost:3001', 'http://localhost:5173', process.env.FRONTEND_URL ].filter(Boolean); diff --git a/backend/src/utils/jwt.js b/backend/src/utils/jwt.js index 60afe29..afd5ccc 100644 --- a/backend/src/utils/jwt.js +++ b/backend/src/utils/jwt.js @@ -1,24 +1,26 @@ + import jwt from 'jsonwebtoken'; +import config from '../config/config.js'; export const generateAccessToken = (userId) => { return jwt.sign( { id: userId }, - process.env.JWT_SECRET, - { expiresIn: process.env.JWT_EXPIRE || '15m' } + config.jwtSecret, + { expiresIn: config.jwtExpire } ); }; export const generateRefreshToken = (userId) => { return jwt.sign( { id: userId }, - process.env.JWT_REFRESH_SECRET, - { expiresIn: process.env.JWT_REFRESH_EXPIRE || '7d' } + config.jwtRefreshSecret, + { expiresIn: config.jwtRefreshExpire } ); }; export const verifyRefreshToken = (token) => { try { - return jwt.verify(token, process.env.JWT_REFRESH_SECRET); + return jwt.verify(token, config.jwtRefreshSecret); } catch (error) { return null; } @@ -30,8 +32,8 @@ export const sendTokenResponse = (user, statusCode, res) => { const cookieOptions = { httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'strict', + secure: config.nodeEnv === 'production', + sameSite: config.nodeEnv === 'production' ? 'strict' : 'lax', maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days }; diff --git a/frontend/.env.example b/frontend/.env.example index 1b1b75e..e35f2be 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1 +1,5 @@ -VITE_API_URL=http://localhost:3000/api +# API Configuration +VITE_API_URL=http://localhost:5000/api + +# Optional: Development settings +# VITE_DEBUG_MODE=true diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9759591..db146c3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,18 +1,13 @@ -import { useEffect } from 'react'; -import { Routes, Route, Link } from 'react-router-dom'; +import React, { useEffect } from 'react'; +import { Routes, Route, Link, Navigate, BrowserRouter as Router } from 'react-router-dom'; import { AuthProvider } from './lib/auth-context'; import { Navigation } from './components/Navigation'; -import { - LandingPage, - AuthPage, - SubmissionForm, - AdminDashboard, - PublicDirectory, - UserProfile -} from './pages'; +import { ProtectedRoute } from './components/ProtectedRoute'; +import { LandingPage, AuthPage, SubmissionForm, AdminDashboard, PublicDirectory, UserProfile } from './pages'; import { Toaster } from './components/ui/sonner'; import { TooltipProvider } from './components/ui/tooltip'; import { initializeData } from './lib/mock-data'; +import { TooltipProvider } from './components/ui/tooltip'; // only if TooltipProvider is needed function NotFound() { return ( @@ -40,10 +35,37 @@ function AppContent() { } /> } /> - } /> - } /> } /> - } /> + + {/* Protected Routes */} + + + + } + /> + + + + } + /> + + {/* Admin Route */} + + + + } + /> + + {/* 404 */} } /> @@ -57,39 +79,19 @@ function AppContent() {

About WikiSourceVerifier

- A community-driven platform for verifying Wikipedia - references and maintaining source quality standards. + A community-driven platform for verifying Wikipedia references and maintaining source quality standards.

+

Quick Links

    -
  • - - Browse Directory - -
  • -
  • - - Submit Reference - -
  • -
  • - - Login / Register - -
  • +
  • Browse Directory
  • +
  • Submit Reference
  • +
  • Login / Register
+

Resources

    @@ -100,14 +102,10 @@ function AppContent() {
+
-

- © 2025 WikiSourceVerifier. Built for the Wikipedia community. -

-

- This is a demonstration platform. For production use, connect - to a real backend service. -

+

© 2025 WikiSourceVerifier. Built for the Wikipedia community.

+

This is a demonstration platform. For production use, connect to a real backend service.

diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..2ac2f4a --- /dev/null +++ b/frontend/src/components/ProtectedRoute.tsx @@ -0,0 +1,56 @@ + +import React from 'react'; +import { Navigate } from 'react-router-dom'; +import { useAuth } from '../lib/auth-context'; +import { Loader2 } from 'lucide-react'; + +interface ProtectedRouteProps { + children: React.ReactNode; + requiredRole?: 'contributor' | 'verifier' | 'admin'; + redirectTo?: string; +} + +export const ProtectedRoute: React.FC = ({ + children, + requiredRole, + redirectTo = '/auth' +}) => { + const { user, loading } = useAuth(); + + if (loading) { + return ( +
+
+ + Loading... +
+
+ ); + } + + if (!user) { + return ; + } + + if (requiredRole) { + const roleHierarchy = { contributor: 1, verifier: 2, admin: 3 }; + const userLevel = roleHierarchy[user.role]; + const requiredLevel = roleHierarchy[requiredRole]; + + if (userLevel < requiredLevel) { + return ( +
+
+

Access Denied

+

+ You need {requiredRole} privileges to access this page. +

+ +
+
+ ); + } + } + + return <>{children}; +}; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index bbff447..2bec32a 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,3 +1,4 @@ + const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:5000/api'; interface ApiResponse { @@ -9,35 +10,36 @@ interface ApiResponse { class ApiClient { private baseURL: string; - private accessToken: string | null = null; - private refreshToken: string | null = null; + private isRefreshing = false; + private failedQueue: Array<{ + resolve: (value: string) => void; + reject: (reason: any) => void; + }> = []; constructor(baseURL: string) { this.baseURL = baseURL; - this.loadTokens(); - } - - private loadTokens() { - this.accessToken = localStorage.getItem('accessToken'); - this.refreshToken = localStorage.getItem('refreshToken'); } - private saveTokens(accessToken: string, refreshToken: string) { - this.accessToken = accessToken; - this.refreshToken = refreshToken; - localStorage.setItem('accessToken', accessToken); - localStorage.setItem('refreshToken', refreshToken); + private processQueue(error: any, token: string | null = null) { + this.failedQueue.forEach(({ resolve, reject }) => { + if (error) { + reject(error); + } else { + resolve(token!); + } + }); + + this.failedQueue = []; } - private clearTokens() { - this.accessToken = null; - this.refreshToken = null; - localStorage.removeItem('accessToken'); - localStorage.removeItem('refreshToken'); - } + private async refreshAccessToken(): Promise { + if (this.isRefreshing) { + return new Promise((resolve, reject) => { + this.failedQueue.push({ resolve, reject }); + }); + } - private async refreshAccessToken(): Promise { - if (!this.refreshToken) return false; + this.isRefreshing = true; try { const response = await fetch(`${this.baseURL}/auth/refresh`, { @@ -45,20 +47,22 @@ class ApiClient { headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ refreshToken: this.refreshToken }), + credentials: 'include', }); if (response.ok) { const data = await response.json(); - this.saveTokens(data.accessToken, this.refreshToken!); - return true; + this.processQueue(null, data.accessToken); + return data.accessToken; + } else { + this.processQueue(new Error('Token refresh failed'), null); + throw new Error('Token refresh failed'); } - - this.clearTokens(); - return false; } catch (error) { - this.clearTokens(); - return false; + this.processQueue(error, null); + throw error; + } finally { + this.isRefreshing = false; } } @@ -72,10 +76,6 @@ class ApiClient { ...(options.headers as Record), }; - if (this.accessToken) { - headers['Authorization'] = `Bearer ${this.accessToken}`; - } - try { let response = await fetch(url, { ...options, @@ -83,17 +83,20 @@ class ApiClient { credentials: 'include', }); - // If unauthorized and we have a refresh token, try to refresh - if (response.status === 401 && this.refreshToken) { - const refreshed = await this.refreshAccessToken(); - if (refreshed) { - // Retry the request with new token - headers['Authorization'] = `Bearer ${this.accessToken}`; + // If unauthorized, try to refresh token + if (response.status === 401) { + try { + const newToken = await this.refreshAccessToken(); + headers['Authorization'] = `Bearer ${newToken}`; + response = await fetch(url, { ...options, headers, credentials: 'include', }); + } catch (refreshError) { + // Refresh failed, redirect to login or throw error + throw new Error('Authentication required'); } } @@ -156,16 +159,16 @@ class ApiClient { return this.request(endpoint, { method: 'DELETE' }); } - setTokens(accessToken: string, refreshToken: string) { - this.saveTokens(accessToken, refreshToken); - } - clearAuth() { - this.clearTokens(); + // Clear any pending refresh requests + this.failedQueue = []; } isAuthenticated(): boolean { - return !!this.accessToken; + // For cookie-based auth, we need to check with the server + // This is a simplified check - in a real app you might want + // to ping the auth/me endpoint + return true; // Will be validated server-side } } diff --git a/frontend/src/lib/auth-context.tsx b/frontend/src/lib/auth-context.tsx index d8b593a..4f1797f 100644 --- a/frontend/src/lib/auth-context.tsx +++ b/frontend/src/lib/auth-context.tsx @@ -14,6 +14,7 @@ export interface User { isActive: boolean; } + interface AuthContextType { user: User | null; loading: boolean; @@ -47,12 +48,12 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children checkAuth(); }, []); + const login = async (username: string, password: string): Promise => { try { const response = await authApi.login(username, password); if (response.success) { - api.setTokens(response.accessToken, response.refreshToken); setUser(response.user); toast.success('Login successful!'); return true; @@ -78,12 +79,12 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children } }; + const register = async (username: string, email: string, password: string, country: string): Promise => { try { const response = await authApi.register(username, email, password, country); if (response.success) { - api.setTokens(response.accessToken, response.refreshToken); setUser(response.user); toast.success('Registration successful!'); return true; @@ -121,6 +122,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children } }; + return ( {children} diff --git a/frontend/src/lib/mock-data.ts b/frontend/src/lib/mock-data.ts index 83c640c..9232e41 100644 --- a/frontend/src/lib/mock-data.ts +++ b/frontend/src/lib/mock-data.ts @@ -79,6 +79,7 @@ export const mockUsers: User[] = [ id: '2', username: 'SourceVerifier', email: 'verifier@example.com', + country: 'GB', role: 'verifier', points: 450, diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts index b54b4c9..e8dc2be 100644 --- a/frontend/src/vite-env.d.ts +++ b/frontend/src/vite-env.d.ts @@ -1,7 +1,9 @@ + /// interface ImportMetaEnv { readonly VITE_API_URL: string + // Add other env variables as needed } interface ImportMeta {