diff --git a/backend/.env.example b/backend/.env.example index 87215cc..0fe61c0 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -3,4 +3,9 @@ MONGODB_URI=mongodb+srv://:@cluster0.thi5oil.mongodb.net/ { } }; +export const wikimediaLogin = async (req, res, next) => { + try { + const params = new URLSearchParams({ + response_type: 'code', + client_id: process.env.WIKIMEDIA_CLIENT_ID, + redirect_uri: process.env.WIKIMEDIA_CALLBACK_URL + }); + + res.redirect(`https://meta.wikimedia.org/w/rest.php/oauth2/authorize?${params.toString()}`); + } catch (error) { + next(error); + } +}; + +export const wikimediaCallback = async (req, res, next) => { + try { + const { code, error: oauthError } = req.query; + + if (oauthError || !code) { + return res.redirect(`${process.env.FRONTEND_URL}/auth?error=${oauthError || 'No code provided'}`); + } + + const userAgent = process.env.WIKIMEDIA_USER_AGENT || 'WikiSourceVerifier/1.0 (Open Source Application; admin@localhost)'; + + const tokenResponse = await fetch('https://meta.wikimedia.org/w/rest.php/oauth2/access_token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'User-Agent': userAgent + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + client_id: process.env.WIKIMEDIA_CLIENT_ID, + client_secret: process.env.WIKIMEDIA_CLIENT_SECRET, + redirect_uri: process.env.WIKIMEDIA_CALLBACK_URL + }) + }); + + const tokenData = await tokenResponse.json(); + if (!tokenResponse.ok) { + console.error('Token Error:', tokenData); + return res.redirect(`${process.env.FRONTEND_URL}/auth?error=Authentication failed`); + } + + const profileResponse = await fetch('https://meta.wikimedia.org/w/rest.php/oauth2/resource/profile', { + headers: { + Authorization: `Bearer ${tokenData.access_token}`, + 'User-Agent': userAgent + } + }); + const profile = await profileResponse.json(); + if (!profileResponse.ok) { + console.error('Profile Error:', profile); + return res.redirect(`${process.env.FRONTEND_URL}/auth?error=Failed to fetch profile`); + } + + let user = await User.findOne({ wikimedia_id: profile.sub }); + + if (!user) { + let finalUsername = profile.username; + const existingUser = await User.findOne({ username: profile.username }); + if (existingUser) { + finalUsername = `${profile.username}_${Math.random().toString(36).substring(7)}`; + } + + user = await User.create({ + username: finalUsername, + email: profile.email || `${finalUsername}@example.com`, + country: 'Global', + wikimedia_id: profile.sub, + wikimedia_username: profile.username, + isActive: true + }); + } else if (!user.isActive) { + return res.redirect(`${process.env.FRONTEND_URL}/auth?error=Account deactivated`); + } + + const accessToken = generateAccessToken(user._id); + const refreshToken = generateRefreshToken(user._id); + + res.redirect(`${process.env.FRONTEND_URL}/auth/callback?accessToken=${accessToken}&refreshToken=${refreshToken}`); + + } catch (error) { + console.error('Wikimedia OAuth catch error:', error); + res.redirect(`${process.env.FRONTEND_URL}/auth?error=Server error`); + } +}; + diff --git a/backend/src/models/User.js b/backend/src/models/User.js index 62fa51b..191f97a 100644 --- a/backend/src/models/User.js +++ b/backend/src/models/User.js @@ -20,10 +20,21 @@ const userSchema = new mongoose.Schema({ }, password: { type: String, - required: [true, 'Password is required'], + required: [ + function() { return !this.wikimedia_id; }, + 'Password is required' + ], minlength: [6, 'Password must be at least 6 characters'], select: false }, + wikimedia_id: { + type: String, + unique: true, + sparse: true + }, + wikimedia_username: { + type: String + }, country: { type: String, required: [true, 'Country is required'] @@ -63,7 +74,7 @@ const userSchema = new mongoose.Schema({ // Hash password before saving userSchema.pre('save', async function(next) { - if (!this.isModified('password')) { + if (!this.isModified('password') || !this.password) { return next(); } diff --git a/backend/src/routes/authRoutes.js b/backend/src/routes/authRoutes.js index 9e00533..91c5e1a 100644 --- a/backend/src/routes/authRoutes.js +++ b/backend/src/routes/authRoutes.js @@ -6,7 +6,9 @@ import { getMe, refreshToken, updateProfile, - changePassword + changePassword, + wikimediaLogin, + wikimediaCallback } from '../controllers/authController.js'; import { protect } from '../middleware/auth.js'; import { registerValidation, loginValidation, validate } from '../middleware/validator.js'; @@ -15,6 +17,8 @@ const router = express.Router(); router.post('/register', registerValidation, validate, register); router.post('/login', loginValidation, validate, login); +router.get('/wikimedia', wikimediaLogin); +router.get('/wikimedia/callback', wikimediaCallback); router.post('/logout', protect, logout); router.get('/me', protect, getMe); router.post('/refresh', refreshToken); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 419bd1f..f08afc1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,7 @@ import { PublicDirectory, UserProfile, CountryPage, + OAuthCallback, } from "./pages"; import { Toaster } from './components/ui/sonner'; import { TooltipProvider } from './components/ui/tooltip'; @@ -48,6 +49,7 @@ function AppContent() { } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/lib/auth-context.tsx b/frontend/src/lib/auth-context.tsx index d8b593a..67e5d8a 100644 --- a/frontend/src/lib/auth-context.tsx +++ b/frontend/src/lib/auth-context.tsx @@ -18,6 +18,7 @@ interface AuthContextType { user: User | null; loading: boolean; login: (username: string, password: string) => Promise; + loginWithTokens: (accessToken: string, refreshToken: string) => Promise; logout: () => void; register: (username: string, email: string, password: string, country: string) => Promise; updateUser: (updates: Partial) => void; @@ -66,6 +67,25 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children } }; + const loginWithTokens = async (accessToken: string, refreshToken: string): Promise => { + try { + api.setTokens(accessToken, refreshToken); + const response = await authApi.getMe(); + if (response.success && response.user) { + setUser(response.user); + toast.success('Login successful!'); + return true; + } + api.clearAuth(); + toast.error('Failed to get user profile'); + return false; + } catch (error) { + api.clearAuth(); + toast.error('Login failed'); + return false; + } + }; + const logout = async () => { try { await authApi.logout(); @@ -122,7 +142,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }; return ( - + {children} ); diff --git a/frontend/src/pages/AuthPage.tsx b/frontend/src/pages/AuthPage.tsx index e1f3a8d..eba63d2 100644 --- a/frontend/src/pages/AuthPage.tsx +++ b/frontend/src/pages/AuthPage.tsx @@ -9,13 +9,29 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '. import { useAuth } from '../lib/auth-context'; import { COUNTRIES } from '../lib/mock-data'; import { toast } from 'sonner'; -import { Loader2 } from 'lucide-react'; +import { Loader2, Globe } from 'lucide-react'; +import { useEffect } from 'react'; export const AuthPage: React.FC = () => { const navigate = useNavigate(); const { login, register } = useAuth(); const [loading, setLoading] = useState(false); + useEffect(() => { + // Check for errors from OAuth redirect + const params = new URLSearchParams(window.location.search); + const error = params.get('error'); + if (error) { + toast.error(error); + } + }, []); + + const handleWikimediaLogin = () => { + window.location.href = import.meta.env.VITE_API_URL + ? `${import.meta.env.VITE_API_URL}/auth/wikimedia` + : 'http://localhost:5000/api/auth/wikimedia'; + }; + // Login form state const [loginUsername, setLoginUsername] = useState(''); const [loginPassword, setLoginPassword] = useState(''); @@ -72,6 +88,25 @@ export const AuthPage: React.FC = () => {

Sign in or create an account to get started

+
+ + +
+
+ +
+
+ Or continue with +
+
+
+ Login diff --git a/frontend/src/pages/OAuthCallback.tsx b/frontend/src/pages/OAuthCallback.tsx new file mode 100644 index 0000000..85a4f01 --- /dev/null +++ b/frontend/src/pages/OAuthCallback.tsx @@ -0,0 +1,50 @@ +import React, { useEffect, useRef } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { useAuth } from '../lib/auth-context'; +import { Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; + +export const OAuthCallback: React.FC = () => { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const { loginWithTokens } = useAuth(); + + // Use a ref to prevent double execution in strict mode + const processed = useRef(false); + + useEffect(() => { + if (processed.current) return; + processed.current = true; + + const accessToken = searchParams.get('accessToken'); + const refreshToken = searchParams.get('refreshToken'); + const error = searchParams.get('error'); + + if (error) { + toast.error(`Authentication failed: ${error}`); + navigate('/auth'); + return; + } + + if (accessToken && refreshToken) { + loginWithTokens(accessToken, refreshToken).then(success => { + if (success) { + navigate('/'); + } else { + navigate('/auth'); + } + }); + } else { + toast.error('Authentication failed: Missing tokens'); + navigate('/auth'); + } + }, [searchParams, navigate, loginWithTokens]); + + return ( +
+ +

Completing sign in...

+

Please wait while we redirect you.

+
+ ); +}; diff --git a/frontend/src/pages/index.ts b/frontend/src/pages/index.ts index ae27df5..b9e1c45 100644 --- a/frontend/src/pages/index.ts +++ b/frontend/src/pages/index.ts @@ -7,3 +7,4 @@ export { AdminDashboard } from './AdminDashboard'; export { PublicDirectory } from './PublicDirectory'; export { UserProfile } from './UserProfile'; export { CountryPage } from './CountryPage'; +export { OAuthCallback } from './OAuthCallback';