diff --git a/backend/.env.example b/backend/.env.example index 87215cc..ea83383 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,6 +1,9 @@ -PORT=5000 -MONGODB_URI=mongodb+srv://:@cluster0.thi5oil.mongodb.net/ +1PORT=4000 +MONGO_URI=mongodb://localhost:27017/sourcewiki JWT_SECRET=123456789 JWT_REFRESH_SECRET=987654321 FRONTEND_URL=http://localhost:5173 -NODE_ENV=development \ No newline at end of file +NODE_ENV=development + +# In development you can use a dev header to simulate authenticated admin: +# x-dev-user: {"id":"3","username":"AdminUser","role":"admin"} \ No newline at end of file diff --git a/backend/.env.test b/backend/.env.test new file mode 100644 index 0000000..f13d02f --- /dev/null +++ b/backend/.env.test @@ -0,0 +1 @@ +ο»ΏTEST_MONGODB_URI=mongodb://localhost:27017/jest-test-db diff --git a/backend/src/controllers/adminController.ts b/backend/src/controllers/adminController.ts new file mode 100644 index 0000000..95629b8 --- /dev/null +++ b/backend/src/controllers/adminController.ts @@ -0,0 +1,144 @@ +import { Response } from 'express'; +import { AuthRequest } from '../middleware/auth'; +import { UserModel } from '../models/User'; +import { SettingsModel } from '../models/Settings'; +import { createAuditLog } from '../utils/audit'; +import { Types } from 'mongoose'; +import { AuditLogModel } from '../models/AuditLog'; + +/* Users CRUD */ + +export const listUsers = async (req: AuthRequest, res: Response) => { + try { + const users = await UserModel.find().sort({ username: 1 }).lean(); + res.json(users); + } catch (err) { + res.status(500).json({ success: false, message: 'Failed to fetch users' }); + } +}; + +export const createUser = async (req: AuthRequest, res: Response) => { + try { + const { username, email, country, role, points, badges, joinDate, password } = req.body; + const user = new UserModel({ username, email, country, role, points: points ?? 0, badges: badges ?? [], joinDate, password }); + await user.save(); + + await createAuditLog({ + action: 'CREATE_USER', + resource: 'User', + method: 'POST', + user: { id: req.user?.id, username: req.user?.username }, + details: { createdUserId: user._id, email: user.email, role: user.role }, + }); + + res.status(201).json(user); + } catch (err) { + res.status(400).json({ success: false, message: 'Failed to create user', error: err instanceof Error ? err.message : err }); + } +}; + +export const updateUser = async (req: AuthRequest, res: Response) => { + try { + const id = req.params.id; + if (!Types.ObjectId.isValid(id) && typeof id !== 'string') { + // allow string ids from mock-data too + } + const updates = { ...req.body }; + delete (updates as any)._id; + + const user = await UserModel.findByIdAndUpdate(id, updates, { new: true }).lean(); + if (!user) return res.status(404).json({ success: false, message: 'User not found' }); + + await createAuditLog({ + action: 'UPDATE_USER', + resource: 'User', + method: 'PUT', + user: { id: req.user?.id, username: req.user?.username }, + details: { updatedUserId: user._id, updates }, + }); + + res.json(user); + } catch (err) { + res.status(400).json({ success: false, message: 'Failed to update user', error: err instanceof Error ? err.message : err }); + } +}; + +export const deleteUser = async (req: AuthRequest, res: Response) => { + try { + const id = req.params.id; + const user = await UserModel.findByIdAndDelete(id).lean(); + if (!user) return res.status(404).json({ success: false, message: 'User not found' }); + + await createAuditLog({ + action: 'DELETE_USER', + resource: 'User', + method: 'DELETE', + user: { id: req.user?.id, username: req.user?.username }, + details: { deletedUserId: user._id, email: user.email }, + }); + + res.json({ success: true }); + } catch (err) { + res.status(400).json({ success: false, message: 'Failed to delete user', error: err instanceof Error ? err.message : err }); + } +}; + +/* Settings */ + +export const getSettings = async (req: AuthRequest, res: Response) => { + try { + let settings = await SettingsModel.findOne().lean(); + if (!settings) { + const s = new SettingsModel({}); + await s.save(); + settings = s.toObject(); + } + res.json(settings); + } catch (err) { + res.status(500).json({ success: false, message: 'Failed to fetch settings' }); + } +}; + +export const updateSettings = async (req: AuthRequest, res: Response) => { + try { + const updates = req.body; + let settings = await SettingsModel.findOne(); + if (!settings) { + settings = new SettingsModel(updates); + } else { + settings.siteName = updates.siteName ?? settings.siteName; + settings.verificationPoints = updates.verificationPoints ?? settings.verificationPoints; + settings.maxSubmissionsPerDay = updates.maxSubmissionsPerDay ?? settings.maxSubmissionsPerDay; + settings.updatedAt = new Date(); + } + await settings.save(); + + await createAuditLog({ + action: 'UPDATE_SETTINGS', + resource: 'Settings', + method: 'PUT', + user: { id: req.user?.id, username: req.user?.username }, + details: { updates }, + }); + + res.json(settings.toObject()); + } catch (err) { + res.status(400).json({ success: false, message: 'Failed to update settings', error: err instanceof Error ? err.message : err }); + } +}; + +/* Audit logs listing */ + +export const listAuditLogs = async (req: AuthRequest, res: Response) => { + try { + const q: any = {}; + if (req.query.action) q.action = String(req.query.action); + if (req.query.user) { + q['user.username'] = String(req.query.user); + } + const logs = await AuditLogModel.find(q).sort({ createdAt: -1 }).limit(1000).lean(); + res.json(logs); + } catch (err) { + res.status(500).json({ success: false, message: 'Failed to fetch audit logs' }); + } +}; \ No newline at end of file diff --git a/backend/src/controllers/auditController.js b/backend/src/controllers/auditController.js new file mode 100644 index 0000000..3ee1219 --- /dev/null +++ b/backend/src/controllers/auditController.js @@ -0,0 +1,10 @@ +import AuditLog from '../models/AuditLog.js'; + +export const getAuditLogs = async (req, res) => { + const logs = await AuditLog + .find() + .populate('userId', 'username email') + .sort({ createdAt: -1 }); + + res.json(logs); +}; diff --git a/backend/src/middleware/auditLogger.js b/backend/src/middleware/auditLogger.js new file mode 100644 index 0000000..ae312a3 --- /dev/null +++ b/backend/src/middleware/auditLogger.js @@ -0,0 +1,25 @@ +import AuditLog from '../models/AuditLog.js'; + +const auditLogger = (action, resource) => { + return (req, res, next) => { + res.on('finish', async () => { + if (!req.user) return; + + try { + await AuditLog.create({ + userId: req.user._id, + action, + resource, + method: req.method, + ip: req.ip + }); + } catch (err) { + console.error('Audit log failed:', err.message); + } + }); + + next(); + }; +}; + +export default auditLogger; diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts new file mode 100644 index 0000000..2bb5780 --- /dev/null +++ b/backend/src/middleware/auth.ts @@ -0,0 +1,36 @@ +import { Request, Response, NextFunction } from 'express'; + +export interface AuthUser { + id?: string; + username?: string; + role?: string; + [k: string]: any; +} + +export interface AuthRequest extends Request { + user?: AuthUser | null; +} + +export const requireAuth = (req: AuthRequest, res: Response, next: NextFunction) => { + if (req.user) return next(); + + const devHeader = req.header('x-dev-user'); + if (devHeader) { + try { + req.user = JSON.parse(devHeader) as AuthUser; + return next(); + } catch (err) { + // fall through + } + } + + return res.status(401).json({ success: false, message: 'Unauthorized' }); +}; + +export const requireAdmin = (req: AuthRequest, res: Response, next: NextFunction) => { + if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' }); + if (req.user.role !== 'admin') { + return res.status(403).json({ success: false, message: 'Admin access required' }); + } + return next(); +}; \ No newline at end of file diff --git a/backend/src/models/AuditLog.js b/backend/src/models/AuditLog.js new file mode 100644 index 0000000..0a42300 --- /dev/null +++ b/backend/src/models/AuditLog.js @@ -0,0 +1,16 @@ +import mongoose from 'mongoose'; + +const AuditLogSchema = new mongoose.Schema({ + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true + }, + action: { type: String, required: true }, + resource: { type: String, required: true }, + method: String, + ip: String, + createdAt: { type: Date, default: Date.now } +}); + +export default mongoose.model('AuditLog', AuditLogSchema); diff --git a/backend/src/models/AuditLog.ts b/backend/src/models/AuditLog.ts new file mode 100644 index 0000000..a10e1a0 --- /dev/null +++ b/backend/src/models/AuditLog.ts @@ -0,0 +1,24 @@ +import mongoose, { Document, Schema } from 'mongoose'; + +export interface IAuditLog extends Document { + action: string; + resource?: string; + method?: string; + user?: { id?: string; username?: string } | null; + details?: any; + createdAt: Date; +} + +const AuditLogSchema = new Schema({ + action: { type: String, required: true }, + resource: { type: String }, + method: { type: String }, + user: { + id: { type: String }, + username: { type: String }, + }, + details: { type: Schema.Types.Mixed }, + createdAt: { type: Date, default: Date.now }, +}); + +export const AuditLogModel = mongoose.model('AuditLog', AuditLogSchema); \ No newline at end of file diff --git a/backend/src/models/Settings.ts b/backend/src/models/Settings.ts new file mode 100644 index 0000000..7063f3d --- /dev/null +++ b/backend/src/models/Settings.ts @@ -0,0 +1,17 @@ +import mongoose, { Document, Schema } from 'mongoose'; + +export interface ISettings extends Document { + siteName: string; + verificationPoints: number; + maxSubmissionsPerDay: number; + updatedAt: Date; +} + +const SettingsSchema = new Schema({ + siteName: { type: String, default: 'WikiSourceVerifier' }, + verificationPoints: { type: Number, default: 10 }, + maxSubmissionsPerDay: { type: Number, default: 5 }, + updatedAt: { type: Date, default: Date.now }, +}); + +export const SettingsModel = mongoose.model('Settings', SettingsSchema); \ No newline at end of file diff --git a/backend/src/models/User.ts b/backend/src/models/User.ts new file mode 100644 index 0000000..f2f00d9 --- /dev/null +++ b/backend/src/models/User.ts @@ -0,0 +1,25 @@ +import mongoose, { Document, Schema } from 'mongoose'; + +export interface IUser extends Document { + username: string; + email: string; + country?: string; + role: 'admin' | 'verifier' | 'contributor'; + points: number; + badges: string[]; + joinDate: string; + password?: string; +} + +const UserSchema = new Schema({ + username: { type: String, required: true, index: true }, + email: { type: String, required: true, unique: true, index: true }, + country: { type: String, default: 'US' }, + role: { type: String, enum: ['admin', 'verifier', 'contributor'], default: 'contributor' }, + points: { type: Number, default: 0 }, + badges: { type: [String], default: [] }, + joinDate: { type: String, default: () => new Date().toISOString().split('T')[0] }, + password: { type: String }, +}); + +export const UserModel = mongoose.model('User', UserSchema); \ No newline at end of file diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts new file mode 100644 index 0000000..da0b817 --- /dev/null +++ b/backend/src/routes/admin.ts @@ -0,0 +1,31 @@ +import express from 'express'; +import { requireAuth, requireAdmin } from '../middleware/auth'; +import { + listUsers, + createUser, + updateUser, + deleteUser, + getSettings, + updateSettings, + listAuditLogs, +} from '../controllers/adminController'; + +const router = express.Router(); + +router.use(requireAuth); +router.use(requireAdmin); + +// Users +router.get('/users', listUsers); +router.post('/users', createUser); +router.put('/users/:id', updateUser); +router.delete('/users/:id', deleteUser); + +// Settings +router.get('/settings', getSettings); +router.put('/settings', updateSettings); + +// Audit logs +router.get('/audit-logs', listAuditLogs); + +export default router; \ No newline at end of file diff --git a/backend/src/routes/auditRoutes.js b/backend/src/routes/auditRoutes.js new file mode 100644 index 0000000..6945175 --- /dev/null +++ b/backend/src/routes/auditRoutes.js @@ -0,0 +1,10 @@ +import express from 'express'; +import { getAuditLogs } from '../controllers/auditController.js'; +import { protect, authorize } from '../middleware/auth.js'; + +const router = express.Router(); + +// Protect route and allow only admin +router.get('/', protect, authorize('admin'), getAuditLogs); + +export default router; diff --git a/backend/src/routes/wikimediaOAuth1Routes.js b/backend/src/routes/wikimediaOAuth1Routes.js new file mode 100644 index 0000000..8dd59c7 --- /dev/null +++ b/backend/src/routes/wikimediaOAuth1Routes.js @@ -0,0 +1,11 @@ +import express from 'express'; + +// Minimal shim for Wikimedia OAuth1 routes used in tests +// Implement real OAuth1 flows as needed. For tests we only need the module to exist. +const router = express.Router(); + +// Example placeholder endpoints (optional) +router.get('/init', (req, res) => res.status(501).json({ success: false, message: 'Not implemented' })); +router.get('/callback', (req, res) => res.status(501).json({ success: false, message: 'Not implemented' })); + +export default router; diff --git a/backend/src/server.js b/backend/src/server.js index fe9eb8b..aecb7c3 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -1,3 +1,6 @@ +import auditRoutes from './routes/auditRoutes.js'; + + import express from 'express'; import cors from 'cors'; import helmet from 'helmet'; @@ -130,6 +133,8 @@ app.use('/api/auth', authRoutes); app.use('/api/submissions', submissionRoutes); app.use('/api/users', userRoutes); app.use('/api/admin', adminRoutes); +// Audit logs route (mounted after app is created) +app.use('/api/admin/audit-logs', auditRoutes); app.use('/api/countries', countryRoutes); app.use('/api/system', systemRoutes); app.use('/api/reports', reportsRoutes); diff --git a/backend/src/server.ts b/backend/src/server.ts new file mode 100644 index 0000000..0c5d9a7 --- /dev/null +++ b/backend/src/server.ts @@ -0,0 +1,35 @@ +import express from 'express'; +import mongoose from 'mongoose'; +import cors from 'cors'; +import adminRoutes from './routes/admin'; +import dotenv from 'dotenv'; + +dotenv.config(); + +const app = express(); +app.use(cors()); +app.use(express.json()); + +// Mount admin API +app.use('/api/admin', adminRoutes); + +// Health +app.get('/api/health', (_, res) => res.json({ ok: true })); + +const PORT = process.env.PORT || 4000; +const MONGO = process.env.MONGO_URI || 'mongodb://localhost:27017/sourcewiki'; + +mongoose + .connect(MONGO) + .then(() => { + // eslint-disable-next-line no-console + console.log('Connected to MongoDB'); + app.listen(PORT, () => { + // eslint-disable-next-line no-console + console.log(`Server listening on port ${PORT}`); + }); + }) + .catch((err) => { + // eslint-disable-next-line no-console + console.error('Failed to connect to MongoDB', err); + }); \ No newline at end of file diff --git a/backend/src/utils/audit.ts b/backend/src/utils/audit.ts new file mode 100644 index 0000000..ba976fc --- /dev/null +++ b/backend/src/utils/audit.ts @@ -0,0 +1,24 @@ +import { AuditLogModel } from '../models/AuditLog'; + +export const createAuditLog = async (opts: { + action: string; + resource?: string; + method?: string; + user?: { id?: string; username?: string } | null; + details?: any; +}) => { + try { + const log = new AuditLogModel({ + action: opts.action, + resource: opts.resource, + method: opts.method, + user: opts.user ?? null, + details: opts.details ?? {}, + }); + await log.save(); + return log; + } catch (err) { + console.error('Failed to create audit log', err); + return null; + } +}; \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index 78e80d7..d32ab70 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -45,7 +45,14 @@ "recharts": "^2.15.2", "sonner": "^2.0.3", "tailwind-merge": "*", - "vaul": "^1.1.2" + "vaul": "^1.1.2", + "vite-plugin-pwa": "^1.2.0", + "workbox-background-sync": "^7.4.0", + "workbox-core": "^7.4.0", + "workbox-routing": "^7.4.0", + "workbox-strategies": "^7.4.0", + "workbox-window": "^7.4.0", + "tailwindcss-animate": "^1.0.7" }, "devDependencies": { "@tailwindcss/postcss": "^4.1.18", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 419bd1f..5e38278 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,3 +1,7 @@ +import React, { useEffect } from 'react'; +import { Routes, Route, Link, Navigate } from 'react-router-dom'; +import { AuthProvider, useAuth } from './lib/auth-context'; +import { Navigation } from './components/Navigation'; import React, { useEffect } from "react"; import { Routes, Route, Link } from "react-router-dom"; @@ -9,12 +13,15 @@ import { CountryNavigation } from "./components/CountryNavigation"; import { LandingPage, AuthPage, + AdminAuditLogs, SubmissionForm, AdminDashboard, PublicDirectory, + BulkUserManagement, UserProfile, - CountryPage, -} from "./pages"; + SettingsPage, + CountryPage, // Merged from upstream +} from './pages'; import { Toaster } from './components/ui/sonner'; import { TooltipProvider } from './components/ui/tooltip'; import { initializeData } from './lib/mock-data'; @@ -32,7 +39,15 @@ function NotFound() { ); } -/* -------------------- App Content -------------------- */ +/* -------------------- Admin Route Guard -------------------- */ +function AdminRoute({ children }: { children: JSX.Element }) { + const { user } = useAuth(); + if (!user) return ; + if (user.role !== 'admin') return ; + return children; +} + +/* -------------------- Main App Content -------------------- */ function AppContent() { useEffect(() => { initializeData(); @@ -40,6 +55,67 @@ function AppContent() { return ( +
+ + +
+ + {/* Public Routes */} + } /> + } /> + } /> + } /> + } /> + + {/* New Upstream Route */} + } /> + + {/* Admin Routes (Your Task) */} + } /> + } /> + } /> + } /> + + {/* 404 */} + } /> + +
+ + + + {/* Footer */} +