Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions backend/.env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
PORT=5000
MONGODB_URI=mongodb+srv://<username>:<pass>@cluster0.thi5oil.mongodb.net/<databasemame>
1PORT=4000
MONGO_URI=mongodb://localhost:27017/sourcewiki
JWT_SECRET=123456789
JWT_REFRESH_SECRET=987654321
FRONTEND_URL=http://localhost:5173
NODE_ENV=development
NODE_ENV=development

# In development you can use a dev header to simulate authenticated admin:
# x-dev-user: {"id":"3","username":"AdminUser","role":"admin"}
1 change: 1 addition & 0 deletions backend/.env.test
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
TEST_MONGODB_URI=mongodb://localhost:27017/jest-test-db
144 changes: 144 additions & 0 deletions backend/src/controllers/adminController.ts
Original file line number Diff line number Diff line change
@@ -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' });
}
};
10 changes: 10 additions & 0 deletions backend/src/controllers/auditController.js
Original file line number Diff line number Diff line change
@@ -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);
};
25 changes: 25 additions & 0 deletions backend/src/middleware/auditLogger.js
Original file line number Diff line number Diff line change
@@ -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;
36 changes: 36 additions & 0 deletions backend/src/middleware/auth.ts
Original file line number Diff line number Diff line change
@@ -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();
};
16 changes: 16 additions & 0 deletions backend/src/models/AuditLog.js
Original file line number Diff line number Diff line change
@@ -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);
24 changes: 24 additions & 0 deletions backend/src/models/AuditLog.ts
Original file line number Diff line number Diff line change
@@ -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<IAuditLog>({
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<IAuditLog>('AuditLog', AuditLogSchema);
17 changes: 17 additions & 0 deletions backend/src/models/Settings.ts
Original file line number Diff line number Diff line change
@@ -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<ISettings>({
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<ISettings>('Settings', SettingsSchema);
25 changes: 25 additions & 0 deletions backend/src/models/User.ts
Original file line number Diff line number Diff line change
@@ -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<IUser>({
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<IUser>('User', UserSchema);
31 changes: 31 additions & 0 deletions backend/src/routes/admin.ts
Original file line number Diff line number Diff line change
@@ -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;
10 changes: 10 additions & 0 deletions backend/src/routes/auditRoutes.js
Original file line number Diff line number Diff line change
@@ -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;
11 changes: 11 additions & 0 deletions backend/src/routes/wikimediaOAuth1Routes.js
Original file line number Diff line number Diff line change
@@ -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;
5 changes: 5 additions & 0 deletions backend/src/server.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import auditRoutes from './routes/auditRoutes.js';


import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
Expand Down Expand Up @@ -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);
Expand Down
Loading