From defbe7278e917c4acc50b22b55a9cc4b326cf08a Mon Sep 17 00:00:00 2001 From: Abanga39 Date: Fri, 19 Dec 2025 14:29:11 +0000 Subject: [PATCH 1/4] Complete hackathon task: Bulk user management, Settings, and Audit logs --- backend/.env.example | 9 +- backend/.env.test | 1 + backend/package-lock.json | 175 +++++++++ backend/src/controllers/adminController.ts | 144 ++++++++ backend/src/controllers/auditController.js | 10 + backend/src/middleware/auditLogger.js | 25 ++ backend/src/middleware/auth.ts | 36 ++ backend/src/models/AuditLog.js | 16 + backend/src/models/AuditLog.ts | 24 ++ backend/src/models/Settings.ts | 17 + backend/src/models/User.ts | 25 ++ backend/src/routes/admin.ts | 31 ++ backend/src/routes/auditRoutes.js | 10 + backend/src/routes/wikimediaOAuth1Routes.js | 11 + backend/src/server.js | 5 + backend/src/server.ts | 35 ++ backend/src/utils/audit.ts | 24 ++ frontend/src/App.tsx | 61 +++- frontend/src/components/Navigation.tsx | 140 ++++--- frontend/src/lib/api.ts | 386 ++++++-------------- frontend/src/pages/AdminAuditLogs.tsx | 155 ++++++++ frontend/src/pages/AdminDashboard.tsx | 179 ++++----- frontend/src/pages/BulkUserManagement.tsx | 247 +++++++++++++ frontend/src/pages/SettingsPage.tsx | 149 ++++++++ frontend/src/pages/index.ts | 3 + frontend/src/services/auditService.js | 5 + 26 files changed, 1486 insertions(+), 437 deletions(-) create mode 100644 backend/.env.test create mode 100644 backend/src/controllers/adminController.ts create mode 100644 backend/src/controllers/auditController.js create mode 100644 backend/src/middleware/auditLogger.js create mode 100644 backend/src/middleware/auth.ts create mode 100644 backend/src/models/AuditLog.js create mode 100644 backend/src/models/AuditLog.ts create mode 100644 backend/src/models/Settings.ts create mode 100644 backend/src/models/User.ts create mode 100644 backend/src/routes/admin.ts create mode 100644 backend/src/routes/auditRoutes.js create mode 100644 backend/src/routes/wikimediaOAuth1Routes.js create mode 100644 backend/src/server.ts create mode 100644 backend/src/utils/audit.ts create mode 100644 frontend/src/pages/AdminAuditLogs.tsx create mode 100644 frontend/src/pages/BulkUserManagement.tsx create mode 100644 frontend/src/pages/SettingsPage.tsx create mode 100644 frontend/src/services/auditService.js 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/package-lock.json b/backend/package-lock.json index 034e9e7..9f96f67 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -63,6 +63,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1480,6 +1481,28 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -1540,6 +1563,19 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, "node_modules/@sinclair/typebox": { "version": "0.34.41", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", @@ -1806,6 +1842,13 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, "node_modules/async-mutex": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", @@ -1823,6 +1866,21 @@ "dev": true, "license": "MIT" }, + "node_modules/b4a": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", + "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -2086,6 +2144,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2329,6 +2388,21 @@ "dev": true, "license": "MIT" }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -2347,6 +2421,26 @@ "dev": true, "license": "MIT" }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -2831,6 +2925,16 @@ "node": ">= 0.4" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -6399,6 +6503,13 @@ "dev": true, "license": "MIT" }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/js-yaml": { "version": "3.14.2", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", @@ -6413,6 +6524,39 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/jsonwebtoken": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", @@ -7125,6 +7269,19 @@ "node": ">=0.10.0" } }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -8414,6 +8571,24 @@ "node": ">= 8" } }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", 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 4ef5e92..34dc467 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'; @@ -117,6 +120,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/src/App.tsx b/frontend/src/App.tsx index 234cb4c..cf30f46 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,14 +1,17 @@ -import { useEffect } from 'react'; -import { Routes, Route, Link } from 'react-router-dom'; -import { AuthProvider } from './lib/auth-context'; +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 { LandingPage, AuthPage, + AdminAuditLogs, SubmissionForm, AdminDashboard, PublicDirectory, - UserProfile + BulkUserManagement, + UserProfile, + SettingsPage, } from './pages'; import { Toaster } from './components/ui/sonner'; import { TooltipProvider } from './components/ui/tooltip'; @@ -26,6 +29,13 @@ function NotFound() { ); } +function AdminRoute({ children }: { children: JSX.Element }) { + const { user } = useAuth(); + if (!user) return ; + if (user.role !== 'admin') return ; + return children; +} + function AppContent() { useEffect(() => { initializeData(); @@ -41,9 +51,43 @@ function AppContent() { } /> } /> } /> - } /> } /> } /> + + {/* Admin routes protected */} + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + } /> @@ -101,12 +145,9 @@ function AppContent() {
-

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

+

Β© 2025 WikiSourceVerifier. Built for the Wikipedia community.

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

diff --git a/frontend/src/components/Navigation.tsx b/frontend/src/components/Navigation.tsx index d62d68c..9db99ef 100644 --- a/frontend/src/components/Navigation.tsx +++ b/frontend/src/components/Navigation.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import { Link, useLocation, useNavigate } from 'react-router-dom'; import { BookOpen, Search, Upload, User, LogOut, Award, Shield, LogIn } from 'lucide-react'; import { Button } from './ui/button'; @@ -6,25 +7,23 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuTrigger, DropdownMenuLabel, DropdownMenuSeparator, - DropdownMenuTrigger, } from './ui/dropdown-menu'; import { Badge } from './ui/badge'; - const countries = [ { name: 'Ghana', slug: 'ghana' }, { name: 'Nigeria', slug: 'nigeria' }, { name: 'Kenya', slug: 'kenya' }, ]; - export const Navigation: React.FC = () => { const { user, logout } = useAuth(); const location = useLocation(); const navigate = useNavigate(); - + const isActive = (path: string) => location.pathname === path; return ( @@ -44,25 +43,27 @@ export const Navigation: React.FC = () => {
- - - - + + + + - - {countries.map((country) => ( - navigate(`/country/${country.slug}`)} - > - {country.name} - - ))} - - + + Browse by country + + {countries.map((country) => ( + navigate(`/country/${country.slug}`)} + > + {country.name} + + ))} + + )} + + {user.role === 'admin' && ( + <> + + + + + )} )}
@@ -101,39 +124,45 @@ export const Navigation: React.FC = () => {
{user ? ( - - - - - - -
- {user.username} - {user.email} -
-
- - navigate('/profile')}> - - Profile - - navigate('/profile')}> - - Badges ({user.badges.length}) - - - - - Logout - -
-
+
+ + + + + + +
+ {user.username} + {user.email} +
+
+ + navigate('/profile')}> + + Profile + + navigate('/profile')}> + + Badges ({(user.badges && user.badges.length) || 0}) + + + { + logout(); + }} + > + + Logout + +
+
+
) : (
- {/* Mobile Navigation */}
+
+ +
+ + +
+ + + {loading ? ( +
Loading...
+ ) : filtered.length === 0 ? ( +
No audit entries found.
+ ) : ( +
+ + + + + + + + + + + + + {filtered.map((log) => ( + + + + + + + + + ))} + +
TimeUserActionResourceMethodDetails
{new Date(log.createdAt).toLocaleString()}{log.user?.username ?? 'System'}{log.action}{log.resource ?? '-'}{log.method ?? '-'} + {typeof log.details === 'string' ? log.details : JSON.stringify(log.details ?? {}, null, 0)} +
+
+ )} + + + + ); +}; + +export default AdminAuditLogs; diff --git a/frontend/src/pages/AdminDashboard.tsx b/frontend/src/pages/AdminDashboard.tsx index 658918b..ed84db2 100644 --- a/frontend/src/pages/AdminDashboard.tsx +++ b/frontend/src/pages/AdminDashboard.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import React, { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { Button } from '../components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card'; @@ -30,6 +30,7 @@ import { } from '../lib/mock-data'; import { submissionApi } from '../lib/api'; import { toast } from 'sonner'; +import { CheckCircle, XCircle, Eye, Clock, TrendingUp, Users, FileCheck } from 'lucide-react'; interface Submission { id: string; @@ -39,15 +40,18 @@ interface Submission { country: string; category: string; status: string; - submitter?: any; + submitterName?: string; verifier?: any; wikipediaArticle?: string; verifierNotes?: string; verifiedAt?: string; + verifiedDate?: string; + submittedDate?: string; + mediaType?: string; + reliability?: 'credible' | 'unreliable' | string; createdAt: string; updatedAt: string; } -import { CheckCircle, XCircle, Eye, Clock, TrendingUp, Users, FileCheck } from 'lucide-react'; export const AdminDashboard: React.FC = () => { const navigate = useNavigate(); @@ -62,30 +66,35 @@ export const AdminDashboard: React.FC = () => { useEffect(() => { loadSubmissions(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [user]); const loadSubmissions = async () => { if (!user) return; - setLoading(true); try { - // Load pending submissions for verifier's country if (user.role === 'verifier' || user.role === 'admin') { const response = await submissionApi.getPendingForCountry(); if (response.success) { - setSubmissions(response.submissions); + setSubmissions(response.submissions || []); + } else { + setSubmissions([]); } } } catch (error) { toast.error('Failed to load submissions'); + setSubmissions([]); } finally { setLoading(false); } }; - const handleVerify = async (submission: Submission, status: 'approved' | 'rejected', credibility?: 'credible' | 'unreliable') => { + const handleVerify = async ( + submission: Submission, + status: 'approved' | 'rejected', + credibility?: 'credible' | 'unreliable' + ) => { if (!user) return; - setLoading(true); try { const response = await submissionApi.verify( @@ -96,17 +105,22 @@ export const AdminDashboard: React.FC = () => { ); if (response.success) { - toast.success( - `Reference ${status === 'approved' ? 'βœ… Approved' : '❌ Rejected'} (+5 points)` - ); - - // Update user points locally - if (user) { - user.points += 5; + if (status === 'approved') { + toast.success('Reference approved (+5 points)'); + // award points to current user locally (avoid mutating context directly) + // best-effort local update; real backend will persist + try { + // some auth contexts expose a setter; if not, this is only local visual + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const anyUser: any = user as any; + if (typeof anyUser.points === 'number') anyUser.points = anyUser.points + 5; + } catch {} + } else { + toast.success('Reference rejected'); } - - // Reload submissions await loadSubmissions(); + } else { + toast.error('Verification failed (server)'); } } catch (error) { toast.error('Failed to verify submission'); @@ -119,7 +133,7 @@ export const AdminDashboard: React.FC = () => { }; const handleReject = async (submission: Submission) => { - await handleVerify(submission, 'rejected'); + await handleVerify(submission, 'rejected', 'unreliable'); }; const openVerificationDialog = (submission: Submission) => { @@ -128,12 +142,10 @@ export const AdminDashboard: React.FC = () => { setShowDialog(true); }; - const getPendingSubmissions = () => { - return submissions.filter((s) => s.status === 'pending'); - }; + const getPendingSubmissions = () => submissions.filter((s) => s.status === 'pending'); const getVerifiedSubmissions = () => { - let filtered = submissions.filter((s) => s.status === 'verified'); + let filtered = submissions.filter((s) => s.status === 'verified' || s.status === 'approved'); if (filterCategory !== 'all') { filtered = filtered.filter((s) => s.category === filterCategory); @@ -141,12 +153,12 @@ export const AdminDashboard: React.FC = () => { if (filterDate === 'today') { const today = new Date().toISOString().split('T')[0]; - filtered = filtered.filter((s: any) => s.verifiedDate === today); + filtered = filtered.filter((s) => s.verifiedDate && s.verifiedDate.startsWith(today)); } else if (filterDate === 'week') { const weekAgo = new Date(); weekAgo.setDate(weekAgo.getDate() - 7); const weekAgoStr = weekAgo.toISOString().split('T')[0]; - filtered = filtered.filter((s: any) => s.verifiedDate && s.verifiedDate >= weekAgoStr); + filtered = filtered.filter((s) => s.verifiedDate && s.verifiedDate >= weekAgoStr); } return filtered; @@ -155,9 +167,8 @@ export const AdminDashboard: React.FC = () => { const getStats = () => { const total = submissions.length; const pending = submissions.filter((s) => s.status === 'pending').length; - const verified = submissions.filter((s) => s.status === 'verified').length; - const credible = submissions.filter((s: any) => s.reliability === 'credible').length; - + const verified = submissions.filter((s) => s.status === 'verified' || s.status === 'approved').length; + const credible = submissions.filter((s) => s.reliability === 'credible').length; return { total, pending, verified, credible }; }; @@ -185,14 +196,27 @@ export const AdminDashboard: React.FC = () => { return (
-
-

Verification Dashboard

-

- Review and verify reference submissions from the community -

+
+
+

Verification Dashboard

+

Review and verify reference submissions from the community

+
+ + {user.role === 'admin' && ( +
+ + + +
+ )}
- {/* Stats */}
@@ -243,12 +267,9 @@ export const AdminDashboard: React.FC = () => {
- {/* Tabs */} - - Pending ({pendingSubmissions.length}) - + Pending ({pendingSubmissions.length}) Verified ({verifiedSubmissions.length}) @@ -263,7 +284,7 @@ export const AdminDashboard: React.FC = () => { ) : (
- {pendingSubmissions.map((submission: any) => ( + {pendingSubmissions.map((submission) => (
@@ -284,44 +305,27 @@ export const AdminDashboard: React.FC = () => { {submission.mediaType === 'pdf' ? 'πŸ“„ PDF' : 'πŸ”— URL'}
- + {submission.url} {submission.wikipediaArticle && ( - + πŸ“– Wikipedia Article )}

- Submitted by {submission.submitterName} on {submission.submittedDate} + Submitted by {submission.submitterName ?? 'Unknown'} on {submission.submittedDate ?? 'β€”'}

- - @@ -361,7 +365,7 @@ export const AdminDashboard: React.FC = () => {
- {verifiedSubmissions.map((submission: any) => ( + {verifiedSubmissions.map((submission) => (
@@ -387,24 +391,15 @@ export const AdminDashboard: React.FC = () => { {getCountryFlag(submission.country)} {getCountryName(submission.country)}
- + {submission.url} {submission.verifierNotes && (
-

- Verification Notes: {submission.verifierNotes} -

+

Verification Notes: {submission.verifierNotes}

)} -

- Verified on {submission.verifiedDate} -

+

Verified on {submission.verifiedDate ?? 'β€”'}

@@ -414,14 +409,11 @@ export const AdminDashboard: React.FC = () => { - {/* Verification Dialog */} Verify Reference - - Review this submission and mark it as credible or unreliable - + Review this submission and mark it as credible or unreliable {selectedSubmission && ( @@ -434,16 +426,10 @@ export const AdminDashboard: React.FC = () => { {getCategoryIcon(selectedSubmission.category)} {selectedSubmission.category} - {getCountryFlag(selectedSubmission.country)}{' '} - {getCountryName(selectedSubmission.country)} + {getCountryFlag(selectedSubmission.country)} {getCountryName(selectedSubmission.country)} - + {selectedSubmission.url} @@ -461,26 +447,15 @@ export const AdminDashboard: React.FC = () => { )} - - - diff --git a/frontend/src/pages/BulkUserManagement.tsx b/frontend/src/pages/BulkUserManagement.tsx new file mode 100644 index 0000000..1d616c3 --- /dev/null +++ b/frontend/src/pages/BulkUserManagement.tsx @@ -0,0 +1,247 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/card'; +import { Button } from '../components/ui/button'; +import { Input } from '../components/ui/input'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogDescription, +} from '../components/ui/dialog'; +import { toast } from 'sonner'; +import { useAuth } from '../lib/auth-context'; +import { User as MockUser, mockUsers } from '../lib/mock-data'; + +type User = MockUser; + +export const BulkUserManagement: React.FC = () => { + const { user: currentUser } = useAuth(); + const navigate = useNavigate(); + + useEffect(() => { + if (currentUser === undefined) return; + if (!currentUser) { + navigate('/auth'); + return; + } + if (currentUser.role !== 'admin') { + toast.error('Access denied'); + navigate('/'); + } + }, [currentUser, navigate]); + + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(''); + const [editingUser, setEditingUser] = useState(null); + const [editDialogOpen, setEditDialogOpen] = useState(false); + + useEffect(() => { + const load = async () => { + setLoading(true); + try { + const res = await fetch('/api/admin/users'); + if (res.ok) { + const data = await res.json(); + setUsers(Array.isArray(data) ? data : data.users ?? data.data ?? []); + } else { + setUsers(mockUsers); + } + } catch { + setUsers(mockUsers); + } finally { + setLoading(false); + } + }; + load(); + }, []); + + const filtered = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return users; + return users.filter( + (u) => + u.username.toLowerCase().includes(q) || + u.email.toLowerCase().includes(q) || + (u.role && u.role.toLowerCase().includes(q)) || + (u.country && u.country.toLowerCase().includes(q)), + ); + }, [users, search]); + + const handleAddUser = async () => { + const newUser: User = { + id: Date.now().toString(), + username: `newuser${users.length + 1}`, + email: `user${users.length + 1}@example.com`, + country: 'US', + role: 'contributor', + points: 0, + badges: [], + joinDate: new Date().toISOString().split('T')[0], + }; + + try { + const res = await fetch('/api/admin/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(newUser), + }); + if (res.ok) { + const created: User = await res.json(); + setUsers((s) => [...s, created]); + toast.success('User added'); + return; + } + } catch { + // fallback to local only + } + + setUsers((s) => [...s, newUser]); + toast.success('User added (local)'); + }; + + const openEditDialog = (u: User) => { + setEditingUser(u); + setEditDialogOpen(true); + }; + + const handleSaveEdit = async () => { + if (!editingUser) return; + + try { + const res = await fetch(`/api/admin/users/${editingUser.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(editingUser), + }); + if (res.ok) { + const saved: User = await res.json(); + setUsers((s) => s.map((x) => (x.id === saved.id ? saved : x))); + toast.success('User updated'); + setEditDialogOpen(false); + setEditingUser(null); + return; + } + } catch { + // fallback to local update + } + + setUsers((s) => s.map((x) => (x.id === editingUser.id ? editingUser : x))); + toast.success('User updated (local)'); + setEditDialogOpen(false); + setEditingUser(null); + }; + + const handleDeleteUser = async (id: string) => { + if (!confirm('Delete user?')) return; + try { + const res = await fetch(`/api/admin/users/${id}`, { method: 'DELETE' }); + if (res.ok) { + setUsers((s) => s.filter((u) => u.id !== id)); + toast.success('User deleted'); + return; + } + } catch { + // fallback to local removal + } + + setUsers((s) => s.filter((u) => u.id !== id)); + toast.success('User deleted (local)'); + }; + + return ( +
+
+

Bulk User Management

+
+ ) => setSearch(e.target.value)} + className="mr-2" + /> + +
+
+ +
+ {loading ? ( +
Loading...
+ ) : filtered.length === 0 ? ( +
No users found.
+ ) : ( + filtered.map((u) => ( + + + {u.username} + + +
+

Email: {u.email}

+

Role: {u.role}

+

Points: {u.points}

+

Country: {u.country}

+
+
+ + +
+
+
+ )) + )} + + + + + Edit User + Update user information + + + {editingUser && ( +
+ setEditingUser({ ...editingUser, username: e.target.value })} + placeholder="Username" + /> + setEditingUser({ ...editingUser, email: e.target.value })} + placeholder="Email" + /> + setEditingUser({ ...editingUser, country: e.target.value })} + placeholder="Country" + /> + +
+ )} + + + + +
+
+
+
+ ); +}; diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx new file mode 100644 index 0000000..5329f02 --- /dev/null +++ b/frontend/src/pages/SettingsPage.tsx @@ -0,0 +1,149 @@ +import React, { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../lib/auth-context'; +import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/card'; +import { Input } from '../components/ui/input'; +import { Button } from '../components/ui/button'; +import { toast } from 'sonner'; + +type Settings = { + siteName: string; + verificationPoints: number; + maxSubmissionsPerDay: number; +}; + +const DEFAULT_SETTINGS: Settings = { + siteName: 'WikiSourceVerifier', + verificationPoints: 10, + maxSubmissionsPerDay: 5, +}; + +export const SettingsPage: React.FC = () => { + const { user } = useAuth(); + const navigate = useNavigate(); + + const [settings, setSettings] = useState(DEFAULT_SETTINGS); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (user === undefined) return; + if (!user) { + navigate('/auth'); + return; + } + if (user.role !== 'admin') { + toast.error('Access denied'); + navigate('/'); + return; + } + }, [user, navigate]); + + useEffect(() => { + const load = async () => { + setLoading(true); + try { + const res = await fetch('/api/admin/settings'); + if (res.ok) { + const data = await res.json(); + setSettings({ + siteName: data.siteName ?? DEFAULT_SETTINGS.siteName, + verificationPoints: Number(data.verificationPoints ?? DEFAULT_SETTINGS.verificationPoints), + maxSubmissionsPerDay: Number(data.maxSubmissionsPerDay ?? DEFAULT_SETTINGS.maxSubmissionsPerDay), + }); + } else { + setSettings(DEFAULT_SETTINGS); + } + } catch { + setSettings(DEFAULT_SETTINGS); + } finally { + setLoading(false); + } + }; + load(); + }, []); + + const handleSave = async () => { + setSaving(true); + try { + const res = await fetch('/api/admin/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(settings), + }); + if (res.ok) { + const saved = await res.json(); + setSettings({ + siteName: saved.siteName ?? settings.siteName, + verificationPoints: Number(saved.verificationPoints ?? settings.verificationPoints), + maxSubmissionsPerDay: Number(saved.maxSubmissionsPerDay ?? settings.maxSubmissionsPerDay), + }); + toast.success('Settings saved'); + } else { + toast.error('Failed to save settings (server)'); + } + } catch { + toast.error('Failed to save settings'); + } finally { + setSaving(false); + } + }; + + return ( +
+

System Settings

+ + + + General + + + {loading ? ( +
Loading...
+ ) : ( +
+
+ + setSettings({ ...settings, siteName: e.target.value })} + placeholder="Site name" + /> +
+ +
+ + + setSettings({ ...settings, verificationPoints: Number(e.target.value || 0) }) + } + /> +
+ +
+ + + setSettings({ ...settings, maxSubmissionsPerDay: Number(e.target.value || 0) }) + } + /> +
+ +
+ +
+
+ )} +
+
+
+ ); +}; + +export default SettingsPage; \ No newline at end of file diff --git a/frontend/src/pages/index.ts b/frontend/src/pages/index.ts index 5d568f4..50cc502 100644 --- a/frontend/src/pages/index.ts +++ b/frontend/src/pages/index.ts @@ -4,3 +4,6 @@ export { SubmissionForm } from './SubmissionForm'; export { AdminDashboard } from './AdminDashboard'; export { PublicDirectory } from './PublicDirectory'; export { UserProfile } from './UserProfile'; +export { AdminAuditLogs } from './AdminAuditLogs'; +export { BulkUserManagement } from './BulkUserManagement'; +export { SettingsPage } from './SettingsPage'; diff --git a/frontend/src/services/auditService.js b/frontend/src/services/auditService.js new file mode 100644 index 0000000..43b4222 --- /dev/null +++ b/frontend/src/services/auditService.js @@ -0,0 +1,5 @@ +import axios from "axios"; + +export const fetchAuditLogs = () => { + return axios.get("/api/admin/audit-logs"); +}; From 0c900e27645dbe6867337b70f97dad9dd7b97deb Mon Sep 17 00:00:00 2001 From: Abanga39 Date: Sat, 20 Dec 2025 18:29:55 +0000 Subject: [PATCH 2/4] Fixed merge conflict in pages index --- frontend/package.json | 5 +- frontend/src/components/Navigation.tsx | 422 +++++++------------------ frontend/src/pages/index.ts | 11 +- 3 files changed, 128 insertions(+), 310 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 849f89e..d32ab70 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -51,9 +51,8 @@ "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", - "vaul": "^1.1.2" + "workbox-window": "^7.4.0", + "tailwindcss-animate": "^1.0.7" }, "devDependencies": { "@tailwindcss/postcss": "^4.1.18", diff --git a/frontend/src/components/Navigation.tsx b/frontend/src/components/Navigation.tsx index be4f0d4..ebc739c 100644 --- a/frontend/src/components/Navigation.tsx +++ b/frontend/src/components/Navigation.tsx @@ -1,25 +1,21 @@ -<<<<<<< HEAD -import React from 'react'; +import React, { useState } from 'react'; import { Link, useLocation, useNavigate } from 'react-router-dom'; -import { BookOpen, Search, Upload, User, LogOut, Award, Shield, LogIn } from 'lucide-react'; +import { + BookOpen, + Search, + Upload, + User, + LogOut, + Award, + Shield, + LogIn, + Menu, + Settings, + Users, + FileText +} from 'lucide-react'; import { Button } from './ui/button'; import { useAuth } from '../lib/auth-context'; -======= -import { Link, useLocation, useNavigate } from "react-router-dom"; -import { - BookOpen, - Search, - Upload, - User, - LogOut, - Award, - Shield, - LogIn, -} from "lucide-react"; -import { Button } from "./ui/button"; -import { useState } from "react"; -import { useAuth } from "../lib/auth-context"; ->>>>>>> 819bfefc688f7d0e0bb9c6fc9c9dd49bf37fe532 import { DropdownMenu, DropdownMenuContent, @@ -27,331 +23,155 @@ import { DropdownMenuTrigger, DropdownMenuLabel, DropdownMenuSeparator, -<<<<<<< HEAD } from './ui/dropdown-menu'; import { Badge } from './ui/badge'; -======= - DropdownMenuTrigger, -} from "./ui/dropdown-menu"; -import { Badge } from "./ui/badge"; -import { Menu } from "lucide-react"; -import { Sheet, SheetContent, SheetTrigger } from "./ui/sheet"; ->>>>>>> 819bfefc688f7d0e0bb9c6fc9c9dd49bf37fe532 +import { Sheet, SheetContent, SheetTrigger } from './ui/sheet'; -const countries = [ - { name: 'Ghana', slug: 'ghana' }, - { name: 'Nigeria', slug: 'nigeria' }, - { name: 'Kenya', slug: 'kenya' }, -]; - -export const Navigation: React.FC = () => { +export function Navigation() { const { user, logout } = useAuth(); const location = useLocation(); const navigate = useNavigate(); -<<<<<<< HEAD -======= - const [open, setOpen] = useState(false); ->>>>>>> 819bfefc688f7d0e0bb9c6fc9c9dd49bf37fe532 + const [isOpen, setIsOpen] = useState(false); + + const handleLogout = () => { + logout(); + navigate('/'); + }; const isActive = (path: string) => location.pathname === path; return ( -