From 52fe7e05c5209597821c7863c6b21d460414c5a3 Mon Sep 17 00:00:00 2001 From: "Mahmoud M. Abdalla" Date: Sun, 27 Jul 2025 23:30:23 +0300 Subject: [PATCH] Release v1.0.0 - Complete Course Management System Major Features: - Authentication & Authorization with JWT and role-based access - Complete User Management System with CRUD operations - Course Management System with publishing and enrollment - Modern React UI with Tailwind CSS and responsive design - Internationalization (i18n) with English and Arabic support - File Upload System for images and documents - RESTful API with Express.js and Sequelize ORM - PostgreSQL database with proper relationships - Super Admin password change functionality - CSV import for bulk user creation - Modal-based user add/edit operations - Search, filter, and pagination capabilities Technical Improvements: - Fixed homepage routing and accessibility issues - Resolved API endpoint mismatches and data rendering - Enhanced security with proper validation and hashing - Optimized performance with React Query and caching - Improved error handling and user feedback - Clean code structure with ESLint compliance Bug Fixes: - Fixed non-functional Add/Edit/Delete buttons - Resolved CSV import BOM issues - Fixed modal rendering and accessibility - Corrected API base URL configuration - Enhanced backend stability and error handling This version represents a complete, production-ready Course Management System. --- backend/config/database.js | 2 +- backend/models/User.js | 4 + backend/package-lock.json | 13 + backend/package.json | 21 +- backend/routes/auth.js | 60 ++- backend/routes/courses.js | 60 ++- backend/routes/users.js | 216 +++++++- frontend/package-lock.json | 157 +++--- frontend/package.json | 10 +- frontend/public/courseworx-logo.png | 1 + frontend/public/images/cx-logo.png | Bin 0 -> 1537 bytes frontend/public/manifest.json | 18 +- frontend/src/App.js | 45 +- frontend/src/components/Layout.js | 17 +- .../src/components/PasswordChangeModal.js | 161 ++++++ frontend/src/contexts/AuthContext.js | 8 + frontend/src/i18n.js | 43 ++ frontend/src/index.css | 2 +- frontend/src/index.js | 3 + frontend/src/pages/CourseCreate.js | 392 +++++++++++++++ frontend/src/pages/CourseDetail.js | 80 +-- frontend/src/pages/CourseEdit.js | 460 ++++++++++++++++++ frontend/src/pages/Courses.js | 128 ++--- frontend/src/pages/Dashboard.js | 49 +- frontend/src/pages/Home.js | 358 ++++++++++++++ frontend/src/pages/UserImport.js | 281 +++++++++++ frontend/src/pages/Users.js | 240 ++++++++- frontend/src/services/api.js | 35 +- package-lock.json | 114 +++++ package.json | 12 +- version.txt | 245 ++++++++++ 31 files changed, 2991 insertions(+), 244 deletions(-) create mode 100644 frontend/public/courseworx-logo.png create mode 100644 frontend/public/images/cx-logo.png create mode 100644 frontend/src/components/PasswordChangeModal.js create mode 100644 frontend/src/i18n.js create mode 100644 frontend/src/pages/CourseCreate.js create mode 100644 frontend/src/pages/CourseEdit.js create mode 100644 frontend/src/pages/Home.js create mode 100644 frontend/src/pages/UserImport.js create mode 100644 version.txt diff --git a/backend/config/database.js b/backend/config/database.js index bba1b0c..7d446dd 100644 --- a/backend/config/database.js +++ b/backend/config/database.js @@ -9,7 +9,7 @@ const sequelize = new Sequelize( host: process.env.DB_HOST || 'localhost', port: process.env.DB_PORT || 5432, dialect: 'postgres', - logging: process.env.NODE_ENV === 'development' ? console.log : false, + logging: false, // Disable SQL logging in terminal pool: { max: 5, min: 0, diff --git a/backend/models/User.js b/backend/models/User.js index 962f2e6..cfaf4ca 100644 --- a/backend/models/User.js +++ b/backend/models/User.js @@ -59,6 +59,10 @@ const User = sequelize.define('User', { lastLogin: { type: DataTypes.DATE, allowNull: true + }, + requiresPasswordChange: { + type: DataTypes.BOOLEAN, + defaultValue: false } }, { tableName: 'users', diff --git a/backend/package-lock.json b/backend/package-lock.json index a45ac0b..cdbe62c 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "bcryptjs": "^2.4.3", "cors": "^2.8.5", + "csv-parser": "^3.2.0", "dotenv": "^16.3.1", "express": "^4.18.2", "express-validator": "^7.0.1", @@ -331,6 +332,18 @@ "node": ">= 0.10" } }, + "node_modules/csv-parser": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/csv-parser/-/csv-parser-3.2.0.tgz", + "integrity": "sha512-fgKbp+AJbn1h2dcAHKIdKNSSjfp43BZZykXsCjzALjKy80VXQNHPFJ6T9Afwdzoj24aMkq8GwDS7KGcDPpejrA==", + "license": "MIT", + "bin": { + "csv-parser": "bin/csv-parser" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", diff --git a/backend/package.json b/backend/package.json index 3cc0ae8..feead92 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,20 +9,21 @@ "setup-db": "node scripts/setup-database.js" }, "dependencies": { - "express": "^4.18.2", - "cors": "^2.8.5", - "helmet": "^7.1.0", - "dotenv": "^16.3.1", "bcryptjs": "^2.4.3", - "jsonwebtoken": "^9.0.2", - "pg": "^8.11.3", - "sequelize": "^6.35.1", - "pg-hstore": "^2.3.4", + "cors": "^2.8.5", + "csv-parser": "^3.2.0", + "dotenv": "^16.3.1", + "express": "^4.18.2", "express-validator": "^7.0.1", + "helmet": "^7.1.0", + "jsonwebtoken": "^9.0.2", + "moment": "^2.29.4", "multer": "^1.4.5-lts.1", - "moment": "^2.29.4" + "pg": "^8.11.3", + "pg-hstore": "^2.3.4", + "sequelize": "^6.35.1" }, "devDependencies": { "nodemon": "^3.0.2" } -} \ No newline at end of file +} diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 723a0e3..a09914f 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -30,11 +30,15 @@ router.post('/login', [ const { email, password } = req.body; const user = await User.findOne({ where: { email } }); + console.log('Login attempt for email:', email, 'User found:', !!user, 'User active:', user?.isActive); + if (!user || !user.isActive) { return res.status(401).json({ error: 'Invalid credentials or account inactive.' }); } const isPasswordValid = await user.comparePassword(password); + console.log('Password validation result:', isPasswordValid); + if (!isPasswordValid) { return res.status(401).json({ error: 'Invalid credentials.' }); } @@ -53,7 +57,8 @@ router.post('/login', [ email: user.email, role: user.role, avatar: user.avatar, - phone: user.phone + phone: user.phone, + requiresPasswordChange: user.requiresPasswordChange } }); } catch (error) { @@ -72,7 +77,7 @@ router.post('/register', [ body('lastName').isLength({ min: 2, max: 50 }), body('email').isEmail().normalizeEmail(), body('password').isLength({ min: 6 }), - body('role').isIn(['trainer', 'trainee']) + body('role').isIn(['super_admin', 'trainer', 'trainee']) ], async (req, res) => { try { const errors = validationResult(req); @@ -97,6 +102,13 @@ router.post('/register', [ phone }); + console.log('User created successfully:', { + id: user.id, + email: user.email, + role: user.role, + isActive: user.isActive + }); + res.status(201).json({ message: 'User created successfully.', user: { @@ -209,4 +221,48 @@ router.put('/change-password', [ } }); +// @route PUT /api/auth/first-password-change +// @desc Change password on first login (for imported users) +// @access Private +router.put('/first-password-change', [ + auth, + body('newPassword').isLength({ min: 6 }) +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { newPassword } = req.body; + + // Check if user requires password change + if (!req.user.requiresPasswordChange) { + return res.status(400).json({ error: 'Password change not required.' }); + } + + await req.user.update({ + password: newPassword, + requiresPasswordChange: false + }); + + res.json({ + message: 'Password changed successfully.', + user: { + id: req.user.id, + firstName: req.user.firstName, + lastName: req.user.lastName, + email: req.user.email, + role: req.user.role, + avatar: req.user.avatar, + phone: req.user.phone, + requiresPasswordChange: false + } + }); + } catch (error) { + console.error('First password change error:', error); + res.status(500).json({ error: 'Server error.' }); + } +}); + module.exports = router; \ No newline at end of file diff --git a/backend/routes/courses.js b/backend/routes/courses.js index dcbb681..8d456a3 100644 --- a/backend/routes/courses.js +++ b/backend/routes/courses.js @@ -2,9 +2,37 @@ const express = require('express'); const { body, validationResult } = require('express-validator'); const { Course, User, Enrollment } = require('../models'); const { auth, requireSuperAdmin, requireTrainer } = require('../middleware/auth'); +const multer = require('multer'); +const path = require('path'); +const fs = require('fs'); const router = express.Router(); +// Multer storage for course images +const courseImageStorage = multer.diskStorage({ + destination: function (req, file, cb) { + const courseName = req.body.courseName || req.params.courseName; + const dir = path.join(__dirname, '../uploads/courses', courseName); + fs.mkdirSync(dir, { recursive: true }); + cb(null, dir); + }, + filename: function (req, file, cb) { + cb(null, file.fieldname + '-' + Date.now() + path.extname(file.originalname)); + } +}); + +const uploadCourseImage = multer({ + storage: courseImageStorage, + fileFilter: (req, file, cb) => { + if (file.mimetype.startsWith('image/')) { + cb(null, true); + } else { + cb(new Error('Only image files are allowed'), false); + } + }, + limits: { fileSize: 5 * 1024 * 1024 } +}); + // @route GET /api/courses // @desc Get all courses (with filters) // @access Public @@ -14,7 +42,7 @@ router.get('/', async (req, res) => { category, level, trainerId, - isPublished = true, + isPublished, page = 1, limit = 12, search, @@ -28,7 +56,15 @@ router.get('/', async (req, res) => { if (category) whereClause.category = category; if (level) whereClause.level = level; if (trainerId) whereClause.trainerId = trainerId; - if (isPublished !== undefined) whereClause.isPublished = isPublished === 'true'; + // Only apply isPublished filter if it's provided in the query + if (isPublished !== undefined) { + whereClause.isPublished = isPublished === 'true'; + } else { + // For non-authenticated users, only show published courses + if (!req.user || req.user.role === 'trainee') { + whereClause.isPublished = true; + } + } if (search) { whereClause[require('sequelize').Op.or] = [ @@ -156,7 +192,7 @@ router.post('/', [ startDate, endDate, trainerId: req.user.id, - isPublished: req.user.role === 'super_admin' // Auto-publish for super admin + isPublished: true // Auto-publish for all users }); const courseWithTrainer = await Course.findByPk(course.id, { @@ -292,6 +328,24 @@ router.put('/:id/publish', [ } }); +// @route POST /api/courses/:courseName/image +// @desc Upload course image (Super Admin or Trainer) +// @access Private +router.post('/:courseName/image', [auth, requireTrainer, uploadCourseImage.single('image')], async (req, res) => { + try { + if (!req.file) { + return res.status(400).json({ error: 'No image file uploaded.' }); + } + res.json({ + message: 'Image uploaded successfully.', + imageUrl: `/uploads/courses/${req.params.courseName}/${req.file.filename}` + }); + } catch (error) { + console.error('Upload course image error:', error); + res.status(500).json({ error: 'Server error.' }); + } +}); + // @route GET /api/courses/categories/all // @desc Get all course categories // @access Public diff --git a/backend/routes/users.js b/backend/routes/users.js index 1e785d2..b81a6b5 100644 --- a/backend/routes/users.js +++ b/backend/routes/users.js @@ -1,10 +1,49 @@ const express = require('express'); const { body, validationResult } = require('express-validator'); const { User } = require('../models'); -const { auth, requireSuperAdmin } = require('../middleware/auth'); +const { auth, requireSuperAdmin, requireTrainer } = require('../middleware/auth'); +const multer = require('multer'); +const path = require('path'); +const fs = require('fs'); +const csv = require('csv-parser'); +const bcrypt = require('bcryptjs'); const router = express.Router(); +// Multer storage for slider images +const sliderImageStorage = multer.diskStorage({ + destination: function (req, file, cb) { + const dir = path.join(__dirname, '../uploads/slider'); + fs.mkdirSync(dir, { recursive: true }); + cb(null, dir); + }, + filename: function (req, file, cb) { + cb(null, file.fieldname + '-' + Date.now() + path.extname(file.originalname)); + } +}); + +const uploadSliderImage = multer({ + storage: sliderImageStorage, + fileFilter: (req, file, cb) => { + if (file.mimetype.startsWith('image/')) { + cb(null, true); + } else { + cb(new Error('Only image files are allowed'), false); + } + }, + limits: { fileSize: 5 * 1024 * 1024 } +}); + +// Utility to remove BOM from a file (in-place) +function removeBOMFromFile(filePath) { + const fs = require('fs'); + const data = fs.readFileSync(filePath); + // UTF-8 BOM is 0xEF,0xBB,0xBF + if (data[0] === 0xEF && data[1] === 0xBB && data[2] === 0xBF) { + fs.writeFileSync(filePath, data.slice(3)); + } +} + // @route GET /api/users // @desc Get all users (Super Admin only) // @access Private (Super Admin) @@ -120,6 +159,37 @@ router.put('/:id', [ } }); +// @route PUT /api/users/:id/password +// @desc Change user password (Super Admin only) +// @access Private (Super Admin) +router.put('/:id/password', [ + auth, + requireSuperAdmin, + body('password').isLength({ min: 6 }) +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const user = await User.findByPk(req.params.id); + if (!user) { + return res.status(404).json({ error: 'User not found.' }); + } + + const { password } = req.body; + await user.update({ password }); + + res.json({ + message: 'User password updated successfully.' + }); + } catch (error) { + console.error('Update user password error:', error); + res.status(500).json({ error: 'Server error.' }); + } +}); + // @route DELETE /api/users/:id // @desc Delete user (Super Admin only) // @access Private (Super Admin) @@ -170,4 +240,148 @@ router.get('/stats/overview', auth, requireSuperAdmin, async (req, res) => { } }); +// Configure multer for file uploads +const upload = multer({ + dest: 'uploads/', + fileFilter: (req, file, cb) => { + if (file.mimetype === 'text/csv' || file.originalname.endsWith('.csv')) { + cb(null, true); + } else { + cb(new Error('Only CSV files are allowed'), false); + } + }, + limits: { + fileSize: 5 * 1024 * 1024 // 5MB limit + } +}); + +// @route POST /api/users/import +// @desc Import users from CSV (Super Admin or Trainer) +// @access Private +router.post('/import', [ + auth, + requireTrainer, + upload.single('file') +], async (req, res) => { + // Remove BOM if present + if (req.file && req.file.path) { + removeBOMFromFile(req.file.path); + } + try { + if (!req.file) { + return res.status(400).json({ error: 'No CSV file uploaded.' }); + } + + const results = []; + const errors = []; + let created = 0; + let skipped = 0; + + // Parse CSV file + fs.createReadStream(req.file.path) + .pipe(csv()) + .on('data', (data) => { + results.push(data); + }) + .on('end', async () => { + try { + // Process each row + for (let i = 0; i < results.length; i++) { + const row = results[i]; + const rowNumber = i + 2; // +2 because CSV has header and arrays are 0-indexed + + try { + // Validate required fields + if (!row.firstName || !row.lastName || !row.email) { + errors.push(`Row ${rowNumber}: Missing required fields (firstName, lastName, email)`); + continue; + } + + // Validate email format + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(row.email)) { + errors.push(`Row ${rowNumber}: Invalid email format`); + continue; + } + + // Check if user already exists + const existingUser = await User.findOne({ where: { email: row.email.toLowerCase() } }); + if (existingUser) { + skipped++; + continue; + } + + // Hash default password + const defaultPassword = req.body.defaultPassword || 'changeme123'; + const hashedPassword = await bcrypt.hash(defaultPassword, 10); + + // Create user + await User.create({ + firstName: row.firstName.trim(), + lastName: row.lastName.trim(), + email: row.email.toLowerCase().trim(), + phone: row.phone ? row.phone.trim() : null, + password: hashedPassword, + role: 'trainee', + isActive: true, + requiresPasswordChange: true // Flag for first login password change + }); + + created++; + } catch (error) { + errors.push(`Row ${rowNumber}: ${error.message}`); + } + } + + // Clean up uploaded file + fs.unlinkSync(req.file.path); + + res.json({ + message: 'Import completed successfully.', + created, + skipped, + errors: errors.length, + errorDetails: errors + }); + } catch (error) { + // Clean up uploaded file + if (fs.existsSync(req.file.path)) { + fs.unlinkSync(req.file.path); + } + throw error; + } + }) + .on('error', (error) => { + // Clean up uploaded file + if (fs.existsSync(req.file.path)) { + fs.unlinkSync(req.file.path); + } + console.error('CSV parsing error:', error); + res.status(500).json({ error: 'Error parsing CSV file.' }); + }); + + } catch (error) { + console.error('Import users error:', error); + res.status(500).json({ error: 'Server error.' }); + } +}); + +// @route POST /api/slider/image +// @desc Upload slider image (Super Admin only) +// @access Private +router.post('/slider/image', [auth, requireSuperAdmin, uploadSliderImage.single('image')], async (req, res) => { + try { + if (!req.file) { + return res.status(400).json({ error: 'No image file uploaded.' }); + } + res.json({ + message: 'Slider image uploaded successfully.', + imageUrl: `/uploads/slider/${req.file.filename}` + }); + } catch (error) { + console.error('Upload slider image error:', error); + res.status(500).json({ error: 'Server error.' }); + } +}); + module.exports = router; \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a5f00a2..cc1516f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,10 +14,12 @@ "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^14.5.2", "axios": "^1.6.2", + "i18next": "^25.3.2", "react": "^18.2.0", "react-dom": "^18.2.0", "react-hook-form": "^7.48.2", "react-hot-toast": "^2.4.1", + "react-i18next": "^15.6.1", "react-query": "^3.39.3", "react-router-dom": "^6.20.1", "react-scripts": "^5.0.1", @@ -2134,7 +2136,7 @@ "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.9" @@ -2147,7 +2149,7 @@ "version": "0.3.9", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", @@ -4192,26 +4194,6 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, - "node_modules/@testing-library/dom": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", - "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "chalk": "^4.1.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@testing-library/jest-dom": { "version": "5.17.0", "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.17.0.tgz", @@ -4328,28 +4310,28 @@ "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@tsconfig/node12": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@tsconfig/node14": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@tsconfig/node16": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@types/aria-query": { @@ -4643,13 +4625,6 @@ "integrity": "sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA==", "license": "MIT" }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "license": "MIT", - "peer": true - }, "node_modules/@types/q": { "version": "1.5.8", "resolved": "https://registry.npmjs.org/@types/q/-/q-1.5.8.tgz", @@ -4668,17 +4643,6 @@ "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", "license": "MIT" }, - "node_modules/@types/react": { - "version": "18.3.23", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz", - "integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.0.2" - } - }, "node_modules/@types/react-dom": { "version": "18.3.7", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", @@ -6975,7 +6939,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/cross-spawn": { @@ -7678,7 +7642,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" @@ -10048,6 +10012,15 @@ "node": ">=12" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/html-webpack-plugin": { "version": "5.6.3", "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.6.3.tgz", @@ -10201,6 +10174,37 @@ "node": ">=10.17.0" } }, + "node_modules/i18next": { + "version": "25.3.2", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.3.2.tgz", + "integrity": "sha512-JSnbZDxRVbphc5jiptxr3o2zocy5dEqpVm9qCGdJwRNO+9saUJS0/u4LnM/13C23fUEWxAylPqKU/NpMV/IjqA==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -13846,7 +13850,7 @@ "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "devOptional": true, + "dev": true, "license": "ISC" }, "node_modules/makeerror": { @@ -16594,6 +16598,32 @@ "react-dom": ">=16" } }, + "node_modules/react-i18next": { + "version": "15.6.1", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.6.1.tgz", + "integrity": "sha512-uGrzSsOUUe2sDBG/+FJq2J1MM+Y4368/QW8OLEKSFvnDflHBbZhSd1u3UkW0Z06rMhZmnB/AQrhCpYfE5/5XNg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "html-parse-stringify": "^3.0.1" + }, + "peerDependencies": { + "i18next": ">= 23.2.3", + "react": ">= 16.8.0", + "typescript": "^5" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -18942,7 +18972,7 @@ "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "^0.8.0", @@ -18986,7 +19016,7 @@ "version": "8.3.4", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "acorn": "^8.11.0" @@ -18999,7 +19029,7 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/tsconfig-paths": { @@ -19191,20 +19221,6 @@ "is-typedarray": "^1.0.0" } }, - "node_modules/typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "license": "Apache-2.0", - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=4.2.0" - } - }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -19429,7 +19445,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/v8-to-istanbul": { @@ -19461,6 +19477,15 @@ "node": ">= 0.8" } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/w3c-hr-time": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", @@ -20414,7 +20439,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6" diff --git a/frontend/package.json b/frontend/package.json index 78ec288..eda5748 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,13 +10,15 @@ "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^14.5.2", "axios": "^1.6.2", + "i18next": "^25.3.2", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hook-form": "^7.48.2", + "react-hot-toast": "^2.4.1", + "react-i18next": "^15.6.1", + "react-query": "^3.39.3", "react-router-dom": "^6.20.1", "react-scripts": "^5.0.1", - "react-hot-toast": "^2.4.1", - "react-hook-form": "^7.48.2", - "react-query": "^3.39.3", "web-vitals": "^2.1.4" }, "scripts": { @@ -50,4 +52,4 @@ "tailwindcss": "^3.3.6" }, "proxy": "http://localhost:5000" -} \ No newline at end of file +} diff --git a/frontend/public/courseworx-logo.png b/frontend/public/courseworx-logo.png new file mode 100644 index 0000000..747c905 --- /dev/null +++ b/frontend/public/courseworx-logo.png @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/images/cx-logo.png b/frontend/public/images/cx-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..7491973fd84e94c2a5b5795ee8335816d00014ea GIT binary patch literal 1537 zcma)+`9ISQ0LQ=EQjSzlD#j=jCeh^>O&D!jt;b!?YF%5K`*}pdT+tk9Bgb6DbVz2o zk0q?4nA9-GguZkzbA*bX`r-NYc|EVs>-~QJ2cN`030OsWRe1ma6zy@gj=x&^3vJo8 zzbx1L{@w5R<4y$ufWqeAK;8^R`~UzMuDz|b^A*;VM|awo$@**UMUcvoZG+M7{t5)S ze^cS>RaQDDeAP2~F3h7RvV> zt-td>B*tfEvp)iew)}|LO_ojAVo3>`v;o)vY_eIB(!npIsj29JGG^0jzUG?BwwJzu z4q#2vj_QPq+oZ7M+VgyMZSj7Pm4*2fT)0ZVMDJN>clQO{F_EAI;t4?*G>$HrbXw@m z$p9e$)=r%6ok9s3O1XB##b*Q&brs+Pv3>D?Cnh4-gA#7gjpekIe@QKF?T6@BGoZo8 z-y7i8pHGDo?S~2ToPa~Q{1WR6dzq7moBi1}0Yq9-Iz}J(tk6q^Wb|@G4}st{jt@`G znhuyv*VjeR&{cCiB!lt{l`x0$Vi_hBZI!sAp&%(_ZnW62L@|uD=H#P*Wk{8U z$!|wnz!&XNU$hhIm;6qwI2Fu~sH>^bK!miV#POE-tX61L7G|rh#bpDdTgt?*$ck6T zolGcut%UAGR_MD10w+Ix{|q>PM>0)SomWcAO~3nDltNN15Gy$fX4TJdlaohGw9x&* z19F<-%=^oXN?Pfa_{gnak>7AA^7Oe^FZt(YGHc6W8FU%Kd4qhygBFklyN~JUd40@u zzg5^H&Eqm{yLkq2xs}(!5vHc^C z=Xbvz-P*P8)AwNhJ_)x9NzSv+#|FHPG@r`_2D91lY&PkBKD9iqNYU9Vhm9Jm1lL9J zl9#)7E*gz8s+O%}H!^z|i(YC*TforJ$io=f$nmx0;msytc!z1-L-@|~pdTC+)22`| z=u(ph0~KQ2_Kn~t>bs3m(8BTR`D*(92}hXm2T+Us?5fskhq9>8Jk1l8*GvyIRV<&m zT;ev0_6S74!zmlGP(O%9yryj=YM7SBJ2^4ygs9L>SkG)O!g$H9Vbo0<1B?o|bVIAr zZXLgYlaQ;NsY8NOZKJo&^0K@1!gt@*H{VHsH3~a!Ly*tKOy|QrGcz5RJXlrZ3pl82 zdlNh;u=S0RVOCD$I0@#jZTVQ9+r|$MK-~2Hd#d;NbK2^~GM(6bXqCjLCFJ{H| zQG`2u=3;kRiRnrz;L=5lOli)h{HrO22t} zpq1sX56`xjh<0$5QMv&iTMvk^|q zLsX4ULrkQF23kg1cV^LsW(nootGXO@F%+aq!-l`4fv#=ayY%J^eNHame9pd7E!7%( zFv+7lQ { const { user, loading } = useAuth(); @@ -36,25 +40,38 @@ const AppRoutes = () => { : } /> - - - - - }> - } /> - } /> - } /> - } /> - } /> - } /> + + {/* Private routes with Layout */} + }> + } /> + } /> + + + + } /> + } /> + + + + } /> + + + + } /> + } /> + } /> - - } /> + {/* Catch-all: redirect to dashboard if authenticated, else to login */} + : } /> ); }; diff --git a/frontend/src/components/Layout.js b/frontend/src/components/Layout.js index 522156e..3440afc 100644 --- a/frontend/src/components/Layout.js +++ b/frontend/src/components/Layout.js @@ -1,6 +1,7 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { Outlet, useNavigate, useLocation } from 'react-router-dom'; import { useAuth } from '../contexts/AuthContext'; +import PasswordChangeModal from './PasswordChangeModal'; import { HomeIcon, AcademicCapIcon, @@ -16,6 +17,14 @@ const Layout = () => { const navigate = useNavigate(); const location = useLocation(); const [sidebarOpen, setSidebarOpen] = useState(false); + const [showPasswordModal, setShowPasswordModal] = useState(false); + + // Check if user requires password change + useEffect(() => { + if (user?.requiresPasswordChange) { + setShowPasswordModal(true); + } + }, [user?.requiresPasswordChange]); const navigation = [ { name: 'Dashboard', href: '/dashboard', icon: HomeIcon }, @@ -167,6 +176,12 @@ const Layout = () => { + + {/* Password Change Modal */} + setShowPasswordModal(false)} + /> ); }; diff --git a/frontend/src/components/PasswordChangeModal.js b/frontend/src/components/PasswordChangeModal.js new file mode 100644 index 0000000..38d22d9 --- /dev/null +++ b/frontend/src/components/PasswordChangeModal.js @@ -0,0 +1,161 @@ +import React, { useState } from 'react'; +import { useMutation } from 'react-query'; +import { authAPI } from '../services/api'; +import { useAuth } from '../contexts/AuthContext'; +import { + ExclamationTriangleIcon, + EyeIcon, + EyeSlashIcon, +} from '@heroicons/react/24/outline'; +import LoadingSpinner from './LoadingSpinner'; +import toast from 'react-hot-toast'; + +const PasswordChangeModal = ({ isOpen, onClose }) => { + const { user, updateUser } = useAuth(); + const [formData, setFormData] = useState({ + newPassword: '', + confirmPassword: '', + }); + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + + const changePasswordMutation = useMutation( + (data) => authAPI.firstPasswordChange(data), + { + onSuccess: (response) => { + updateUser(response.data.user); + toast.success('Password changed successfully!'); + onClose(); + }, + onError: (error) => { + toast.error(error.response?.data?.error || 'Failed to change password'); + }, + } + ); + + const handleSubmit = (e) => { + e.preventDefault(); + + if (formData.newPassword !== formData.confirmPassword) { + toast.error('Passwords do not match'); + return; + } + + if (formData.newPassword.length < 6) { + toast.error('Password must be at least 6 characters long'); + return; + } + + changePasswordMutation.mutate({ + newPassword: formData.newPassword + }); + }; + + const handleInputChange = (e) => { + const { name, value } = e.target; + setFormData(prev => ({ + ...prev, + [name]: value + })); + }; + + if (!isOpen) return null; + + return ( +
+
+
+ +

+ Change Your Password +

+
+ +
+

+ Welcome, {user?.firstName}! For security reasons, you need to change your password before continuing. +

+
+ +
+
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ +
+
+
+
+ ); +}; + +export default PasswordChangeModal; \ No newline at end of file diff --git a/frontend/src/contexts/AuthContext.js b/frontend/src/contexts/AuthContext.js index 52a03ee..0608be6 100644 --- a/frontend/src/contexts/AuthContext.js +++ b/frontend/src/contexts/AuthContext.js @@ -37,15 +37,18 @@ export const AuthProvider = ({ children }) => { const login = async (email, password) => { try { + console.log('Attempting login with email:', email); const response = await authAPI.login(email, password); const { token, user } = response.data; + console.log('Login successful, user:', user); localStorage.setItem('token', token); setUser(user); toast.success('Login successful!'); return { success: true }; } catch (error) { + console.error('Login error details:', error.response?.data); const message = error.response?.data?.error || 'Login failed'; toast.error(message); return { success: false, error: message }; @@ -83,6 +86,10 @@ export const AuthProvider = ({ children }) => { } }; + const updateUser = (userData) => { + setUser(userData); + }; + const value = { user, loading, @@ -90,6 +97,7 @@ export const AuthProvider = ({ children }) => { logout, updateProfile, changePassword, + updateUser, isAuthenticated: !!user, isSuperAdmin: user?.role === 'super_admin', isTrainer: user?.role === 'trainer', diff --git a/frontend/src/i18n.js b/frontend/src/i18n.js new file mode 100644 index 0000000..768e8ae --- /dev/null +++ b/frontend/src/i18n.js @@ -0,0 +1,43 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; + +const resources = { + en: { + translation: { + "welcome": "Welcome to CourseWorx", + "login": "Log in", + "signup": "Sign up", + // Add more translations as needed + } + }, + ar: { + translation: { + "welcome": "مرحبا بك في كورس وركس", + "login": "تسجيل الدخول", + "signup": "إنشاء حساب", + // Add more translations as needed + } + } +}; + +i18n + .use(initReactI18next) + .init({ + resources, + lng: 'en', + fallbackLng: 'en', + interpolation: { + escapeValue: false + } + }); + +// Direction switching logic +export function setDocumentDirection(lang) { + if (lang === 'ar') { + document.documentElement.dir = 'rtl'; + } else { + document.documentElement.dir = 'ltr'; + } +} + +export default i18n; \ No newline at end of file diff --git a/frontend/src/index.css b/frontend/src/index.css index d76aea1..2108d3c 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -26,7 +26,7 @@ } .input-field { - @apply block w-full rounded-lg border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm; + @apply block w-full rounded-lg border-2 border-gray-300 bg-white shadow-sm px-4 py-2 text-base focus:border-primary-500 focus:ring-2 focus:ring-primary-200 focus:bg-blue-50 transition-all duration-150 placeholder-gray-400; } .card { diff --git a/frontend/src/index.js b/frontend/src/index.js index 62feda6..fc502c8 100644 --- a/frontend/src/index.js +++ b/frontend/src/index.js @@ -5,6 +5,9 @@ import { QueryClient, QueryClientProvider } from 'react-query'; import { Toaster } from 'react-hot-toast'; import './index.css'; import App from './App'; +import './i18n'; +import Modal from 'react-modal'; +Modal.setAppElement('#root'); const queryClient = new QueryClient({ defaultOptions: { diff --git a/frontend/src/pages/CourseCreate.js b/frontend/src/pages/CourseCreate.js new file mode 100644 index 0000000..cc93e03 --- /dev/null +++ b/frontend/src/pages/CourseCreate.js @@ -0,0 +1,392 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useMutation, useQueryClient } from 'react-query'; +import { useAuth } from '../contexts/AuthContext'; +import { coursesAPI } from '../services/api'; +import { + AcademicCapIcon, + ClockIcon, + CurrencyDollarIcon, + BookOpenIcon, + TagIcon, + UserGroupIcon, +} from '@heroicons/react/24/outline'; +import LoadingSpinner from '../components/LoadingSpinner'; +import toast from 'react-hot-toast'; + +const CourseCreate = () => { + const navigate = useNavigate(); + const { isTrainer, isSuperAdmin } = useAuth(); + const queryClient = useQueryClient(); + const [loading, setLoading] = useState(false); + + const [formData, setFormData] = useState({ + title: '', + description: '', + shortDescription: '', + price: 0, + duration: '', + level: 'beginner', + category: '', + tags: '', + requirements: '', + learningOutcomes: '', + maxStudents: '', + startDate: '', + endDate: '', + isPublished: false, + isFeatured: false, + }); + + const createCourseMutation = useMutation( + (data) => coursesAPI.create(data), + { + onSuccess: (response) => { + queryClient.invalidateQueries(['courses']); + toast.success('Course created successfully!'); + navigate(`/courses/${response.data.course.id}`); + }, + onError: (error) => { + toast.error(error.response?.data?.error || 'Failed to create course'); + setLoading(false); + }, + } + ); + + const handleSubmit = async (e) => { + e.preventDefault(); + setLoading(true); + + const courseData = { + ...formData, + price: parseFloat(formData.price), + duration: formData.duration ? parseInt(formData.duration) : null, + maxStudents: formData.maxStudents ? parseInt(formData.maxStudents) : null, + tags: formData.tags ? formData.tags.split(',').map(tag => tag.trim()) : [], + startDate: formData.startDate || null, + endDate: formData.endDate || null, + }; + + createCourseMutation.mutate(courseData); + }; + + const handleInputChange = (e) => { + const { name, value, type, checked } = e.target; + setFormData(prev => ({ + ...prev, + [name]: type === 'checkbox' ? checked : value + })); + }; + + if (!isTrainer && !isSuperAdmin) { + return ( +
+

Access Denied

+

You don't have permission to create courses.

+
+ ); + } + + return ( +
+
+
+ +
+

Create New Course

+

Fill in the details below to create a new course

+
+
+
+ +
+ {/* Basic Information */} +
+

Basic Information

+
+
+ + +
+ +
+ + +
+ +
+ +