initial commit
This commit is contained in:
commit
0068785924
18
.dockerignore
Normal file
18
.dockerignore
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
coverage
|
||||||
|
.nyc_output
|
||||||
|
*.log
|
||||||
22
.env.example
Normal file
22
.env.example
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# Database Configuration
|
||||||
|
DB_NAME=lernplattform
|
||||||
|
DB_USER=lernplattform_user
|
||||||
|
DB_PASSWORD=your_secure_password_here
|
||||||
|
DB_PORT=5432
|
||||||
|
|
||||||
|
# Backend Configuration
|
||||||
|
NODE_ENV=production
|
||||||
|
BACKEND_PORT=3000
|
||||||
|
JWT_SECRET=your_jwt_secret_key_here_min_32_characters
|
||||||
|
SESSION_SECRET=your_session_secret_here_min_32_characters
|
||||||
|
ADMIN_DEFAULT_PASSWORD=your_admin_password_here
|
||||||
|
|
||||||
|
# Frontend Configuration
|
||||||
|
FRONTEND_PORT=80
|
||||||
|
VITE_API_URL=http://localhost:3000/api
|
||||||
|
|
||||||
|
# Session Configuration
|
||||||
|
SESSION_TIMEOUT=3600000
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
LOG_LEVEL=info
|
||||||
57
.gitignore
vendored
Normal file
57
.gitignore
vendored
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
*/node_modules/
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*/dist/
|
||||||
|
*/build/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.sql~
|
||||||
|
*.sqlite
|
||||||
|
*.db
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
*.tmp
|
||||||
|
|
||||||
|
# Coverage
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
|
||||||
|
# Production
|
||||||
|
/backend/dist
|
||||||
|
/frontend/dist
|
||||||
412
LESSON_QUICK_REFERENCE.md
Normal file
412
LESSON_QUICK_REFERENCE.md
Normal file
@ -0,0 +1,412 @@
|
|||||||
|
# Security Awareness Lessons - Quick Reference
|
||||||
|
|
||||||
|
A quick reference guide for instructors and administrators using the learning platform.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Available Lessons Overview
|
||||||
|
|
||||||
|
| Lesson | Difficulty | Duration | Topics | Interactive |
|
||||||
|
|--------|-----------|----------|--------|-------------|
|
||||||
|
| Phishing Email Detection | Beginner | 15 min | Email security, social engineering | No |
|
||||||
|
| SQL Injection Shop Demo | Intermediate | 20 min | Web security, OWASP Top 10 | Yes |
|
||||||
|
| Browser-in-the-Browser | Advanced | 25 min | Advanced phishing, OAuth | Yes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phishing Email Detection Basics
|
||||||
|
|
||||||
|
### Quick Facts
|
||||||
|
- **Best for:** All employees, security awareness foundation
|
||||||
|
- **Prerequisites:** None
|
||||||
|
- **Key Takeaway:** How to identify and report phishing emails
|
||||||
|
|
||||||
|
### What Students Learn
|
||||||
|
1. Common phishing indicators (suspicious domains, urgent language)
|
||||||
|
2. Email analysis techniques (hover over links, check headers)
|
||||||
|
3. Organizational reporting procedures
|
||||||
|
|
||||||
|
### Question Breakdown
|
||||||
|
- **Q1 (50 pts):** Identify phishing red flags (multiple choice)
|
||||||
|
- **Q2 (25 pts):** Safe email practices (single choice)
|
||||||
|
- **Q3 (25 pts):** Explain reporting procedures (free text)
|
||||||
|
|
||||||
|
### Teaching Tips
|
||||||
|
- Use real examples from your organization
|
||||||
|
- Emphasize cost of successful attacks
|
||||||
|
- Make reporting easy and encouraged
|
||||||
|
- Follow up with participants who fail
|
||||||
|
|
||||||
|
### Follow-Up Activities
|
||||||
|
- Conduct simulated phishing exercises
|
||||||
|
- Share recent phishing attempts with team
|
||||||
|
- Review organizational policies
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SQL Injection Attack - Online Shop Demo
|
||||||
|
|
||||||
|
### Quick Facts
|
||||||
|
- **Best for:** Developers, QA engineers, technical staff
|
||||||
|
- **Prerequisites:** Basic understanding of databases and web applications
|
||||||
|
- **Key Takeaway:** SQL injection is preventable with parameterized queries
|
||||||
|
|
||||||
|
### What Students Learn
|
||||||
|
1. How SQL injection vulnerabilities work
|
||||||
|
2. Types of SQL injection (OR, UNION, destructive)
|
||||||
|
3. Impact of successful attacks (data theft, deletion)
|
||||||
|
4. Defense mechanisms (parameterized queries, input validation)
|
||||||
|
|
||||||
|
### Interactive Component: Fake Shop
|
||||||
|
Students can:
|
||||||
|
- Search products normally
|
||||||
|
- Execute SQL injection attacks safely
|
||||||
|
- See real-time query visualization
|
||||||
|
- Compare vulnerable vs secure queries
|
||||||
|
|
||||||
|
**Attack Examples Demonstrated:**
|
||||||
|
- `' OR '1'='1` - View all products
|
||||||
|
- `' UNION SELECT ...` - Extract user credentials
|
||||||
|
- `'; DROP TABLE ...` - Destructive attack
|
||||||
|
|
||||||
|
### Question Breakdown
|
||||||
|
- **Q1 (40 pts):** Identify SQL injection payloads (multiple choice)
|
||||||
|
- **Q2 (30 pts):** Best prevention method (single choice)
|
||||||
|
- **Q3 (30 pts):** Explain parameterized queries (free text)
|
||||||
|
|
||||||
|
### Teaching Tips
|
||||||
|
- Let students experiment with injections
|
||||||
|
- Emphasize parameterized queries over filtering
|
||||||
|
- Show real breach examples (British Airways, etc.)
|
||||||
|
- Discuss OWASP Top 10 context
|
||||||
|
- Connect to secure coding standards
|
||||||
|
|
||||||
|
### Follow-Up Activities
|
||||||
|
- Code review session for SQL vulnerabilities
|
||||||
|
- Scan existing applications for SQL injection
|
||||||
|
- Implement parameterized queries in projects
|
||||||
|
- Add SQL injection tests to CI/CD pipeline
|
||||||
|
|
||||||
|
### For Developers
|
||||||
|
This lesson directly applies to:
|
||||||
|
- Backend API development
|
||||||
|
- Database query construction
|
||||||
|
- Security code reviews
|
||||||
|
- Penetration testing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Browser-in-the-Browser (BitB) Attack
|
||||||
|
|
||||||
|
### Quick Facts
|
||||||
|
- **Best for:** All staff, especially those using SSO/OAuth
|
||||||
|
- **Prerequisites:** Understanding of web browsers and login flows
|
||||||
|
- **Key Takeaway:** Physical testing (drag window) detects fake popups
|
||||||
|
|
||||||
|
### What Students Learn
|
||||||
|
1. How BitB attacks mimic legitimate browser windows
|
||||||
|
2. Why traditional phishing training doesn't catch this
|
||||||
|
3. Detection techniques (drag test, inspect element)
|
||||||
|
4. Why password managers provide protection
|
||||||
|
|
||||||
|
### Interactive Component: Fake Browser Popups
|
||||||
|
Students can:
|
||||||
|
- Launch realistic fake OAuth popups (Google, Microsoft)
|
||||||
|
- Compare real vs fake browser windows
|
||||||
|
- Test detection techniques (drag, right-click)
|
||||||
|
- See educational feedback when testing
|
||||||
|
|
||||||
|
**Two Scenarios:**
|
||||||
|
1. **Legitimate OAuth** - Shows how real popups behave
|
||||||
|
2. **BitB Attack** - Demonstrates fake trapped popup
|
||||||
|
|
||||||
|
### Question Breakdown
|
||||||
|
- **Q1 (40 pts):** Detection indicators (multiple choice)
|
||||||
|
- **Q2 (35 pts):** Safest approach to popups (single choice)
|
||||||
|
- **Q3 (25 pts):** Why password managers help (free text)
|
||||||
|
|
||||||
|
### Teaching Tips
|
||||||
|
- Emphasize this is NEW and sophisticated
|
||||||
|
- Practice the "drag test" multiple times
|
||||||
|
- Explain OAuth/SSO context for relevance
|
||||||
|
- Recommend password managers strongly
|
||||||
|
- Show real-world attack examples (2022+)
|
||||||
|
|
||||||
|
### Follow-Up Activities
|
||||||
|
- Test SSO popups in your organization
|
||||||
|
- Deploy password manager to all staff
|
||||||
|
- Enable 2FA/MFA on all accounts
|
||||||
|
- Consider hardware security keys (FIDO2)
|
||||||
|
- Review OAuth implementation security
|
||||||
|
|
||||||
|
### For Security Teams
|
||||||
|
This lesson supports:
|
||||||
|
- Advanced phishing awareness
|
||||||
|
- SSO security strategy
|
||||||
|
- Password manager adoption
|
||||||
|
- Zero-trust implementation
|
||||||
|
- Security tool evaluation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scoring System
|
||||||
|
|
||||||
|
### Point Distribution Philosophy
|
||||||
|
- **Easy questions:** 20-30% of total
|
||||||
|
- **Medium questions:** 40-50% of total
|
||||||
|
- **Hard questions:** 20-30% of total
|
||||||
|
|
||||||
|
### Passing Scores
|
||||||
|
- **70%** - Demonstrates basic competency
|
||||||
|
- **80%** - Strong understanding
|
||||||
|
- **90%+** - Expert level
|
||||||
|
|
||||||
|
### Partial Credit (Multiple Choice)
|
||||||
|
- Points awarded per correct selection
|
||||||
|
- Incorrect selections don't subtract points
|
||||||
|
- Encourages selecting all correct answers
|
||||||
|
|
||||||
|
### Free Text Validation
|
||||||
|
- Keyword-based scoring
|
||||||
|
- Partial credit if some keywords present
|
||||||
|
- Minimum length requirements
|
||||||
|
- Case-insensitive matching
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lesson Recommendations by Role
|
||||||
|
|
||||||
|
### All Employees
|
||||||
|
1. ✅ **Phishing Email Detection** (required)
|
||||||
|
2. ⚠️ **Browser-in-the-Browser** (recommended)
|
||||||
|
|
||||||
|
### Developers / Technical Staff
|
||||||
|
1. ✅ **SQL Injection Shop** (required)
|
||||||
|
2. ✅ **Phishing Email Detection** (required)
|
||||||
|
3. ⚠️ **Browser-in-the-Browser** (recommended)
|
||||||
|
|
||||||
|
### Security Team
|
||||||
|
1. ✅ All lessons (required)
|
||||||
|
2. Use as train-the-trainer material
|
||||||
|
|
||||||
|
### Management / Executives
|
||||||
|
1. ✅ **Phishing Email Detection** (required)
|
||||||
|
2. ✅ **Browser-in-the-Browser** (recommended - targets high-value accounts)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Creating Training Events
|
||||||
|
|
||||||
|
### Recommended Event Structures
|
||||||
|
|
||||||
|
#### New Hire Security Basics
|
||||||
|
```
|
||||||
|
Event: "Security Awareness Onboarding"
|
||||||
|
Duration: 1 week access
|
||||||
|
Lessons:
|
||||||
|
1. Phishing Email Detection (weight: 1.0)
|
||||||
|
2. Browser-in-the-Browser (weight: 1.0)
|
||||||
|
Passing: 70% overall
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Developer Security Training
|
||||||
|
```
|
||||||
|
Event: "Secure Coding Fundamentals"
|
||||||
|
Duration: 2 weeks access
|
||||||
|
Lessons:
|
||||||
|
1. Phishing Email Detection (weight: 0.5)
|
||||||
|
2. SQL Injection Shop (weight: 2.0)
|
||||||
|
3. Browser-in-the-Browser (weight: 0.5)
|
||||||
|
Passing: 75% overall
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Quarterly Security Refresher
|
||||||
|
```
|
||||||
|
Event: "Q1 Security Updates"
|
||||||
|
Duration: 1 week access
|
||||||
|
Lessons:
|
||||||
|
- Rotate lessons each quarter
|
||||||
|
- Include new lessons as available
|
||||||
|
Passing: 70% overall
|
||||||
|
```
|
||||||
|
|
||||||
|
### Event Configuration Tips
|
||||||
|
|
||||||
|
**Lesson Weights:**
|
||||||
|
- Weight = 1.0: Normal importance
|
||||||
|
- Weight = 2.0: Double importance
|
||||||
|
- Weight = 0.5: Lower priority/bonus
|
||||||
|
|
||||||
|
**Lesson Order:**
|
||||||
|
- Beginner → Intermediate → Advanced
|
||||||
|
- Required lessons first
|
||||||
|
- Interactive lessons for engagement
|
||||||
|
|
||||||
|
**Access Duration:**
|
||||||
|
- Minimum: 3 days (allows flexible completion)
|
||||||
|
- Typical: 1-2 weeks
|
||||||
|
- Ongoing training: No end date
|
||||||
|
|
||||||
|
**Point Configuration:**
|
||||||
|
- Use default 100 max points per lesson
|
||||||
|
- Adjust weights instead of max points
|
||||||
|
- Keep passing score consistent (70-75%)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting Common Issues
|
||||||
|
|
||||||
|
### "Lesson not showing up"
|
||||||
|
- ✅ Check lesson is assigned to event
|
||||||
|
- ✅ Verify event is active
|
||||||
|
- ✅ Confirm participant joined correct event
|
||||||
|
|
||||||
|
### "Can't complete lesson"
|
||||||
|
- ✅ Ensure all questions answered
|
||||||
|
- ✅ Check for validation errors
|
||||||
|
- ✅ Verify lesson was started
|
||||||
|
- ✅ Try refreshing page
|
||||||
|
|
||||||
|
### "Interactive component not working"
|
||||||
|
- ✅ Check browser console for errors
|
||||||
|
- ✅ Try different browser
|
||||||
|
- ✅ Verify JavaScript enabled
|
||||||
|
- ✅ Clear browser cache
|
||||||
|
|
||||||
|
### "Score seems wrong"
|
||||||
|
- ✅ Review partial credit rules
|
||||||
|
- ✅ Check question weights
|
||||||
|
- ✅ Verify lesson weight in event
|
||||||
|
- ✅ See participant answers in admin panel
|
||||||
|
|
||||||
|
### "Assigning lesson fails"
|
||||||
|
- ✅ Check lesson isn't already assigned
|
||||||
|
- ✅ Verify event exists
|
||||||
|
- ✅ Order index auto-increments now (fixed)
|
||||||
|
- ✅ Try different order index
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Keyboard Shortcuts (Lesson Player)
|
||||||
|
|
||||||
|
| Action | Shortcut | Notes |
|
||||||
|
|--------|----------|-------|
|
||||||
|
| Next step | → or Enter | Only if current step complete |
|
||||||
|
| Previous step | ← | Always available |
|
||||||
|
| Submit answer | Enter | In text input |
|
||||||
|
| Complete lesson | - | Click button when on last step |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Best Practices for Admins
|
||||||
|
|
||||||
|
### Event Planning
|
||||||
|
- Schedule events with advance notice
|
||||||
|
- Provide clear deadline communication
|
||||||
|
- Send reminder emails at 50% and 90% of time
|
||||||
|
- Celebrate completion publicly
|
||||||
|
- Review analytics after event
|
||||||
|
|
||||||
|
### Lesson Assignment
|
||||||
|
- Start with easier lessons
|
||||||
|
- Mix content types (text + interactive)
|
||||||
|
- Don't overload with too many lessons
|
||||||
|
- Consider time required for completion
|
||||||
|
- Weight by organizational priority
|
||||||
|
|
||||||
|
### Participant Support
|
||||||
|
- Provide help desk contact info
|
||||||
|
- Monitor completion rates
|
||||||
|
- Follow up with non-completers
|
||||||
|
- Review common wrong answers
|
||||||
|
- Adjust training based on feedback
|
||||||
|
|
||||||
|
### Monitoring Progress
|
||||||
|
- Check completion rates weekly
|
||||||
|
- Identify struggling participants
|
||||||
|
- Review average scores per lesson
|
||||||
|
- Look for common failure points
|
||||||
|
- Export data for reporting
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Integration Ideas
|
||||||
|
|
||||||
|
### With Existing Training
|
||||||
|
- Part of onboarding checklist
|
||||||
|
- Annual security training requirement
|
||||||
|
- Post-incident remediation
|
||||||
|
- Role-based training tracks
|
||||||
|
|
||||||
|
### With Security Tools
|
||||||
|
- Password manager deployment
|
||||||
|
- 2FA enrollment campaign
|
||||||
|
- Phishing simulation platform
|
||||||
|
- Security awareness metrics
|
||||||
|
|
||||||
|
### With HR/Compliance
|
||||||
|
- Track completion for compliance
|
||||||
|
- Report to leadership quarterly
|
||||||
|
- Include in performance reviews
|
||||||
|
- Tie to security culture initiatives
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Metrics to Track
|
||||||
|
|
||||||
|
### Completion Metrics
|
||||||
|
- % of participants who completed
|
||||||
|
- Average time to complete
|
||||||
|
- Completion rate by department
|
||||||
|
- Deadline adherence
|
||||||
|
|
||||||
|
### Performance Metrics
|
||||||
|
- Average score per lesson
|
||||||
|
- Pass/fail rates
|
||||||
|
- Most missed questions
|
||||||
|
- Improvement over time
|
||||||
|
|
||||||
|
### Engagement Metrics
|
||||||
|
- Time spent per lesson
|
||||||
|
- Interactive component usage
|
||||||
|
- Repeat attempts
|
||||||
|
- Question feedback ratings
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Access URLs
|
||||||
|
|
||||||
|
Assuming platform at `http://localhost`:
|
||||||
|
|
||||||
|
- **Participant Hub:** `/`
|
||||||
|
- **Admin Login:** `/admin/login`
|
||||||
|
- **Event Management:** `/admin/events`
|
||||||
|
- **Lesson Configuration:** `/admin/events/{id}/lessons`
|
||||||
|
- **Participant Data:** `/admin/events/{id}/participants`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Support Resources
|
||||||
|
|
||||||
|
### For Lesson Content Questions
|
||||||
|
- Review lesson documentation
|
||||||
|
- Check existing lesson examples
|
||||||
|
- Test in development environment
|
||||||
|
|
||||||
|
### For Technical Issues
|
||||||
|
- Check browser console
|
||||||
|
- Review backend logs
|
||||||
|
- Verify container health
|
||||||
|
- Check database connectivity
|
||||||
|
|
||||||
|
### For Training Strategy
|
||||||
|
- Consult security team
|
||||||
|
- Review industry standards
|
||||||
|
- Benchmark against similar organizations
|
||||||
|
- Gather participant feedback
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated:** 2026-01-12
|
||||||
|
**Platform Version:** 1.0.0
|
||||||
|
**Total Lessons:** 3
|
||||||
896
PLATFORM_DOCUMENTATION.md
Normal file
896
PLATFORM_DOCUMENTATION.md
Normal file
@ -0,0 +1,896 @@
|
|||||||
|
# Security Awareness Learning Platform - Documentation
|
||||||
|
|
||||||
|
Complete technical documentation for the containerized security awareness training platform.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Platform Overview](#platform-overview)
|
||||||
|
2. [Architecture](#architecture)
|
||||||
|
3. [Getting Started](#getting-started)
|
||||||
|
4. [User Roles & Workflows](#user-roles--workflows)
|
||||||
|
5. [Lessons System](#lessons-system)
|
||||||
|
6. [API Reference](#api-reference)
|
||||||
|
7. [Database Schema](#database-schema)
|
||||||
|
8. [Frontend Components](#frontend-components)
|
||||||
|
9. [Deployment](#deployment)
|
||||||
|
10. [Development Guide](#development-guide)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Platform Overview
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
A containerized web application for security awareness training featuring:
|
||||||
|
- **Hub-based architecture** - Participants join events with pseudonyms
|
||||||
|
- **Modular lessons** - Easily expandable lesson system
|
||||||
|
- **Interactive content** - Hands-on security demonstrations
|
||||||
|
- **Weighted scoring** - Flexible point configuration per event
|
||||||
|
|
||||||
|
### Technology Stack
|
||||||
|
- **Backend:** Node.js 18 + Express
|
||||||
|
- **Frontend:** React 18 + Vite 5
|
||||||
|
- **Database:** PostgreSQL 15
|
||||||
|
- **Deployment:** Docker Compose
|
||||||
|
- **Lesson Format:** YAML configs + JavaScript modules
|
||||||
|
|
||||||
|
### Key Features
|
||||||
|
- ✅ No registration required for participants (pseudonym-based)
|
||||||
|
- ✅ Admin panel with password authentication
|
||||||
|
- ✅ Interactive security lessons (SQL injection, phishing demos)
|
||||||
|
- ✅ Progress tracking and analytics
|
||||||
|
- ✅ Weighted scoring system
|
||||||
|
- ✅ Containerized deployment (production-ready)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### High-Level Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ User (Browser) │
|
||||||
|
└─────────────┬───────────────────────────────┘
|
||||||
|
│
|
||||||
|
├─── http://localhost (Frontend - Nginx)
|
||||||
|
│ ├─ Hub (Join Events)
|
||||||
|
│ ├─ Lesson Player
|
||||||
|
│ ├─ Admin Panel
|
||||||
|
│ └─ Progress Dashboard
|
||||||
|
│
|
||||||
|
├─── http://localhost:3000/api (Backend - Express)
|
||||||
|
│ ├─ Participant API
|
||||||
|
│ ├─ Admin API
|
||||||
|
│ ├─ Lesson API
|
||||||
|
│ └─ Lesson Modules
|
||||||
|
│
|
||||||
|
└─── PostgreSQL:5432 (Database)
|
||||||
|
├─ Events & Participants
|
||||||
|
├─ Lessons & Progress
|
||||||
|
└─ Answers & Scores
|
||||||
|
```
|
||||||
|
|
||||||
|
### Container Architecture
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
database:
|
||||||
|
- PostgreSQL 15 Alpine
|
||||||
|
- Volume: lesson_platform_data
|
||||||
|
- Health checks enabled
|
||||||
|
|
||||||
|
backend:
|
||||||
|
- Node.js 18 Alpine
|
||||||
|
- Depends on: database (healthy)
|
||||||
|
- Volumes: ./backend/lessons (lessons)
|
||||||
|
- Health endpoint: /health
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
- Nginx Alpine
|
||||||
|
- Serves: React SPA
|
||||||
|
- Reverse proxy: API calls to backend
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Participant Flow:
|
||||||
|
Hub → Join Event → View Lessons → Complete Lesson → View Progress
|
||||||
|
|
||||||
|
Admin Flow:
|
||||||
|
Login → Dashboard → Manage Events → Assign Lessons → View Analytics
|
||||||
|
|
||||||
|
Lesson Execution:
|
||||||
|
Start → Navigate Steps → Answer Questions → Submit → Complete
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- Docker Desktop or Docker Engine + Docker Compose
|
||||||
|
- 2GB RAM minimum
|
||||||
|
- Ports 80, 3000, 5432 available
|
||||||
|
|
||||||
|
### Quick Start
|
||||||
|
|
||||||
|
1. **Clone and Setup:**
|
||||||
|
```bash
|
||||||
|
cd lernplattform
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env with your settings
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Start Platform:**
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Verify Health:**
|
||||||
|
```bash
|
||||||
|
docker-compose ps # All should be "healthy"
|
||||||
|
curl http://localhost:3000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Access:**
|
||||||
|
- **Participants:** http://localhost
|
||||||
|
- **Admin:** http://localhost/admin/login
|
||||||
|
- Username: `admin`
|
||||||
|
- Password: `admin123`
|
||||||
|
|
||||||
|
5. **Seed Lessons (if needed):**
|
||||||
|
```bash
|
||||||
|
docker exec lernplattform_backend node seed-lessons.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### First Time Setup
|
||||||
|
|
||||||
|
1. **Create an Event:**
|
||||||
|
- Login to admin panel
|
||||||
|
- Click "Manage Events"
|
||||||
|
- Create new event with name and dates
|
||||||
|
|
||||||
|
2. **Assign Lessons:**
|
||||||
|
- Click "Manage Lessons" on event
|
||||||
|
- Assign lessons with weights
|
||||||
|
- Configure max points
|
||||||
|
|
||||||
|
3. **Test as Participant:**
|
||||||
|
- Go to http://localhost
|
||||||
|
- Join the event with a pseudonym
|
||||||
|
- Complete a lesson
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User Roles & Workflows
|
||||||
|
|
||||||
|
### Participant Role
|
||||||
|
|
||||||
|
**Capabilities:**
|
||||||
|
- Join events with pseudonym (no registration)
|
||||||
|
- View assigned lessons
|
||||||
|
- Complete interactive lessons
|
||||||
|
- Submit answers and receive immediate feedback
|
||||||
|
- View personal progress and score
|
||||||
|
- Complete lessons in any order (unless locked)
|
||||||
|
|
||||||
|
**Workflow:**
|
||||||
|
1. Navigate to hub page
|
||||||
|
2. Select event from dropdown
|
||||||
|
3. Enter pseudonym
|
||||||
|
4. Click "Join Event"
|
||||||
|
5. View lesson list
|
||||||
|
6. Click lesson to start
|
||||||
|
7. Navigate through steps
|
||||||
|
8. Answer questions
|
||||||
|
9. Complete lesson
|
||||||
|
10. View updated score
|
||||||
|
|
||||||
|
**Session Management:**
|
||||||
|
- Session token stored in localStorage
|
||||||
|
- Remains logged in across browser sessions
|
||||||
|
- Can logout to join different event
|
||||||
|
|
||||||
|
### Admin Role
|
||||||
|
|
||||||
|
**Capabilities:**
|
||||||
|
- Create, edit, delete events
|
||||||
|
- Assign lessons to events
|
||||||
|
- Configure lesson weights and points
|
||||||
|
- View participant data and progress
|
||||||
|
- See detailed answers submitted
|
||||||
|
- Export analytics
|
||||||
|
|
||||||
|
**Workflow:**
|
||||||
|
|
||||||
|
**Event Management:**
|
||||||
|
1. Login to admin panel
|
||||||
|
2. Navigate to Event Management
|
||||||
|
3. Create/edit/delete events
|
||||||
|
4. Set dates and active status
|
||||||
|
|
||||||
|
**Lesson Configuration:**
|
||||||
|
1. Select event
|
||||||
|
2. Click "Manage Lessons"
|
||||||
|
3. Assign lessons from catalog
|
||||||
|
4. Set order, weights, points
|
||||||
|
5. Mark as required/optional
|
||||||
|
|
||||||
|
**Monitor Progress:**
|
||||||
|
1. Select event
|
||||||
|
2. Click "View Participants"
|
||||||
|
3. See completion rates
|
||||||
|
4. Click participant for details
|
||||||
|
5. Review submitted answers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lessons System
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
Every lesson consists of:
|
||||||
|
1. **YAML Configuration** - Structure, questions, scoring
|
||||||
|
2. **JavaScript Module** - Validation, interactive data
|
||||||
|
3. **Database Entry** - Metadata, catalog reference
|
||||||
|
|
||||||
|
### Lesson Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Load Config → Initialize Module → Render Steps → Validate Answers → Calculate Score
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step Types
|
||||||
|
|
||||||
|
**1. Content Step**
|
||||||
|
```yaml
|
||||||
|
- id: "intro"
|
||||||
|
type: "content"
|
||||||
|
title: "Introduction"
|
||||||
|
content: "Educational content..."
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Question Step**
|
||||||
|
```yaml
|
||||||
|
- id: "question-1"
|
||||||
|
type: "question"
|
||||||
|
questionType: "single_choice|multiple_choice|free_text"
|
||||||
|
question: "Question text?"
|
||||||
|
options: [...]
|
||||||
|
maxPoints: 25
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Interactive Step**
|
||||||
|
```yaml
|
||||||
|
- id: "demo"
|
||||||
|
type: "interactive"
|
||||||
|
title: "Interactive Demo"
|
||||||
|
interactiveComponent: "ComponentName"
|
||||||
|
content: "Instructions..."
|
||||||
|
```
|
||||||
|
|
||||||
|
### Available Lessons
|
||||||
|
|
||||||
|
| Lesson | Key | Difficulty | Duration | Interactive |
|
||||||
|
|--------|-----|-----------|----------|-------------|
|
||||||
|
| Phishing Email Detection | `phishing-email-basics` | Beginner | 15 min | No |
|
||||||
|
| SQL Injection Shop | `sql-injection-shop` | Intermediate | 20 min | Yes |
|
||||||
|
| Browser-in-the-Browser | `browser-in-browser-attack` | Advanced | 25 min | Yes |
|
||||||
|
|
||||||
|
**Detailed Documentation:**
|
||||||
|
- [Comprehensive Lesson Docs](backend/lessons/LESSONS_DOCUMENTATION.md)
|
||||||
|
- [Quick Reference](LESSON_QUICK_REFERENCE.md)
|
||||||
|
- [Lesson README](backend/lessons/README.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### Base URL
|
||||||
|
```
|
||||||
|
http://localhost:3000/api
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
**Participant:**
|
||||||
|
- Header: `Authorization: Bearer <session_token>`
|
||||||
|
- Token obtained on event join
|
||||||
|
|
||||||
|
**Admin:**
|
||||||
|
- Header: `Authorization: Bearer <jwt_token>`
|
||||||
|
- Token obtained on login
|
||||||
|
|
||||||
|
### Participant Endpoints
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /participant/join
|
||||||
|
Body: { pseudonym, eventId }
|
||||||
|
→ { sessionToken, participant, event }
|
||||||
|
|
||||||
|
GET /participant/events
|
||||||
|
→ { events: [...] }
|
||||||
|
|
||||||
|
GET /participant/progress
|
||||||
|
Auth: Bearer session_token
|
||||||
|
→ { totalScore, completedLessons, ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lesson Endpoints
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /lesson/event/:eventId/lessons
|
||||||
|
Auth: Bearer session_token
|
||||||
|
→ { lessons: [...] }
|
||||||
|
|
||||||
|
GET /lesson/:eventLessonId
|
||||||
|
Auth: Bearer session_token
|
||||||
|
→ { title, steps, maxPoints, progress }
|
||||||
|
|
||||||
|
POST /lesson/:eventLessonId/start
|
||||||
|
Auth: Bearer session_token
|
||||||
|
→ { progressId, status }
|
||||||
|
|
||||||
|
POST /lesson/:eventLessonId/answer
|
||||||
|
Auth: Bearer session_token
|
||||||
|
Body: { questionId, answer }
|
||||||
|
→ { isCorrect, pointsAwarded, totalScore }
|
||||||
|
|
||||||
|
POST /lesson/:eventLessonId/complete
|
||||||
|
Auth: Bearer session_token
|
||||||
|
→ { completedAt, finalScore }
|
||||||
|
|
||||||
|
POST /lesson/:eventLessonId/action/:action
|
||||||
|
Auth: Bearer session_token
|
||||||
|
Body: { ...actionData }
|
||||||
|
→ { result }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Admin Endpoints
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /admin/login
|
||||||
|
Body: { username, password }
|
||||||
|
→ { token, admin }
|
||||||
|
|
||||||
|
GET /admin/events
|
||||||
|
Auth: Bearer admin_token
|
||||||
|
→ { events: [...] }
|
||||||
|
|
||||||
|
POST /admin/events
|
||||||
|
Auth: Bearer admin_token
|
||||||
|
Body: { name, description, startDate, endDate }
|
||||||
|
→ { event }
|
||||||
|
|
||||||
|
GET /admin/events/:eventId/participants
|
||||||
|
Auth: Bearer admin_token
|
||||||
|
→ { participants: [...] }
|
||||||
|
|
||||||
|
GET /admin/lessons
|
||||||
|
Auth: Bearer admin_token
|
||||||
|
→ { lessons: [...] }
|
||||||
|
|
||||||
|
POST /admin/events/:eventId/lessons
|
||||||
|
Auth: Bearer admin_token
|
||||||
|
Body: { lessonId, orderIndex, maxPoints, weight }
|
||||||
|
→ { eventLesson }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Responses
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"error": {
|
||||||
|
"message": "Error description",
|
||||||
|
"statusCode": 400
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
### Core Tables
|
||||||
|
|
||||||
|
**events**
|
||||||
|
```sql
|
||||||
|
id, name, description, start_date, end_date, is_active,
|
||||||
|
created_at, updated_at
|
||||||
|
```
|
||||||
|
|
||||||
|
**participants**
|
||||||
|
```sql
|
||||||
|
id, pseudonym, event_id, session_token,
|
||||||
|
created_at, last_active
|
||||||
|
UNIQUE(pseudonym, event_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
**lessons**
|
||||||
|
```sql
|
||||||
|
id, lesson_key, title, description,
|
||||||
|
module_path, config_path, difficulty_level, estimated_duration,
|
||||||
|
created_at, updated_at
|
||||||
|
```
|
||||||
|
|
||||||
|
**event_lessons**
|
||||||
|
```sql
|
||||||
|
id, event_id, lesson_id, order_index, max_points, weight,
|
||||||
|
is_required, unlock_after_lesson_id,
|
||||||
|
created_at, updated_at
|
||||||
|
UNIQUE(event_id, order_index)
|
||||||
|
```
|
||||||
|
|
||||||
|
**lesson_progress**
|
||||||
|
```sql
|
||||||
|
id, participant_id, event_lesson_id, status,
|
||||||
|
started_at, completed_at, score, attempts, current_step
|
||||||
|
```
|
||||||
|
|
||||||
|
**lesson_answers**
|
||||||
|
```sql
|
||||||
|
id, lesson_progress_id, question_key, answer_data (JSONB),
|
||||||
|
is_correct, points_awarded, feedback, submitted_at
|
||||||
|
```
|
||||||
|
|
||||||
|
**admin_users**
|
||||||
|
```sql
|
||||||
|
id, username, password_hash, created_at, updated_at
|
||||||
|
```
|
||||||
|
|
||||||
|
### Relationships
|
||||||
|
|
||||||
|
```
|
||||||
|
events (1) → (*) event_lessons → (*) lesson_progress
|
||||||
|
events (1) → (*) participants → (*) lesson_progress
|
||||||
|
lessons (1) → (*) event_lessons
|
||||||
|
lesson_progress (1) → (*) lesson_answers
|
||||||
|
```
|
||||||
|
|
||||||
|
### Indexes
|
||||||
|
|
||||||
|
- `participants(session_token)` - Session lookup
|
||||||
|
- `event_lessons(event_id, order_index)` - Lesson ordering
|
||||||
|
- `lesson_progress(participant_id, event_lesson_id)` - Progress tracking
|
||||||
|
- `lesson_answers(lesson_progress_id)` - Answer retrieval
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend Components
|
||||||
|
|
||||||
|
### Page Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── pages/
|
||||||
|
│ ├── Hub.jsx # Join event
|
||||||
|
│ ├── EventLanding.jsx # Lesson list
|
||||||
|
│ ├── LessonView.jsx # Lesson player
|
||||||
|
│ ├── ParticipantProgress.jsx # Progress view
|
||||||
|
│ └── admin/
|
||||||
|
│ ├── AdminLogin.jsx
|
||||||
|
│ ├── AdminDashboard.jsx
|
||||||
|
│ ├── EventManagement.jsx
|
||||||
|
│ ├── LessonConfiguration.jsx
|
||||||
|
│ └── ParticipantData.jsx
|
||||||
|
├── components/
|
||||||
|
│ ├── common/ # Shared components
|
||||||
|
│ └── lessons/
|
||||||
|
│ └── InteractiveContent/
|
||||||
|
│ ├── SQLShopDemo.jsx
|
||||||
|
│ └── BitBDemo.jsx
|
||||||
|
├── contexts/
|
||||||
|
│ ├── ParticipantContext.jsx # Participant auth
|
||||||
|
│ └── AdminContext.jsx # Admin auth
|
||||||
|
├── services/
|
||||||
|
│ ├── api.service.js # API client
|
||||||
|
│ └── session.service.js # Session mgmt
|
||||||
|
└── routes/
|
||||||
|
└── AppRoutes.jsx # Route config
|
||||||
|
```
|
||||||
|
|
||||||
|
### State Management
|
||||||
|
|
||||||
|
**Context Providers:**
|
||||||
|
- `ParticipantContext` - Session, authentication, profile
|
||||||
|
- `AdminContext` - Admin auth, profile
|
||||||
|
|
||||||
|
**Local State:**
|
||||||
|
- Component-level useState for UI state
|
||||||
|
- No Redux/Zustand needed for current scale
|
||||||
|
|
||||||
|
### Routing
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
/ → Hub (public)
|
||||||
|
/event → EventLanding (protected: participant)
|
||||||
|
/lesson/:id → LessonView (protected: participant)
|
||||||
|
/progress → ParticipantProgress (protected: participant)
|
||||||
|
|
||||||
|
/admin/login → AdminLogin (public)
|
||||||
|
/admin → AdminDashboard (protected: admin)
|
||||||
|
/admin/events → EventManagement (protected: admin)
|
||||||
|
/admin/events/:id/lessons → LessonConfiguration (protected: admin)
|
||||||
|
/admin/events/:id/participants → ParticipantData (protected: admin)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Interactive Components
|
||||||
|
|
||||||
|
**SQLShopDemo:**
|
||||||
|
- Fake e-commerce search interface
|
||||||
|
- Executes vulnerable SQL queries
|
||||||
|
- Real-time injection detection
|
||||||
|
- Safe vs vulnerable comparison
|
||||||
|
|
||||||
|
**BitBDemo:**
|
||||||
|
- Fake OAuth popup simulator
|
||||||
|
- Drag-test demonstration
|
||||||
|
- Right-click inspection
|
||||||
|
- Educational feedback system
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Database
|
||||||
|
DB_HOST=database
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_NAME=lernplattform_db
|
||||||
|
DB_USER=lernplattform_user
|
||||||
|
DB_PASSWORD=your_secure_password
|
||||||
|
|
||||||
|
# Backend
|
||||||
|
NODE_ENV=production
|
||||||
|
PORT=3000
|
||||||
|
JWT_SECRET=your_jwt_secret_key
|
||||||
|
SESSION_SECRET=your_session_secret
|
||||||
|
|
||||||
|
# Admin
|
||||||
|
ADMIN_PASSWORD=your_admin_password
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Compose Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start all services
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Stop all services
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker-compose logs -f [service]
|
||||||
|
|
||||||
|
# Rebuild specific service
|
||||||
|
docker-compose up -d --build [service]
|
||||||
|
|
||||||
|
# Check status
|
||||||
|
docker-compose ps
|
||||||
|
|
||||||
|
# Execute command in container
|
||||||
|
docker exec [container] [command]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Health Checks
|
||||||
|
|
||||||
|
**Backend Health:**
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "healthy",
|
||||||
|
"timestamp": "2026-01-12T...",
|
||||||
|
"environment": "production",
|
||||||
|
"database": "connected"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Container Status:**
|
||||||
|
```bash
|
||||||
|
docker-compose ps
|
||||||
|
# All should show "Up" and "healthy"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backup & Restore
|
||||||
|
|
||||||
|
**Backup Database:**
|
||||||
|
```bash
|
||||||
|
docker exec lernplattform_db pg_dump -U lernplattform_user lernplattform_db > backup.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
**Restore Database:**
|
||||||
|
```bash
|
||||||
|
cat backup.sql | docker exec -i lernplattform_db psql -U lernplattform_user lernplattform_db
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production Considerations
|
||||||
|
|
||||||
|
1. **Change Default Passwords** in `.env`
|
||||||
|
2. **Enable HTTPS** with reverse proxy (nginx/traefik)
|
||||||
|
3. **Set Up Regular Backups** of database
|
||||||
|
4. **Configure CORS** for production domain
|
||||||
|
5. **Enable Rate Limiting** on API endpoints
|
||||||
|
6. **Set Up Monitoring** (logs, metrics)
|
||||||
|
7. **Configure Log Rotation**
|
||||||
|
8. **Use Strong JWT/Session Secrets**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development Guide
|
||||||
|
|
||||||
|
### Local Development Setup
|
||||||
|
|
||||||
|
1. **Install Dependencies:**
|
||||||
|
```bash
|
||||||
|
cd backend && npm install
|
||||||
|
cd ../frontend && npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Run Without Docker:**
|
||||||
|
```bash
|
||||||
|
# Terminal 1: Database
|
||||||
|
docker run -p 5432:5432 -e POSTGRES_PASSWORD=pass postgres:15-alpine
|
||||||
|
|
||||||
|
# Terminal 2: Backend
|
||||||
|
cd backend && npm run dev
|
||||||
|
|
||||||
|
# Terminal 3: Frontend
|
||||||
|
cd frontend && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Access Development Server:**
|
||||||
|
- Frontend: http://localhost:5173
|
||||||
|
- Backend: http://localhost:3000
|
||||||
|
|
||||||
|
### Adding a New Lesson
|
||||||
|
|
||||||
|
**Step-by-Step:**
|
||||||
|
|
||||||
|
1. Create YAML config:
|
||||||
|
```bash
|
||||||
|
nano backend/lessons/configs/my-lesson.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create module directory:
|
||||||
|
```bash
|
||||||
|
mkdir backend/lessons/modules/my-lesson
|
||||||
|
nano backend/lessons/modules/my-lesson/index.js
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Extend base class:
|
||||||
|
```javascript
|
||||||
|
const LessonModule = require('../base/LessonModule');
|
||||||
|
|
||||||
|
class MyLesson extends LessonModule {
|
||||||
|
constructor(config) {
|
||||||
|
super(config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = MyLesson;
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Seed to database:
|
||||||
|
```bash
|
||||||
|
# Add to seed script
|
||||||
|
docker exec lernplattform_backend node seed-lessons.js
|
||||||
|
```
|
||||||
|
|
||||||
|
5. (Optional) Create interactive component:
|
||||||
|
```bash
|
||||||
|
nano frontend/src/components/lessons/InteractiveContent/MyComponent.jsx
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Register in LessonView.jsx
|
||||||
|
|
||||||
|
7. Test thoroughly
|
||||||
|
|
||||||
|
### Code Style
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
- Use async/await (not callbacks)
|
||||||
|
- Validate inputs with ApiError
|
||||||
|
- Use prepared statements for SQL
|
||||||
|
- Handle errors in asyncHandler
|
||||||
|
- Comment complex logic
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
- Functional components + hooks
|
||||||
|
- PropTypes for type checking (optional)
|
||||||
|
- CSS-in-JS for styling
|
||||||
|
- Destructure props
|
||||||
|
- Keep components small
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
**Manual Testing Checklist:**
|
||||||
|
- [ ] Can join event
|
||||||
|
- [ ] Lessons appear correctly
|
||||||
|
- [ ] Questions validate properly
|
||||||
|
- [ ] Scoring calculates correctly
|
||||||
|
- [ ] Progress saves
|
||||||
|
- [ ] Admin can assign lessons
|
||||||
|
- [ ] Interactive components work
|
||||||
|
- [ ] Session persists
|
||||||
|
|
||||||
|
**API Testing:**
|
||||||
|
```bash
|
||||||
|
# Health check
|
||||||
|
curl http://localhost:3000/health
|
||||||
|
|
||||||
|
# Get events (as participant)
|
||||||
|
curl http://localhost:3000/api/participant/events
|
||||||
|
```
|
||||||
|
|
||||||
|
### Debugging
|
||||||
|
|
||||||
|
**Backend Logs:**
|
||||||
|
```bash
|
||||||
|
docker logs -f lernplattform_backend
|
||||||
|
```
|
||||||
|
|
||||||
|
**Frontend Console:**
|
||||||
|
- Open browser DevTools (F12)
|
||||||
|
- Check Console tab for errors
|
||||||
|
- Check Network tab for API calls
|
||||||
|
|
||||||
|
**Database Query:**
|
||||||
|
```bash
|
||||||
|
docker exec -it lernplattform_db psql -U lernplattform_user lernplattform_db
|
||||||
|
\dt -- List tables
|
||||||
|
SELECT * FROM lessons;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
**1. Port already in use**
|
||||||
|
```bash
|
||||||
|
# Find process using port
|
||||||
|
lsof -i :80 # or 3000, 5432
|
||||||
|
# Kill process or change port in docker-compose.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Database connection failed**
|
||||||
|
```bash
|
||||||
|
# Check database is running
|
||||||
|
docker-compose ps database
|
||||||
|
# Check logs
|
||||||
|
docker logs lernplattform_db
|
||||||
|
# Verify credentials in .env
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Frontend shows blank page**
|
||||||
|
```bash
|
||||||
|
# Check frontend logs
|
||||||
|
docker logs lernplattform_frontend
|
||||||
|
# Rebuild frontend
|
||||||
|
docker-compose up -d --build frontend
|
||||||
|
# Check browser console
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. Admin login fails**
|
||||||
|
```bash
|
||||||
|
# Verify password hash in database
|
||||||
|
docker exec lernplattform_db psql -U lernplattform_user lernplattform_db -c "SELECT username, password_hash FROM admin_users;"
|
||||||
|
# Regenerate hash if needed
|
||||||
|
```
|
||||||
|
|
||||||
|
**5. Lesson won't assign**
|
||||||
|
```bash
|
||||||
|
# Check backend logs for constraint errors
|
||||||
|
docker logs lernplattform_backend
|
||||||
|
# Verify lesson exists
|
||||||
|
docker exec lernplattform_db psql -U lernplattform_user lernplattform_db -c "SELECT * FROM lessons;"
|
||||||
|
# Check for duplicate order_index (fixed in latest version)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
- Participants: UUID session tokens (stateless)
|
||||||
|
- Admins: JWT tokens with bcrypt password hashing
|
||||||
|
- No password recovery (admin must reset manually)
|
||||||
|
|
||||||
|
### Data Protection
|
||||||
|
- No PII collected from participants (pseudonyms only)
|
||||||
|
- Session tokens in localStorage (XSS risk mitigated)
|
||||||
|
- SQL injection prevented (parameterized queries)
|
||||||
|
- Input validation on frontend and backend
|
||||||
|
|
||||||
|
### Production Security
|
||||||
|
- Change all default passwords
|
||||||
|
- Use strong JWT secrets
|
||||||
|
- Enable HTTPS
|
||||||
|
- Configure CORS properly
|
||||||
|
- Implement rate limiting
|
||||||
|
- Regular security updates
|
||||||
|
- Monitor logs for attacks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
### Current Scale
|
||||||
|
- Handles ~100 concurrent users
|
||||||
|
- Database pool: 20 connections
|
||||||
|
- Frontend: Static files, fast load
|
||||||
|
|
||||||
|
### Optimization Options
|
||||||
|
- Add Redis for session caching
|
||||||
|
- Enable frontend caching (service worker)
|
||||||
|
- CDN for static assets
|
||||||
|
- Database read replicas
|
||||||
|
- Horizontal scaling (multiple backend instances)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
### Planned Features
|
||||||
|
- [ ] More lessons (XSS, CSRF, password security)
|
||||||
|
- [ ] Email notifications
|
||||||
|
- [ ] Bulk participant import
|
||||||
|
- [ ] Certificate generation
|
||||||
|
- [ ] Advanced analytics dashboard
|
||||||
|
- [ ] Lesson scheduling
|
||||||
|
- [ ] Mobile responsive improvements
|
||||||
|
- [ ] Dark mode
|
||||||
|
|
||||||
|
### Extension Points
|
||||||
|
- Custom lesson modules
|
||||||
|
- Additional interactive components
|
||||||
|
- Third-party integrations (LDAP, SSO)
|
||||||
|
- LMS integration (SCORM)
|
||||||
|
- API webhooks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Support & Resources
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- **This File:** Platform overview
|
||||||
|
- **[LESSONS_DOCUMENTATION.md](backend/lessons/LESSONS_DOCUMENTATION.md):** Comprehensive lesson guide
|
||||||
|
- **[LESSON_QUICK_REFERENCE.md](LESSON_QUICK_REFERENCE.md):** Quick reference for instructors
|
||||||
|
- **[lessons/README.md](backend/lessons/README.md):** Lesson development guide
|
||||||
|
|
||||||
|
### Useful Commands
|
||||||
|
```bash
|
||||||
|
# View all containers
|
||||||
|
docker ps -a
|
||||||
|
|
||||||
|
# Clean up
|
||||||
|
docker-compose down -v # Remove volumes
|
||||||
|
docker system prune # Clean unused resources
|
||||||
|
|
||||||
|
# Database access
|
||||||
|
docker exec -it lernplattform_db psql -U lernplattform_user lernplattform_db
|
||||||
|
|
||||||
|
# Backend shell
|
||||||
|
docker exec -it lernplattform_backend sh
|
||||||
|
|
||||||
|
# Tail logs
|
||||||
|
docker-compose logs -f --tail=100
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Version:** 1.0.0
|
||||||
|
**Last Updated:** 2026-01-12
|
||||||
|
**License:** MIT (or your license)
|
||||||
|
**Repository:** https://github.com/your-org/lernplattform (if applicable)
|
||||||
309
README.md
Normal file
309
README.md
Normal file
@ -0,0 +1,309 @@
|
|||||||
|
# Security Awareness Learning Platform
|
||||||
|
|
||||||
|
A containerized web application for security awareness training with modular, expandable lessons. Participants join events using pseudonyms and complete interactive security lessons including phishing detection, SQL injection demos, and more.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Hub-Based Architecture**: Participants join events with pseudonyms (no registration required)
|
||||||
|
- **Interactive Lessons**: Phishing demos, SQL injection sandboxes, fake login forms, and more
|
||||||
|
- **Modular Lesson System**: Easy to expand by adding YAML configs and JavaScript modules
|
||||||
|
- **Weighted Scoring**: Configurable points and weights per lesson
|
||||||
|
- **Admin Panel**: Complete event and lesson management, participant data viewing
|
||||||
|
- **Container-Based**: Easy deployment with Docker Compose
|
||||||
|
|
||||||
|
## Technology Stack
|
||||||
|
|
||||||
|
- **Backend**: Node.js + Express
|
||||||
|
- **Frontend**: React + Vite
|
||||||
|
- **Database**: PostgreSQL 15
|
||||||
|
- **Containerization**: Docker + Docker Compose
|
||||||
|
- **Lesson Storage**: YAML/JSON configurations + JavaScript modules
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Docker and Docker Compose installed
|
||||||
|
- Git (optional, for version control)
|
||||||
|
|
||||||
|
### Setup Instructions
|
||||||
|
|
||||||
|
1. **Clone or download the project**
|
||||||
|
```bash
|
||||||
|
cd lernplattform
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Configure environment variables**
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `.env` and set secure values for:
|
||||||
|
- `DB_PASSWORD` - Database password
|
||||||
|
- `JWT_SECRET` - JWT secret key (min 32 characters)
|
||||||
|
- `SESSION_SECRET` - Session secret (min 32 characters)
|
||||||
|
- `ADMIN_DEFAULT_PASSWORD` - Admin login password
|
||||||
|
|
||||||
|
3. **Start the application**
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Wait for services to be healthy**
|
||||||
|
```bash
|
||||||
|
docker-compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
All services should show "healthy" status.
|
||||||
|
|
||||||
|
5. **Access the application**
|
||||||
|
- Frontend: http://localhost (port 80)
|
||||||
|
- Backend API: http://localhost:3000
|
||||||
|
- Health check: http://localhost:3000/health
|
||||||
|
|
||||||
|
### Default Credentials
|
||||||
|
|
||||||
|
- **Admin Login**:
|
||||||
|
- Username: `admin`
|
||||||
|
- Password: Value set in `.env` (`ADMIN_DEFAULT_PASSWORD`)
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
lernplattform/
|
||||||
|
├── database/
|
||||||
|
│ └── init/
|
||||||
|
│ └── 01-schema.sql # Database schema
|
||||||
|
├── backend/
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── config/ # Configuration files
|
||||||
|
│ │ ├── middleware/ # Express middleware
|
||||||
|
│ │ ├── routes/ # API routes
|
||||||
|
│ │ ├── controllers/ # Route controllers
|
||||||
|
│ │ ├── services/ # Business logic
|
||||||
|
│ │ └── models/ # Database queries
|
||||||
|
│ ├── lessons/
|
||||||
|
│ │ ├── configs/ # Lesson YAML configs
|
||||||
|
│ │ └── modules/ # Lesson JavaScript modules
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ └── package.json
|
||||||
|
├── frontend/
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── pages/ # Page components
|
||||||
|
│ │ ├── components/ # Reusable components
|
||||||
|
│ │ ├── services/ # API services
|
||||||
|
│ │ └── styles/ # CSS styles
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ ├── nginx.conf
|
||||||
|
│ └── package.json
|
||||||
|
├── docker-compose.yml
|
||||||
|
└── .env.example
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Running in Development Mode
|
||||||
|
|
||||||
|
#### Backend Development
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The backend will run on port 3000 with hot reload.
|
||||||
|
|
||||||
|
#### Frontend Development
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The frontend will run on port 5173 with hot reload.
|
||||||
|
|
||||||
|
### Database Access
|
||||||
|
|
||||||
|
Connect to the PostgreSQL database:
|
||||||
|
```bash
|
||||||
|
docker-compose exec database psql -U lernplattform_user -d lernplattform
|
||||||
|
```
|
||||||
|
|
||||||
|
### Viewing Logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# All services
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# Specific service
|
||||||
|
docker-compose logs -f backend
|
||||||
|
docker-compose logs -f frontend
|
||||||
|
docker-compose logs -f database
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adding New Lessons
|
||||||
|
|
||||||
|
Lessons are modular and easy to add. Each lesson consists of:
|
||||||
|
1. A YAML configuration file (`backend/lessons/configs/*.yaml`)
|
||||||
|
2. A JavaScript module (`backend/lessons/modules/*/index.js`)
|
||||||
|
|
||||||
|
### Step-by-Step Guide
|
||||||
|
|
||||||
|
1. **Create YAML configuration** in `backend/lessons/configs/`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
lessonKey: "my-new-lesson"
|
||||||
|
title: "My New Lesson"
|
||||||
|
description: "Learn about security concept X"
|
||||||
|
difficultyLevel: "beginner"
|
||||||
|
estimatedDuration: 15
|
||||||
|
module: "my-new-lesson"
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- id: "intro"
|
||||||
|
type: "content"
|
||||||
|
title: "Introduction"
|
||||||
|
content: "Educational content here..."
|
||||||
|
|
||||||
|
- id: "question-1"
|
||||||
|
type: "question"
|
||||||
|
questionType: "single_choice"
|
||||||
|
question: "What is the answer?"
|
||||||
|
options:
|
||||||
|
- id: "option-1"
|
||||||
|
text: "Correct answer"
|
||||||
|
isCorrect: true
|
||||||
|
points: 50
|
||||||
|
- id: "option-2"
|
||||||
|
text: "Wrong answer"
|
||||||
|
isCorrect: false
|
||||||
|
points: 0
|
||||||
|
maxPoints: 50
|
||||||
|
feedback:
|
||||||
|
correct: "Great job!"
|
||||||
|
incorrect: "Try again..."
|
||||||
|
|
||||||
|
scoring:
|
||||||
|
passingScore: 70
|
||||||
|
maxTotalPoints: 100
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Create JavaScript module** in `backend/lessons/modules/my-new-lesson/`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const LessonModule = require('../base/LessonModule');
|
||||||
|
|
||||||
|
class MyNewLesson extends LessonModule {
|
||||||
|
constructor(config) {
|
||||||
|
super(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override methods if custom validation needed
|
||||||
|
// Otherwise, base class handles standard question types
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = MyNewLesson;
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Add lesson to database** (via admin panel or SQL):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO lessons (lesson_key, title, description, module_path, config_path, difficulty_level, estimated_duration)
|
||||||
|
VALUES ('my-new-lesson', 'My New Lesson', 'Description', 'my-new-lesson', 'my-new-lesson.yaml', 'beginner', 15);
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Assign lesson to event** via admin panel.
|
||||||
|
|
||||||
|
## API Documentation
|
||||||
|
|
||||||
|
### Participant Endpoints
|
||||||
|
|
||||||
|
- `POST /api/participant/join` - Join event with pseudonym
|
||||||
|
- `GET /api/participant/events` - List active events
|
||||||
|
- `GET /api/participant/event/:eventId/lessons` - Get lessons for event
|
||||||
|
- `GET /api/participant/lesson/:lessonId` - Get lesson content
|
||||||
|
- `POST /api/participant/lesson/:lessonId/answer` - Submit answer
|
||||||
|
- `GET /api/participant/progress` - Get progress
|
||||||
|
|
||||||
|
### Admin Endpoints
|
||||||
|
|
||||||
|
- `POST /api/admin/login` - Admin authentication
|
||||||
|
- `GET /api/admin/events` - List all events
|
||||||
|
- `POST /api/admin/events` - Create new event
|
||||||
|
- `PUT /api/admin/events/:eventId` - Update event
|
||||||
|
- `DELETE /api/admin/events/:eventId` - Delete event
|
||||||
|
- `POST /api/admin/events/:eventId/lessons` - Assign lesson to event
|
||||||
|
- `GET /api/admin/events/:eventId/participants` - View participant data
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
- All passwords are hashed with bcrypt
|
||||||
|
- JWT tokens for admin authentication
|
||||||
|
- Session tokens for participant authentication
|
||||||
|
- Parameterized SQL queries to prevent SQL injection
|
||||||
|
- CORS configured for security
|
||||||
|
- Security headers via Helmet.js
|
||||||
|
- Input validation on all endpoints
|
||||||
|
- Non-root user in Docker containers
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Database Connection Issues
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check database status
|
||||||
|
docker-compose ps database
|
||||||
|
|
||||||
|
# View database logs
|
||||||
|
docker-compose logs database
|
||||||
|
|
||||||
|
# Restart database
|
||||||
|
docker-compose restart database
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend Not Starting
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check backend logs
|
||||||
|
docker-compose logs backend
|
||||||
|
|
||||||
|
# Verify environment variables
|
||||||
|
docker-compose exec backend env | grep DB_
|
||||||
|
|
||||||
|
# Restart backend
|
||||||
|
docker-compose restart backend
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Not Loading
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check frontend logs
|
||||||
|
docker-compose logs frontend
|
||||||
|
|
||||||
|
# Verify nginx configuration
|
||||||
|
docker-compose exec frontend nginx -t
|
||||||
|
|
||||||
|
# Restart frontend
|
||||||
|
docker-compose restart frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reset Everything
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stop all services
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# Remove volumes (WARNING: Deletes all data!)
|
||||||
|
docker-compose down -v
|
||||||
|
|
||||||
|
# Rebuild and start
|
||||||
|
docker-compose up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
ISC
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues and questions, please open an issue in the project repository.
|
||||||
40
backend/Dockerfile
Normal file
40
backend/Dockerfile
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
# Multi-stage build for backend
|
||||||
|
|
||||||
|
# Builder stage
|
||||||
|
FROM node:18-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm ci --only=production
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup -g 1001 -S nodejs && \
|
||||||
|
adduser -S nodejs -u 1001
|
||||||
|
|
||||||
|
# Copy dependencies from builder
|
||||||
|
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY --chown=nodejs:nodejs . .
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER nodejs
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||||
|
CMD node -e "require('http').get('http://localhost:3000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"
|
||||||
|
|
||||||
|
# Start application
|
||||||
|
CMD ["node", "src/index.js"]
|
||||||
930
backend/lessons/LESSONS_DOCUMENTATION.md
Normal file
930
backend/lessons/LESSONS_DOCUMENTATION.md
Normal file
@ -0,0 +1,930 @@
|
|||||||
|
# Security Awareness Lessons Documentation
|
||||||
|
|
||||||
|
This document provides comprehensive information about all available lessons in the platform, including learning objectives, content structure, interactive components, and implementation details.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Phishing Email Detection Basics](#1-phishing-email-detection-basics)
|
||||||
|
2. [SQL Injection Attack - Online Shop Demo](#2-sql-injection-attack---online-shop-demo)
|
||||||
|
3. [Browser-in-the-Browser (BitB) Attack](#3-browser-in-the-browser-bitb-attack)
|
||||||
|
4. [Creating New Lessons](#creating-new-lessons)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Phishing Email Detection Basics
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
**Lesson Key:** `phishing-email-basics`
|
||||||
|
**Difficulty:** Beginner
|
||||||
|
**Duration:** 15 minutes
|
||||||
|
**Category:** Social Engineering / Email Security
|
||||||
|
|
||||||
|
### Learning Objectives
|
||||||
|
|
||||||
|
By the end of this lesson, participants will be able to:
|
||||||
|
- Identify common characteristics of phishing emails
|
||||||
|
- Recognize suspicious sender addresses and domains
|
||||||
|
- Detect urgency tactics used by attackers
|
||||||
|
- Understand link verification techniques
|
||||||
|
- Apply best practices for handling suspicious emails
|
||||||
|
|
||||||
|
### Content Structure
|
||||||
|
|
||||||
|
#### Steps:
|
||||||
|
|
||||||
|
1. **Introduction to Phishing** (Content)
|
||||||
|
- Definition and impact of phishing
|
||||||
|
- Statistics on phishing attacks
|
||||||
|
- Why email is a primary attack vector
|
||||||
|
|
||||||
|
2. **Common Red Flags** (Content)
|
||||||
|
- Suspicious sender addresses
|
||||||
|
- Spelling and grammar errors
|
||||||
|
- Urgent or threatening language
|
||||||
|
- Requests for sensitive information
|
||||||
|
- Suspicious links and attachments
|
||||||
|
|
||||||
|
3. **Question 1: Identify Phishing Indicators** (Multiple Choice)
|
||||||
|
- **Points:** 50 total
|
||||||
|
- **Correct Answers:**
|
||||||
|
- Misspelled sender domain (15 pts)
|
||||||
|
- Generic greeting instead of name (10 pts)
|
||||||
|
- Urgent threat about account closure (15 pts)
|
||||||
|
- Suspicious link destination (10 pts)
|
||||||
|
- **Topic:** Recognition of multiple warning signs
|
||||||
|
|
||||||
|
4. **Email Analysis Techniques** (Content)
|
||||||
|
- How to inspect sender information
|
||||||
|
- Hovering over links to check destinations
|
||||||
|
- Checking email headers
|
||||||
|
- Verifying legitimacy through official channels
|
||||||
|
|
||||||
|
5. **Question 2: Safe Email Practices** (Single Choice)
|
||||||
|
- **Points:** 25 total
|
||||||
|
- **Correct Answer:** "Hover over links to verify destination before clicking"
|
||||||
|
- **Topic:** Proactive defense techniques
|
||||||
|
|
||||||
|
6. **Question 3: Reporting Procedures** (Free Text)
|
||||||
|
- **Points:** 25 total
|
||||||
|
- **Validation:** Must mention "report", "IT", and "forward"
|
||||||
|
- **Topic:** Organizational response to phishing
|
||||||
|
|
||||||
|
### Scoring
|
||||||
|
|
||||||
|
- **Total Points:** 100
|
||||||
|
- **Passing Score:** 70%
|
||||||
|
- **Question Distribution:**
|
||||||
|
- Multiple choice: 50 points (partial credit)
|
||||||
|
- Single choice: 25 points (all or nothing)
|
||||||
|
- Free text: 25 points (keyword-based)
|
||||||
|
|
||||||
|
### Implementation Details
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Config: `backend/lessons/configs/phishing-email-basics.yaml`
|
||||||
|
- Module: `backend/lessons/modules/phishing-email-basics/index.js`
|
||||||
|
|
||||||
|
**Question Types:**
|
||||||
|
- Uses standard base class validation
|
||||||
|
- No custom interactive components
|
||||||
|
- Text-based content delivery
|
||||||
|
|
||||||
|
### Best Practices for Teaching
|
||||||
|
|
||||||
|
- Start with real-world examples
|
||||||
|
- Show actual phishing emails (sanitized)
|
||||||
|
- Emphasize the cost of successful attacks
|
||||||
|
- Practice with interactive email analysis
|
||||||
|
- Reinforce reporting procedures
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. SQL Injection Attack - Online Shop Demo
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
**Lesson Key:** `sql-injection-shop`
|
||||||
|
**Difficulty:** Intermediate
|
||||||
|
**Duration:** 20 minutes
|
||||||
|
**Category:** Web Application Security / OWASP Top 10
|
||||||
|
|
||||||
|
### Learning Objectives
|
||||||
|
|
||||||
|
By the end of this lesson, participants will be able to:
|
||||||
|
- Understand how SQL injection vulnerabilities work
|
||||||
|
- Recognize vulnerable code patterns
|
||||||
|
- Execute SQL injection attacks in a safe environment
|
||||||
|
- Understand the difference between vulnerable and secure queries
|
||||||
|
- Apply parameterized queries as the primary defense
|
||||||
|
|
||||||
|
### Content Structure
|
||||||
|
|
||||||
|
#### Steps:
|
||||||
|
|
||||||
|
1. **What is SQL Injection?** (Content)
|
||||||
|
- Definition and mechanism
|
||||||
|
- Types of damage (data theft, modification, deletion)
|
||||||
|
- Authentication bypass techniques
|
||||||
|
- Administrative operation exploitation
|
||||||
|
|
||||||
|
2. **Vulnerable Online Shop** (Interactive)
|
||||||
|
- **Component:** `SQLShopDemo`
|
||||||
|
- Live product search with vulnerable SQL backend
|
||||||
|
- Real-time query visualization
|
||||||
|
- Injection detection and explanation
|
||||||
|
|
||||||
|
3. **Question 1: Identify SQL Injection Payloads** (Multiple Choice)
|
||||||
|
- **Points:** 40 total
|
||||||
|
- **Correct Answers:**
|
||||||
|
- `' OR '1'='1` (15 pts) - Always-true condition
|
||||||
|
- `' UNION SELECT username, password FROM users--` (15 pts) - Data extraction
|
||||||
|
- `'; DROP TABLE products--` (10 pts) - Destructive attack
|
||||||
|
- **Topic:** Recognition of injection syntax
|
||||||
|
|
||||||
|
4. **How SQL Injection Works** (Content)
|
||||||
|
- Query structure explanation
|
||||||
|
- Normal vs malicious input comparison
|
||||||
|
- Step-by-step breakdown of attacks
|
||||||
|
- Impact demonstration
|
||||||
|
|
||||||
|
5. **Question 2: Prevention Methods** (Single Choice)
|
||||||
|
- **Points:** 30 total
|
||||||
|
- **Correct Answer:** "Use parameterized queries (prepared statements)"
|
||||||
|
- **Topic:** Gold-standard defense mechanism
|
||||||
|
|
||||||
|
6. **Preventing SQL Injection** (Content)
|
||||||
|
- Parameterized queries (primary defense)
|
||||||
|
- Input validation strategies
|
||||||
|
- Least privilege principle
|
||||||
|
- Web Application Firewalls
|
||||||
|
- Security auditing
|
||||||
|
|
||||||
|
7. **Question 3: Explain Parameterized Queries** (Free Text)
|
||||||
|
- **Points:** 30 total
|
||||||
|
- **Validation:** Must mention "parameter", "data", and "separate"
|
||||||
|
- **Minimum Length:** 50 characters
|
||||||
|
- **Topic:** Understanding separation of code and data
|
||||||
|
|
||||||
|
### Interactive Component: SQLShopDemo
|
||||||
|
|
||||||
|
#### Features:
|
||||||
|
|
||||||
|
**Mock Database:**
|
||||||
|
```javascript
|
||||||
|
products: 8 items (laptops, accessories, office supplies)
|
||||||
|
users: 3 accounts (admin, john_doe, jane_smith)
|
||||||
|
orders: 2 sample orders
|
||||||
|
```
|
||||||
|
|
||||||
|
**Attack Scenarios:**
|
||||||
|
|
||||||
|
1. **OR Injection:**
|
||||||
|
- Input: `' OR '1'='1`
|
||||||
|
- Result: Returns ALL products
|
||||||
|
- Explanation: Bypasses WHERE clause with always-true condition
|
||||||
|
|
||||||
|
2. **UNION SELECT:**
|
||||||
|
- Input: `' UNION SELECT id, username, password, role, 'LEAKED' FROM users--`
|
||||||
|
- Result: Displays user credentials in product table
|
||||||
|
- Explanation: Combines product data with user table
|
||||||
|
|
||||||
|
3. **DROP TABLE:**
|
||||||
|
- Input: `'; DROP TABLE products--`
|
||||||
|
- Result: Simulates table deletion
|
||||||
|
- Explanation: Executes destructive SQL command
|
||||||
|
|
||||||
|
**UI Elements:**
|
||||||
|
- Search input with monospace font for code clarity
|
||||||
|
- "Vulnerable Search" button (red) - executes unsafe query
|
||||||
|
- "Safe Search" button (green) - uses parameterized query
|
||||||
|
- Quick-load example buttons
|
||||||
|
- Real-time SQL query display with syntax highlighting
|
||||||
|
- Injection detection warnings with emoji indicators
|
||||||
|
- Results table showing affected data
|
||||||
|
- Color-coded feedback (red for exploits, green for safe)
|
||||||
|
|
||||||
|
#### Technical Implementation:
|
||||||
|
|
||||||
|
**Backend Methods:**
|
||||||
|
```javascript
|
||||||
|
executeVulnerableQuery(searchTerm)
|
||||||
|
- Simulates vulnerable string concatenation
|
||||||
|
- Detects injection patterns
|
||||||
|
- Returns appropriate results based on attack type
|
||||||
|
|
||||||
|
executeSafeQuery(searchTerm)
|
||||||
|
- Demonstrates parameterized approach
|
||||||
|
- Treats all input as literal data
|
||||||
|
- Shows query with placeholder syntax
|
||||||
|
|
||||||
|
detectInjection(input)
|
||||||
|
- Regex-based pattern matching
|
||||||
|
- Identifies quotes, comments, SQL keywords
|
||||||
|
|
||||||
|
analyzeInjection(input)
|
||||||
|
- Classifies injection type
|
||||||
|
- Generates educational explanation
|
||||||
|
```
|
||||||
|
|
||||||
|
**Frontend API Call:**
|
||||||
|
```javascript
|
||||||
|
participantAPI.executeLessonAction(
|
||||||
|
eventLessonId,
|
||||||
|
'execute-query',
|
||||||
|
{ searchTerm, mode: 'vulnerable' | 'safe' }
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scoring
|
||||||
|
|
||||||
|
- **Total Points:** 100
|
||||||
|
- **Passing Score:** 70%
|
||||||
|
- **Question Distribution:**
|
||||||
|
- Multiple choice: 40 points (partial credit)
|
||||||
|
- Single choice: 30 points
|
||||||
|
- Free text: 30 points (keyword validation)
|
||||||
|
|
||||||
|
### Implementation Details
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Config: `backend/lessons/configs/sql-injection-shop.yaml`
|
||||||
|
- Module: `backend/lessons/modules/sql-injection-shop/index.js`
|
||||||
|
- Component: `frontend/src/components/lessons/InteractiveContent/SQLShopDemo.jsx`
|
||||||
|
|
||||||
|
**Dependencies:**
|
||||||
|
- Extends `LessonModule` base class
|
||||||
|
- Custom `executeVulnerableQuery` method
|
||||||
|
- Custom `executeSafeQuery` method
|
||||||
|
- Interactive data provider
|
||||||
|
|
||||||
|
**API Endpoint:**
|
||||||
|
- `POST /api/lesson/:eventLessonId/action/execute-query`
|
||||||
|
- Requires participant authentication
|
||||||
|
- Validates lesson is started
|
||||||
|
|
||||||
|
### Best Practices for Teaching
|
||||||
|
|
||||||
|
- Start with normal searches to establish baseline
|
||||||
|
- Progress from simple to complex injections
|
||||||
|
- Always compare vulnerable vs safe implementations
|
||||||
|
- Emphasize that filtering alone is insufficient
|
||||||
|
- Show real-world impact examples
|
||||||
|
- Demonstrate UNION attacks to highlight data exposure risk
|
||||||
|
- Use color coding to make injection obvious
|
||||||
|
- Provide immediate feedback on each attempt
|
||||||
|
|
||||||
|
### Real-World Context
|
||||||
|
|
||||||
|
**OWASP Ranking:** #3 in OWASP Top 10 (Injection)
|
||||||
|
|
||||||
|
**Notable Incidents:**
|
||||||
|
- 2019: British Airways breach (380,000 transactions)
|
||||||
|
- 2020: Freepik SQL injection (8.3 million accounts)
|
||||||
|
- Ongoing: Automated scanning for vulnerable endpoints
|
||||||
|
|
||||||
|
**Industry Standards:**
|
||||||
|
- OWASP recommends parameterized queries
|
||||||
|
- PCI DSS requires SQL injection prevention
|
||||||
|
- ISO 27001 covers secure coding practices
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Browser-in-the-Browser (BitB) Attack
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
**Lesson Key:** `browser-in-browser-attack`
|
||||||
|
**Difficulty:** Advanced
|
||||||
|
**Duration:** 25 minutes
|
||||||
|
**Category:** Social Engineering / Advanced Phishing
|
||||||
|
|
||||||
|
### Learning Objectives
|
||||||
|
|
||||||
|
By the end of this lesson, participants will be able to:
|
||||||
|
- Understand Browser-in-the-Browser attack methodology
|
||||||
|
- Differentiate between real and fake browser windows
|
||||||
|
- Apply physical testing techniques to detect fake popups
|
||||||
|
- Recognize OAuth/SSO popup security implications
|
||||||
|
- Understand why password managers provide protection
|
||||||
|
|
||||||
|
### Content Structure
|
||||||
|
|
||||||
|
#### Steps:
|
||||||
|
|
||||||
|
1. **What is Browser-in-the-Browser?** (Content)
|
||||||
|
- Definition and attack mechanism
|
||||||
|
- Why it's effective (mimics trusted UI)
|
||||||
|
- Comparison to traditional phishing
|
||||||
|
- Historical context (2022 emergence)
|
||||||
|
|
||||||
|
2. **How the Attack Works** (Content)
|
||||||
|
- Traditional OAuth flow diagram
|
||||||
|
- BitB attack flow comparison
|
||||||
|
- Technical explanation (HTML/CSS fake browser)
|
||||||
|
- Visual deception techniques
|
||||||
|
|
||||||
|
3. **Interactive BitB Demo** (Interactive)
|
||||||
|
- **Component:** `BitBDemo`
|
||||||
|
- Side-by-side real vs fake comparison
|
||||||
|
- Interactive detection testing
|
||||||
|
- Real-world attack examples
|
||||||
|
|
||||||
|
4. **Question 1: Detection Indicators** (Multiple Choice)
|
||||||
|
- **Points:** 40 total
|
||||||
|
- **Correct Answers:**
|
||||||
|
- "Window cannot be dragged outside browser" (20 pts)
|
||||||
|
- "Right-click shows 'Inspect Element' on address bar" (20 pts)
|
||||||
|
- **Topic:** Physical behavior testing
|
||||||
|
|
||||||
|
5. **Detection Techniques** (Content)
|
||||||
|
- Drag window test (primary method)
|
||||||
|
- Address bar selectability check
|
||||||
|
- Right-click inspection test
|
||||||
|
- Pixel-perfect detail examination
|
||||||
|
- Taskbar appearance verification
|
||||||
|
- Browser extension usage
|
||||||
|
|
||||||
|
6. **Question 2: Safest Approach** (Single Choice)
|
||||||
|
- **Points:** 35 total
|
||||||
|
- **Correct Answer:** "Try to drag the popup outside browser window"
|
||||||
|
- **Topic:** Practical defense technique
|
||||||
|
|
||||||
|
7. **Protecting Against BitB** (Content)
|
||||||
|
- User defenses (testing, 2FA, manual navigation)
|
||||||
|
- Developer responsibilities (education, redirect flow)
|
||||||
|
- Organizational measures (training, hardware keys)
|
||||||
|
|
||||||
|
8. **Question 3: Password Manager Protection** (Free Text)
|
||||||
|
- **Points:** 25 total
|
||||||
|
- **Validation:** Must mention "domain", "autofill", and "real"
|
||||||
|
- **Minimum Length:** 40 characters
|
||||||
|
- **Topic:** Technical security controls
|
||||||
|
|
||||||
|
### Interactive Component: BitBDemo
|
||||||
|
|
||||||
|
#### Features:
|
||||||
|
|
||||||
|
**Two Scenarios:**
|
||||||
|
|
||||||
|
1. **Legitimate OAuth Popup (Simulated Real)**
|
||||||
|
- Provider: Google
|
||||||
|
- Domain: `accounts.google.com`
|
||||||
|
- Indicators shown for educational purposes
|
||||||
|
- Green "✅ REAL" badge
|
||||||
|
|
||||||
|
2. **BitB Attack (Fake Popup)**
|
||||||
|
- Provider: Microsoft
|
||||||
|
- Domain: `login.microsoftonline.com`
|
||||||
|
- Trapped within page boundaries
|
||||||
|
- Red "⚠️ FAKE" badge
|
||||||
|
|
||||||
|
**Interactive Tests:**
|
||||||
|
|
||||||
|
1. **Drag Test:**
|
||||||
|
- Real: Would allow dragging (simulated)
|
||||||
|
- Fake: Cannot drag outside browser
|
||||||
|
- Feedback: Yellow warning appears when attempted
|
||||||
|
|
||||||
|
2. **Right-Click Test:**
|
||||||
|
- Real: Browser context menu (simulated)
|
||||||
|
- Fake: Shows "Inspect Element" menu
|
||||||
|
- Feedback: Warning about HTML detection
|
||||||
|
|
||||||
|
3. **Visual Inspection:**
|
||||||
|
- Fake window controls (non-functional buttons)
|
||||||
|
- Fake address bar (styled HTML div)
|
||||||
|
- Fake HTTPS lock icon (just an image)
|
||||||
|
|
||||||
|
**UI Elements:**
|
||||||
|
- Side-by-side scenario cards
|
||||||
|
- Provider-specific styling (Google blue, Microsoft blue)
|
||||||
|
- Launch buttons to open fake popups
|
||||||
|
- Dark overlay when popup is active
|
||||||
|
- Educational indicators list
|
||||||
|
- Real-world attack timeline
|
||||||
|
- Test instructions panel
|
||||||
|
|
||||||
|
**Realistic Browser Chrome:**
|
||||||
|
```
|
||||||
|
- macOS-style window controls (red, yellow, green)
|
||||||
|
- Address bar with lock icon
|
||||||
|
- Provider-specific branding
|
||||||
|
- Login form (email + password)
|
||||||
|
- Sign-in button
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Technical Implementation:
|
||||||
|
|
||||||
|
**Frontend Structure:**
|
||||||
|
```javascript
|
||||||
|
renderFakeBrowser(scenario)
|
||||||
|
- Creates modal overlay
|
||||||
|
- Renders fake browser window
|
||||||
|
- Applies provider styling
|
||||||
|
- Handles drag attempts
|
||||||
|
- Handles right-click detection
|
||||||
|
- Shows feedback badges
|
||||||
|
```
|
||||||
|
|
||||||
|
**Drag Detection:**
|
||||||
|
```javascript
|
||||||
|
handleDragStart(e, isReal)
|
||||||
|
- Sets dragAttempted flag
|
||||||
|
- Prevents drag if fake (e.preventDefault)
|
||||||
|
- Shows educational feedback
|
||||||
|
```
|
||||||
|
|
||||||
|
**Right-Click Detection:**
|
||||||
|
```javascript
|
||||||
|
handleAddressBarRightClick(e, isReal)
|
||||||
|
- Sets inspectAttempted flag
|
||||||
|
- Allows context menu on fake popup
|
||||||
|
- Shows educational feedback
|
||||||
|
```
|
||||||
|
|
||||||
|
**Real-World Examples Data:**
|
||||||
|
```javascript
|
||||||
|
[
|
||||||
|
{ year: 2022, target: 'Corporate employees', provider: 'Microsoft OAuth' },
|
||||||
|
{ year: 2022, target: 'Cryptocurrency users', provider: 'Google Sign-in' },
|
||||||
|
{ year: 2023, target: 'GitHub developers', provider: 'GitHub OAuth' }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scoring
|
||||||
|
|
||||||
|
- **Total Points:** 100
|
||||||
|
- **Passing Score:** 75%
|
||||||
|
- **Question Distribution:**
|
||||||
|
- Multiple choice: 40 points (physical testing)
|
||||||
|
- Single choice: 35 points (best practice)
|
||||||
|
- Free text: 25 points (technical understanding)
|
||||||
|
|
||||||
|
### Implementation Details
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Config: `backend/lessons/configs/browser-in-browser-attack.yaml`
|
||||||
|
- Module: `backend/lessons/modules/browser-in-browser-attack/index.js`
|
||||||
|
- Component: `frontend/src/components/lessons/InteractiveContent/BitBDemo.jsx`
|
||||||
|
|
||||||
|
**Dependencies:**
|
||||||
|
- Extends `LessonModule` base class
|
||||||
|
- Custom `getInteractiveData` method
|
||||||
|
- React state management for popups
|
||||||
|
- CSS-in-JS for fake browser styling
|
||||||
|
|
||||||
|
**Special Features:**
|
||||||
|
- Modal overlay system
|
||||||
|
- Drag prevention
|
||||||
|
- Context menu detection
|
||||||
|
- Provider theming
|
||||||
|
- Responsive design
|
||||||
|
|
||||||
|
### Best Practices for Teaching
|
||||||
|
|
||||||
|
- Emphasize that visual inspection alone is insufficient
|
||||||
|
- Demonstrate the drag test as the most reliable method
|
||||||
|
- Show how convincing the fake popups can be
|
||||||
|
- Discuss password manager benefits
|
||||||
|
- Explain why manual navigation is safest
|
||||||
|
- Reference real-world incidents
|
||||||
|
- Practice detection multiple times
|
||||||
|
- Warn about new variations
|
||||||
|
|
||||||
|
### Real-World Context
|
||||||
|
|
||||||
|
**Discovery:** 2022 by security researcher mr.d0x
|
||||||
|
|
||||||
|
**Attack Campaign Examples:**
|
||||||
|
- **March 2022:** Steam account phishing
|
||||||
|
- **April 2022:** Cryptocurrency exchange targeting
|
||||||
|
- **May 2022:** Corporate credential harvesting
|
||||||
|
- **2023:** GitHub and GitLab developer targeting
|
||||||
|
|
||||||
|
**Affected Platforms:**
|
||||||
|
- Any OAuth/SSO provider (Google, Microsoft, Facebook, GitHub)
|
||||||
|
- Banking sites with "secure" login popups
|
||||||
|
- Enterprise SSO systems
|
||||||
|
- Cryptocurrency wallets
|
||||||
|
|
||||||
|
**Why It's Effective:**
|
||||||
|
- Mimics trusted UI perfectly
|
||||||
|
- Bypasses traditional phishing training
|
||||||
|
- Works on security-aware users
|
||||||
|
- No browser warnings triggered
|
||||||
|
- HTTPS indicators can be faked
|
||||||
|
|
||||||
|
**Defense Evolution:**
|
||||||
|
- Hardware security keys (FIDO2/WebAuthn) immune
|
||||||
|
- Password managers check real domain
|
||||||
|
- Browser extensions can detect fake UI
|
||||||
|
- User education most critical
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Creating New Lessons
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
This section provides guidance for creating new lessons in the platform.
|
||||||
|
|
||||||
|
### Lesson Structure
|
||||||
|
|
||||||
|
Every lesson consists of two main components:
|
||||||
|
|
||||||
|
1. **YAML Configuration File** (`lessons/configs/*.yaml`)
|
||||||
|
- Defines lesson metadata
|
||||||
|
- Structures content steps
|
||||||
|
- Configures questions and answers
|
||||||
|
- Sets scoring rules
|
||||||
|
|
||||||
|
2. **JavaScript Module** (`lessons/modules/*/index.js`)
|
||||||
|
- Extends `LessonModule` base class
|
||||||
|
- Implements custom validation logic
|
||||||
|
- Provides interactive data
|
||||||
|
- Handles special behaviors
|
||||||
|
|
||||||
|
### YAML Configuration Format
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
lessonKey: "unique-lesson-identifier"
|
||||||
|
title: "Lesson Display Title"
|
||||||
|
description: "Brief description for lesson catalog"
|
||||||
|
difficultyLevel: "beginner|intermediate|advanced"
|
||||||
|
estimatedDuration: 15 # minutes
|
||||||
|
module: "module-directory-name"
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- id: "step-1"
|
||||||
|
type: "content|question|interactive"
|
||||||
|
title: "Step Title"
|
||||||
|
content: "Step content (can be multiline)"
|
||||||
|
|
||||||
|
- id: "question-1"
|
||||||
|
type: "question"
|
||||||
|
questionType: "single_choice|multiple_choice|free_text"
|
||||||
|
question: "The question text?"
|
||||||
|
options: # for choice questions
|
||||||
|
- id: "option-1"
|
||||||
|
text: "Option text"
|
||||||
|
isCorrect: true|false
|
||||||
|
points: 10
|
||||||
|
validationRules: # for free_text questions
|
||||||
|
keywords:
|
||||||
|
required: ["keyword1", "keyword2"]
|
||||||
|
partialCredit: 5
|
||||||
|
minLength: 50
|
||||||
|
maxPoints: 25
|
||||||
|
feedback:
|
||||||
|
correct: "Positive feedback"
|
||||||
|
incorrect: "Educational feedback"
|
||||||
|
|
||||||
|
scoring:
|
||||||
|
passingScore: 70
|
||||||
|
maxTotalPoints: 100
|
||||||
|
```
|
||||||
|
|
||||||
|
### JavaScript Module Structure
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const LessonModule = require('../base/LessonModule');
|
||||||
|
|
||||||
|
class YourLessonModule extends LessonModule {
|
||||||
|
constructor(config) {
|
||||||
|
super(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: Custom answer validation
|
||||||
|
async validateAnswer(questionId, answer) {
|
||||||
|
// Custom logic here, or use:
|
||||||
|
return super.validateAnswer(questionId, answer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: Provide data for interactive components
|
||||||
|
getInteractiveData(stepId) {
|
||||||
|
if (stepId === 'your-interactive-step') {
|
||||||
|
return {
|
||||||
|
// Data your frontend component needs
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: Custom methods for lesson-specific actions
|
||||||
|
yourCustomMethod(params) {
|
||||||
|
// Implementation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = YourLessonModule;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Question Types
|
||||||
|
|
||||||
|
#### 1. Single Choice
|
||||||
|
```yaml
|
||||||
|
questionType: "single_choice"
|
||||||
|
options:
|
||||||
|
- id: "correct-option"
|
||||||
|
text: "The right answer"
|
||||||
|
isCorrect: true
|
||||||
|
points: 25
|
||||||
|
- id: "wrong-option"
|
||||||
|
text: "An incorrect answer"
|
||||||
|
isCorrect: false
|
||||||
|
points: 0
|
||||||
|
```
|
||||||
|
- Only one correct answer
|
||||||
|
- All-or-nothing scoring
|
||||||
|
- Radio button UI
|
||||||
|
|
||||||
|
#### 2. Multiple Choice
|
||||||
|
```yaml
|
||||||
|
questionType: "multiple_choice"
|
||||||
|
options:
|
||||||
|
- id: "correct-1"
|
||||||
|
text: "First correct answer"
|
||||||
|
isCorrect: true
|
||||||
|
points: 15
|
||||||
|
- id: "correct-2"
|
||||||
|
text: "Second correct answer"
|
||||||
|
isCorrect: true
|
||||||
|
points: 15
|
||||||
|
- id: "wrong-1"
|
||||||
|
text: "Incorrect answer"
|
||||||
|
isCorrect: false
|
||||||
|
points: 0
|
||||||
|
```
|
||||||
|
- Multiple correct answers
|
||||||
|
- Partial credit awarded per correct selection
|
||||||
|
- Checkbox UI
|
||||||
|
|
||||||
|
#### 3. Free Text
|
||||||
|
```yaml
|
||||||
|
questionType: "free_text"
|
||||||
|
validationRules:
|
||||||
|
keywords:
|
||||||
|
required: ["must", "contain", "these"]
|
||||||
|
partialCredit: 10 # points if some keywords present
|
||||||
|
minLength: 50 # minimum character count
|
||||||
|
maxPoints: 25
|
||||||
|
```
|
||||||
|
- Open-ended response
|
||||||
|
- Keyword-based validation
|
||||||
|
- Minimum length requirement
|
||||||
|
|
||||||
|
### Interactive Components
|
||||||
|
|
||||||
|
#### Creating a New Interactive Component
|
||||||
|
|
||||||
|
1. **Define in YAML:**
|
||||||
|
```yaml
|
||||||
|
- id: "interactive-demo"
|
||||||
|
type: "interactive"
|
||||||
|
title: "Interactive Demo"
|
||||||
|
interactiveComponent: "YourComponentName"
|
||||||
|
content: "Instructions for the interactive element"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Provide Data in Module:**
|
||||||
|
```javascript
|
||||||
|
getInteractiveData(stepId) {
|
||||||
|
if (stepId === 'interactive-demo') {
|
||||||
|
return {
|
||||||
|
data: 'Your component data',
|
||||||
|
config: {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Create React Component:**
|
||||||
|
```javascript
|
||||||
|
// frontend/src/components/lessons/InteractiveContent/YourComponent.jsx
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const YourComponent = ({ lessonData, eventLessonId }) => {
|
||||||
|
const interactiveData = lessonData?.interactiveData || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Your interactive UI */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default YourComponent;
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Register in LessonView:**
|
||||||
|
```javascript
|
||||||
|
// frontend/src/pages/LessonView.jsx
|
||||||
|
import YourComponent from '../components/lessons/InteractiveContent/YourComponent';
|
||||||
|
|
||||||
|
// In render:
|
||||||
|
{currentStep.interactiveComponent === 'YourComponent' && (
|
||||||
|
<YourComponent lessonData={lesson} eventLessonId={eventLessonId} />
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lesson-Specific Actions
|
||||||
|
|
||||||
|
For interactive components that need backend processing:
|
||||||
|
|
||||||
|
1. **Add Method to Module:**
|
||||||
|
```javascript
|
||||||
|
yourCustomAction(params) {
|
||||||
|
// Process params
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Handle in Controller:**
|
||||||
|
```javascript
|
||||||
|
// backend/src/controllers/lesson.controller.js
|
||||||
|
if (action === 'your-action' && lessonModule.yourCustomAction) {
|
||||||
|
result = lessonModule.yourCustomAction(actionData);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Call from Frontend:**
|
||||||
|
```javascript
|
||||||
|
participantAPI.executeLessonAction(
|
||||||
|
eventLessonId,
|
||||||
|
'your-action',
|
||||||
|
{ data }
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Seeding New Lessons
|
||||||
|
|
||||||
|
1. **Add to Catalog:**
|
||||||
|
```javascript
|
||||||
|
// backend/seed-lessons.js
|
||||||
|
const lessons = [
|
||||||
|
{
|
||||||
|
lesson_key: 'your-lesson-key',
|
||||||
|
title: 'Your Lesson Title',
|
||||||
|
description: 'Description',
|
||||||
|
module_path: 'your-module-directory',
|
||||||
|
config_path: 'your-config.yaml',
|
||||||
|
difficulty_level: 'intermediate',
|
||||||
|
estimated_duration: 20
|
||||||
|
}
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Run Seed Script:**
|
||||||
|
```bash
|
||||||
|
docker exec lernplattform_backend node seed-lessons.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### Best Practices
|
||||||
|
|
||||||
|
**Content Design:**
|
||||||
|
- Start with clear learning objectives
|
||||||
|
- Use progressive difficulty (easy → hard)
|
||||||
|
- Provide immediate feedback
|
||||||
|
- Include real-world examples
|
||||||
|
- Make it hands-on when possible
|
||||||
|
|
||||||
|
**Question Design:**
|
||||||
|
- Multiple choice: 2-4 options, avoid "all of the above"
|
||||||
|
- Free text: Clear validation criteria
|
||||||
|
- Feedback: Educational, not just correct/incorrect
|
||||||
|
- Points: Reflect question difficulty
|
||||||
|
|
||||||
|
**Interactive Elements:**
|
||||||
|
- Make them essential, not decorative
|
||||||
|
- Provide clear instructions
|
||||||
|
- Give immediate visual feedback
|
||||||
|
- Include educational explanations
|
||||||
|
- Allow experimentation
|
||||||
|
|
||||||
|
**Code Quality:**
|
||||||
|
- Follow existing patterns
|
||||||
|
- Handle errors gracefully
|
||||||
|
- Validate all inputs
|
||||||
|
- Comment complex logic
|
||||||
|
- Test thoroughly
|
||||||
|
|
||||||
|
**Security:**
|
||||||
|
- Never execute actual SQL
|
||||||
|
- Sandbox all demonstrations
|
||||||
|
- Validate on both frontend and backend
|
||||||
|
- Don't leak sensitive information
|
||||||
|
- Use appropriate warnings
|
||||||
|
|
||||||
|
### Testing New Lessons
|
||||||
|
|
||||||
|
1. **Lesson Content:**
|
||||||
|
- All steps render correctly
|
||||||
|
- Images/media load properly
|
||||||
|
- Text formatting is correct
|
||||||
|
|
||||||
|
2. **Questions:**
|
||||||
|
- Correct answers award proper points
|
||||||
|
- Wrong answers give appropriate feedback
|
||||||
|
- Partial credit calculates correctly
|
||||||
|
- Free text validation works
|
||||||
|
|
||||||
|
3. **Interactive Components:**
|
||||||
|
- Components load without errors
|
||||||
|
- Actions execute successfully
|
||||||
|
- Feedback displays correctly
|
||||||
|
- Edge cases handled
|
||||||
|
|
||||||
|
4. **Scoring:**
|
||||||
|
- Total points sum correctly
|
||||||
|
- Passing threshold works
|
||||||
|
- Score persists properly
|
||||||
|
- Leaderboard updates
|
||||||
|
|
||||||
|
5. **Progress:**
|
||||||
|
- Lesson marked as started
|
||||||
|
- Navigation works (prev/next)
|
||||||
|
- Completion triggers properly
|
||||||
|
- Locked lessons stay locked
|
||||||
|
|
||||||
|
### File Checklist
|
||||||
|
|
||||||
|
- [ ] YAML config created
|
||||||
|
- [ ] Module directory created
|
||||||
|
- [ ] Module class implemented
|
||||||
|
- [ ] Interactive components (if any) created
|
||||||
|
- [ ] Seed script updated
|
||||||
|
- [ ] Lesson seeded to database
|
||||||
|
- [ ] Tested in browser
|
||||||
|
- [ ] Documentation updated
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix
|
||||||
|
|
||||||
|
### Lesson Difficulty Guidelines
|
||||||
|
|
||||||
|
**Beginner:**
|
||||||
|
- Foundational concepts
|
||||||
|
- No prior security knowledge required
|
||||||
|
- 10-15 minute duration
|
||||||
|
- Basic terminology introduction
|
||||||
|
- Real-world relevance emphasized
|
||||||
|
|
||||||
|
**Intermediate:**
|
||||||
|
- Builds on basic security awareness
|
||||||
|
- Requires understanding of systems/networks
|
||||||
|
- 15-25 minute duration
|
||||||
|
- Hands-on demonstrations
|
||||||
|
- Technical explanations
|
||||||
|
|
||||||
|
**Advanced:**
|
||||||
|
- Assumes security knowledge
|
||||||
|
- Complex attack scenarios
|
||||||
|
- 20-30 minute duration
|
||||||
|
- In-depth technical details
|
||||||
|
- Sophisticated defenses
|
||||||
|
|
||||||
|
### Scoring Philosophy
|
||||||
|
|
||||||
|
- **Partial Credit:** Encourage learning, reward partial knowledge
|
||||||
|
- **Passing Score:** 70-75% allows mistakes while ensuring competency
|
||||||
|
- **Question Weight:** Harder questions = more points
|
||||||
|
- **Immediate Feedback:** Don't wait until end of lesson
|
||||||
|
|
||||||
|
### Content Style Guide
|
||||||
|
|
||||||
|
**Tone:**
|
||||||
|
- Professional but approachable
|
||||||
|
- Educational, not preachy
|
||||||
|
- Objective about risks
|
||||||
|
- Encouraging about defenses
|
||||||
|
|
||||||
|
**Formatting:**
|
||||||
|
- Use bullet points for lists
|
||||||
|
- Bold important terms on first use
|
||||||
|
- Code blocks for technical content
|
||||||
|
- Clear step-by-step instructions
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
- Prefer real-world incidents
|
||||||
|
- Include dates and sources
|
||||||
|
- Show impact (financial, reputational)
|
||||||
|
- Demonstrate both attacks and defenses
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For questions about lesson development:
|
||||||
|
- Review existing lessons as templates
|
||||||
|
- Check base class methods in `LessonModule.js`
|
||||||
|
- Test in development environment first
|
||||||
|
- Document any new patterns
|
||||||
|
|
||||||
|
**Last Updated:** 2026-01-12
|
||||||
|
**Platform Version:** 1.0.0
|
||||||
|
**Total Lessons:** 3
|
||||||
310
backend/lessons/README.md
Normal file
310
backend/lessons/README.md
Normal file
@ -0,0 +1,310 @@
|
|||||||
|
# Lessons Directory
|
||||||
|
|
||||||
|
This directory contains all lesson content for the security awareness training platform.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
lessons/
|
||||||
|
├── configs/ # YAML lesson configurations
|
||||||
|
│ ├── phishing-email-basics.yaml
|
||||||
|
│ ├── sql-injection-shop.yaml
|
||||||
|
│ └── browser-in-browser-attack.yaml
|
||||||
|
├── modules/ # JavaScript lesson modules
|
||||||
|
│ ├── base/
|
||||||
|
│ │ └── LessonModule.js # Base class all lessons extend
|
||||||
|
│ ├── phishing-email-basics/
|
||||||
|
│ │ └── index.js
|
||||||
|
│ ├── sql-injection-shop/
|
||||||
|
│ │ └── index.js
|
||||||
|
│ └── browser-in-browser-attack/
|
||||||
|
│ └── index.js
|
||||||
|
├── lesson-schema.json # JSON schema for validation (optional)
|
||||||
|
├── README.md # This file
|
||||||
|
└── LESSONS_DOCUMENTATION.md # Comprehensive lesson docs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Available Lessons
|
||||||
|
|
||||||
|
### 1. Phishing Email Detection Basics
|
||||||
|
- **Key:** `phishing-email-basics`
|
||||||
|
- **Difficulty:** Beginner
|
||||||
|
- **Duration:** 15 minutes
|
||||||
|
- **Topics:** Email security, social engineering, red flags
|
||||||
|
- **Interactive:** No
|
||||||
|
|
||||||
|
### 2. SQL Injection Attack - Online Shop Demo
|
||||||
|
- **Key:** `sql-injection-shop`
|
||||||
|
- **Difficulty:** Intermediate
|
||||||
|
- **Duration:** 20 minutes
|
||||||
|
- **Topics:** Web security, OWASP Top 10, SQL injection
|
||||||
|
- **Interactive:** Yes - Fake shop with vulnerable search
|
||||||
|
|
||||||
|
### 3. Browser-in-the-Browser (BitB) Attack
|
||||||
|
- **Key:** `browser-in-browser-attack`
|
||||||
|
- **Difficulty:** Advanced
|
||||||
|
- **Duration:** 25 minutes
|
||||||
|
- **Topics:** Advanced phishing, OAuth security, UI spoofing
|
||||||
|
- **Interactive:** Yes - Fake browser popup demos
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Adding a New Lesson
|
||||||
|
|
||||||
|
1. **Create YAML config:**
|
||||||
|
```bash
|
||||||
|
cp configs/phishing-email-basics.yaml configs/your-lesson.yaml
|
||||||
|
# Edit the file with your lesson content
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Create module:**
|
||||||
|
```bash
|
||||||
|
mkdir modules/your-lesson
|
||||||
|
# Create index.js extending LessonModule
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Seed to database:**
|
||||||
|
```bash
|
||||||
|
# Add to seed script
|
||||||
|
docker exec lernplattform_backend node seed-lessons.js
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Assign to event:**
|
||||||
|
- Use admin panel to assign lesson to events
|
||||||
|
- Configure points, weight, and order
|
||||||
|
|
||||||
|
### Testing a Lesson
|
||||||
|
|
||||||
|
1. **Seed the lesson** (see above)
|
||||||
|
2. **Assign to a test event** via admin panel
|
||||||
|
3. **Join event as participant** from hub page
|
||||||
|
4. **Complete the lesson** and verify:
|
||||||
|
- All steps render correctly
|
||||||
|
- Questions award proper points
|
||||||
|
- Interactive components work
|
||||||
|
- Score calculates correctly
|
||||||
|
|
||||||
|
## Lesson Configuration Format
|
||||||
|
|
||||||
|
### Minimal YAML Example
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
lessonKey: "my-lesson"
|
||||||
|
title: "My Lesson Title"
|
||||||
|
description: "Brief description"
|
||||||
|
difficultyLevel: "beginner"
|
||||||
|
estimatedDuration: 15
|
||||||
|
module: "my-lesson"
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- id: "intro"
|
||||||
|
type: "content"
|
||||||
|
title: "Introduction"
|
||||||
|
content: "Lesson content here..."
|
||||||
|
|
||||||
|
- id: "q1"
|
||||||
|
type: "question"
|
||||||
|
questionType: "single_choice"
|
||||||
|
question: "What is the answer?"
|
||||||
|
options:
|
||||||
|
- id: "correct"
|
||||||
|
text: "The right answer"
|
||||||
|
isCorrect: true
|
||||||
|
points: 100
|
||||||
|
- id: "wrong"
|
||||||
|
text: "Wrong answer"
|
||||||
|
isCorrect: false
|
||||||
|
points: 0
|
||||||
|
maxPoints: 100
|
||||||
|
feedback:
|
||||||
|
correct: "Great job!"
|
||||||
|
incorrect: "Try again!"
|
||||||
|
|
||||||
|
scoring:
|
||||||
|
passingScore: 70
|
||||||
|
maxTotalPoints: 100
|
||||||
|
```
|
||||||
|
|
||||||
|
### Minimal Module Example
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const LessonModule = require('../base/LessonModule');
|
||||||
|
|
||||||
|
class MyLesson extends LessonModule {
|
||||||
|
constructor(config) {
|
||||||
|
super(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use base class validation by default
|
||||||
|
// Override only if custom logic needed
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = MyLesson;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Question Types
|
||||||
|
|
||||||
|
### Single Choice
|
||||||
|
- One correct answer
|
||||||
|
- Radio buttons in UI
|
||||||
|
- All-or-nothing scoring
|
||||||
|
|
||||||
|
### Multiple Choice
|
||||||
|
- Multiple correct answers
|
||||||
|
- Checkboxes in UI
|
||||||
|
- Partial credit per correct selection
|
||||||
|
|
||||||
|
### Free Text
|
||||||
|
- Open-ended response
|
||||||
|
- Keyword-based validation
|
||||||
|
- Minimum length requirement
|
||||||
|
|
||||||
|
## Interactive Components
|
||||||
|
|
||||||
|
For lessons with interactive demos:
|
||||||
|
|
||||||
|
1. **Define in YAML:**
|
||||||
|
```yaml
|
||||||
|
- id: "demo"
|
||||||
|
type: "interactive"
|
||||||
|
title: "Interactive Demo"
|
||||||
|
interactiveComponent: "MyComponent"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Provide data in module:**
|
||||||
|
```javascript
|
||||||
|
getInteractiveData(stepId) {
|
||||||
|
if (stepId === 'demo') {
|
||||||
|
return { /* component data */ };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Create React component:**
|
||||||
|
```
|
||||||
|
frontend/src/components/lessons/InteractiveContent/MyComponent.jsx
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Register in LessonView.jsx**
|
||||||
|
|
||||||
|
## File Naming Conventions
|
||||||
|
|
||||||
|
- **Lesson keys:** lowercase-with-hyphens
|
||||||
|
- **Config files:** `{lesson-key}.yaml`
|
||||||
|
- **Module directories:** `{lesson-key}/`
|
||||||
|
- **Module entry:** `index.js`
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### Content
|
||||||
|
- Start with clear learning objectives
|
||||||
|
- Use real-world examples
|
||||||
|
- Progress from easy to hard
|
||||||
|
- Provide immediate feedback
|
||||||
|
- Make it hands-on when possible
|
||||||
|
|
||||||
|
### Questions
|
||||||
|
- 2-4 options for choice questions
|
||||||
|
- Clear, unambiguous wording
|
||||||
|
- Educational feedback (not just "correct"/"incorrect")
|
||||||
|
- Points reflect difficulty
|
||||||
|
|
||||||
|
### Code
|
||||||
|
- Extend `LessonModule` base class
|
||||||
|
- Use base class methods when possible
|
||||||
|
- Comment complex logic
|
||||||
|
- Handle errors gracefully
|
||||||
|
- Validate all inputs
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Never execute actual dangerous commands
|
||||||
|
- Sandbox all demonstrations
|
||||||
|
- Use appropriate warnings
|
||||||
|
- Don't leak sensitive data
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Multi-step Content Lesson
|
||||||
|
```yaml
|
||||||
|
steps:
|
||||||
|
- type: content (intro)
|
||||||
|
- type: content (main content)
|
||||||
|
- type: question
|
||||||
|
- type: content (summary)
|
||||||
|
- type: question
|
||||||
|
```
|
||||||
|
|
||||||
|
### Interactive Demo Lesson
|
||||||
|
```yaml
|
||||||
|
steps:
|
||||||
|
- type: content (intro)
|
||||||
|
- type: interactive (hands-on)
|
||||||
|
- type: question (about demo)
|
||||||
|
- type: content (explanation)
|
||||||
|
- type: question (best practices)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Progressive Learning
|
||||||
|
```yaml
|
||||||
|
# Start easy
|
||||||
|
- Single choice with obvious answer
|
||||||
|
# Build complexity
|
||||||
|
- Multiple choice with several correct answers
|
||||||
|
# Test understanding
|
||||||
|
- Free text requiring explanation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Lesson not appearing in admin panel
|
||||||
|
- Check if lesson was seeded to database
|
||||||
|
- Verify `lesson_key` matches between YAML and database
|
||||||
|
- Check database logs for errors
|
||||||
|
|
||||||
|
### Questions not scoring correctly
|
||||||
|
- Verify `maxPoints` matches sum of correct option points
|
||||||
|
- For multiple choice, ensure points are on correct options
|
||||||
|
- Check `isCorrect` boolean values
|
||||||
|
|
||||||
|
### Interactive component not loading
|
||||||
|
- Verify component name matches in YAML
|
||||||
|
- Check component is imported in LessonView.jsx
|
||||||
|
- Look for console errors in browser
|
||||||
|
- Verify `getInteractiveData()` returns data
|
||||||
|
|
||||||
|
### Module not found errors
|
||||||
|
- Check `module_path` in database matches directory name
|
||||||
|
- Verify `index.js` exists in module directory
|
||||||
|
- Ensure `module.exports` is present
|
||||||
|
- Check for syntax errors in module
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- **Comprehensive Guide:** [LESSONS_DOCUMENTATION.md](./LESSONS_DOCUMENTATION.md)
|
||||||
|
- **Base Class:** [modules/base/LessonModule.js](./modules/base/LessonModule.js)
|
||||||
|
- **Examples:** See existing lessons in `configs/` and `modules/`
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
1. **Design** lesson content and questions
|
||||||
|
2. **Create** YAML config and module
|
||||||
|
3. **Test** locally with seed script
|
||||||
|
4. **Assign** to test event
|
||||||
|
5. **Validate** all questions and interactions
|
||||||
|
6. **Review** scoring and feedback
|
||||||
|
7. **Deploy** to production event
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For questions or issues:
|
||||||
|
1. Review existing lesson implementations
|
||||||
|
2. Check base class documentation
|
||||||
|
3. Test in development environment first
|
||||||
|
4. Review error logs for debugging
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated:** 2026-01-12
|
||||||
|
**Total Lessons:** 3
|
||||||
|
**Platform Version:** 1.0.0
|
||||||
174
backend/lessons/configs/browser-in-browser-attack.yaml
Normal file
174
backend/lessons/configs/browser-in-browser-attack.yaml
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
lessonKey: "browser-in-browser-attack"
|
||||||
|
title: "Browser-in-the-Browser (BitB) Attack"
|
||||||
|
description: "Learn to identify sophisticated phishing attacks that mimic legitimate browser windows"
|
||||||
|
difficultyLevel: "advanced"
|
||||||
|
estimatedDuration: 25
|
||||||
|
module: "browser-in-browser-attack"
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- id: "intro"
|
||||||
|
type: "content"
|
||||||
|
title: "What is Browser-in-the-Browser?"
|
||||||
|
content: |
|
||||||
|
Browser-in-the-Browser (BitB) is an advanced phishing technique that creates a fake browser window inside a webpage. It's designed to trick users into thinking they're interacting with a legitimate OAuth/SSO login popup.
|
||||||
|
|
||||||
|
Why it's dangerous:
|
||||||
|
• Looks identical to real browser popup windows
|
||||||
|
• Shows a fake address bar with HTTPS lock icon
|
||||||
|
• Mimics trusted services (Google, Microsoft, Facebook)
|
||||||
|
• Can steal credentials even from security-aware users
|
||||||
|
• Bypasses traditional phishing detection
|
||||||
|
|
||||||
|
This attack gained prominence in 2022 and has been used in targeted attacks against organizations.
|
||||||
|
|
||||||
|
- id: "how-it-works"
|
||||||
|
type: "content"
|
||||||
|
title: "How the Attack Works"
|
||||||
|
content: |
|
||||||
|
Traditional OAuth Flow:
|
||||||
|
1. User clicks "Sign in with Google" on a website
|
||||||
|
2. Browser opens a REAL popup to google.com
|
||||||
|
3. User enters credentials on Google's actual site
|
||||||
|
4. Google redirects back with authentication token
|
||||||
|
|
||||||
|
BitB Attack Flow:
|
||||||
|
1. User clicks "Sign in with Google" on malicious site
|
||||||
|
2. Site creates a FAKE popup using HTML/CSS/JavaScript
|
||||||
|
3. Fake popup shows fake address bar displaying "accounts.google.com"
|
||||||
|
4. User enters credentials on attacker's fake page
|
||||||
|
5. Attacker captures credentials and simulates success
|
||||||
|
|
||||||
|
The entire "browser window" is actually just HTML elements styled to look like a browser!
|
||||||
|
|
||||||
|
- id: "bitb-demo"
|
||||||
|
type: "interactive"
|
||||||
|
title: "Interactive BitB Demo"
|
||||||
|
interactiveComponent: "BitBDemo"
|
||||||
|
content: |
|
||||||
|
Below you'll see two login scenarios. One uses a REAL browser popup (secure), and one uses a BitB attack (malicious).
|
||||||
|
|
||||||
|
Can you identify the fake? Pay close attention to the details!
|
||||||
|
|
||||||
|
- id: "question-1"
|
||||||
|
type: "question"
|
||||||
|
questionType: "multiple_choice"
|
||||||
|
question: "What are the key indicators that can help identify a Browser-in-the-Browser attack?"
|
||||||
|
options:
|
||||||
|
- id: "https-lock"
|
||||||
|
text: "The presence of HTTPS and a lock icon in the address bar"
|
||||||
|
isCorrect: false
|
||||||
|
points: 0
|
||||||
|
- id: "window-behavior"
|
||||||
|
text: "The popup window cannot be dragged outside the main browser window"
|
||||||
|
isCorrect: true
|
||||||
|
points: 20
|
||||||
|
- id: "inspect-element"
|
||||||
|
text: "Right-clicking allows you to 'Inspect Element' on the address bar"
|
||||||
|
isCorrect: true
|
||||||
|
points: 20
|
||||||
|
- id: "domain-name"
|
||||||
|
text: "The domain name shown in the address bar"
|
||||||
|
isCorrect: false
|
||||||
|
points: 0
|
||||||
|
maxPoints: 40
|
||||||
|
feedback:
|
||||||
|
correct: "Excellent! Real browser windows can be moved anywhere and their UI cannot be inspected as HTML elements."
|
||||||
|
incorrect: "Think about what differentiates a real browser window from HTML/CSS elements on a webpage. The lock icon and domain can both be faked!"
|
||||||
|
|
||||||
|
- id: "detection-techniques"
|
||||||
|
type: "content"
|
||||||
|
title: "Detecting BitB Attacks"
|
||||||
|
content: |
|
||||||
|
How to spot a Browser-in-the-Browser attack:
|
||||||
|
|
||||||
|
1. **Try to Drag the Window**
|
||||||
|
• Real popups can be dragged outside the browser
|
||||||
|
• Fake popups are trapped within the main window
|
||||||
|
|
||||||
|
2. **Check if Address Bar is Selectable**
|
||||||
|
• Real address bars: text is selectable
|
||||||
|
• Fake address bars: usually just an image or styled div
|
||||||
|
|
||||||
|
3. **Right-Click the Address Bar**
|
||||||
|
• Real browser: no "Inspect Element" option
|
||||||
|
• Fake browser: shows HTML inspection menu
|
||||||
|
|
||||||
|
4. **Look for Pixel-Perfect Details**
|
||||||
|
• Fake windows may have slight styling differences
|
||||||
|
• Shadow effects, fonts, or spacing might be off
|
||||||
|
|
||||||
|
5. **Check Your Browser's Task Bar**
|
||||||
|
• Real popups appear as separate windows in taskbar
|
||||||
|
• Fake popups don't create new window entries
|
||||||
|
|
||||||
|
6. **Use Browser Extensions**
|
||||||
|
• Some extensions can detect fake browser UI
|
||||||
|
|
||||||
|
- id: "question-2"
|
||||||
|
type: "question"
|
||||||
|
questionType: "single_choice"
|
||||||
|
question: "A website asks you to 'Sign in with Microsoft' and a popup appears. What is the SAFEST approach?"
|
||||||
|
options:
|
||||||
|
- id: "trust-https"
|
||||||
|
text: "Check for HTTPS in the address bar and proceed if present"
|
||||||
|
isCorrect: false
|
||||||
|
points: 0
|
||||||
|
- id: "test-window"
|
||||||
|
text: "Try to drag the popup outside the browser window to verify it's real"
|
||||||
|
isCorrect: true
|
||||||
|
points: 35
|
||||||
|
- id: "check-domain"
|
||||||
|
text: "Carefully read the domain name to ensure it's Microsoft's real domain"
|
||||||
|
isCorrect: false
|
||||||
|
points: 0
|
||||||
|
- id: "close-and-manual"
|
||||||
|
text: "Close the popup and manually navigate to Microsoft's site"
|
||||||
|
isCorrect: false
|
||||||
|
points: 10
|
||||||
|
maxPoints: 35
|
||||||
|
feedback:
|
||||||
|
correct: "Perfect! Testing if the window can be dragged outside the browser is the most reliable quick check. Though manually navigating is also very safe!"
|
||||||
|
incorrect: "While checking the domain helps, it can be faked in a BitB attack. The physical behavior of the window (can it be dragged out?) reveals the truth."
|
||||||
|
|
||||||
|
- id: "prevention"
|
||||||
|
type: "content"
|
||||||
|
title: "Protecting Against BitB Attacks"
|
||||||
|
content: |
|
||||||
|
For Users:
|
||||||
|
• Always test if popup windows can be moved freely
|
||||||
|
• Use password managers (they check actual domains)
|
||||||
|
• Enable 2FA/MFA for additional security layer
|
||||||
|
• Be suspicious of unexpected login prompts
|
||||||
|
• Manually navigate to sites instead of clicking links
|
||||||
|
|
||||||
|
For Developers:
|
||||||
|
• Educate users about OAuth popup behavior
|
||||||
|
• Use OAuth redirect flow instead of popups when possible
|
||||||
|
• Implement additional verification steps
|
||||||
|
• Consider passwordless authentication methods
|
||||||
|
• Show clear security indicators in your app
|
||||||
|
|
||||||
|
For Organizations:
|
||||||
|
• Train employees to recognize advanced phishing
|
||||||
|
• Deploy anti-phishing browser extensions
|
||||||
|
• Use hardware security keys (FIDO2/WebAuthn)
|
||||||
|
• Monitor for suspicious authentication attempts
|
||||||
|
• Implement conditional access policies
|
||||||
|
|
||||||
|
- id: "question-3"
|
||||||
|
type: "question"
|
||||||
|
questionType: "free_text"
|
||||||
|
question: "Why are password managers particularly effective at protecting against BitB attacks?"
|
||||||
|
validationRules:
|
||||||
|
keywords:
|
||||||
|
required: ["domain", "autofill", "real"]
|
||||||
|
partialCredit: 8
|
||||||
|
minLength: 40
|
||||||
|
maxPoints: 25
|
||||||
|
feedback:
|
||||||
|
correct: "Excellent! Password managers check the actual domain of the page and won't autofill credentials on fake domains, even if they look legitimate."
|
||||||
|
incorrect: "Think about how password managers verify which site they're on before filling in credentials. They check the real URL, not what's displayed visually."
|
||||||
|
|
||||||
|
scoring:
|
||||||
|
passingScore: 75
|
||||||
|
maxTotalPoints: 100
|
||||||
117
backend/lessons/configs/phishing-email-basics.yaml
Normal file
117
backend/lessons/configs/phishing-email-basics.yaml
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
lessonKey: "phishing-email-basics"
|
||||||
|
title: "Phishing Email Detection Basics"
|
||||||
|
description: "Learn to identify common phishing tactics in emails and protect yourself from email-based attacks"
|
||||||
|
difficultyLevel: "beginner"
|
||||||
|
estimatedDuration: 15
|
||||||
|
module: "phishing-email-basics"
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- id: "intro"
|
||||||
|
type: "content"
|
||||||
|
title: "What is Phishing?"
|
||||||
|
content: |
|
||||||
|
Phishing is a type of cyber attack where attackers impersonate legitimate organizations
|
||||||
|
to steal sensitive information like passwords, credit card numbers, or personal data.
|
||||||
|
|
||||||
|
Phishing emails often:
|
||||||
|
- Create a sense of urgency
|
||||||
|
- Contain suspicious links or attachments
|
||||||
|
- Have spelling and grammar errors
|
||||||
|
- Use generic greetings like "Dear Customer"
|
||||||
|
- Request sensitive information
|
||||||
|
|
||||||
|
- id: "example-1"
|
||||||
|
type: "content"
|
||||||
|
title: "Example Phishing Email"
|
||||||
|
content: |
|
||||||
|
**From:** security@paypa1-verify.com
|
||||||
|
**Subject:** Urgent: Verify Your Account Now!
|
||||||
|
|
||||||
|
Dear Valued Customer,
|
||||||
|
|
||||||
|
Your PayPal account has been temporarily suspended due to unusual activity.
|
||||||
|
To restore your account, please verify your information immediately by clicking
|
||||||
|
the link below:
|
||||||
|
|
||||||
|
[Verify Account Now]
|
||||||
|
|
||||||
|
Failure to verify within 24 hours will result in permanent account suspension.
|
||||||
|
|
||||||
|
Thank you,
|
||||||
|
PayPal Security Team
|
||||||
|
|
||||||
|
- id: "question-1"
|
||||||
|
type: "question"
|
||||||
|
questionType: "multiple_choice"
|
||||||
|
question: "What are the suspicious elements in this email? (Select all that apply)"
|
||||||
|
options:
|
||||||
|
- id: "misspelled-domain"
|
||||||
|
text: "The sender's domain is misspelled (paypa1 instead of paypal)"
|
||||||
|
isCorrect: true
|
||||||
|
points: 15
|
||||||
|
- id: "urgent-language"
|
||||||
|
text: "Uses urgent/threatening language to create pressure"
|
||||||
|
isCorrect: true
|
||||||
|
points: 15
|
||||||
|
- id: "generic-greeting"
|
||||||
|
text: "Uses generic greeting 'Dear Valued Customer'"
|
||||||
|
isCorrect: true
|
||||||
|
points: 10
|
||||||
|
- id: "requests-action"
|
||||||
|
text: "Requests immediate action via a link"
|
||||||
|
isCorrect: true
|
||||||
|
points: 10
|
||||||
|
- id: "legitimate"
|
||||||
|
text: "This appears to be a legitimate email"
|
||||||
|
isCorrect: false
|
||||||
|
points: 0
|
||||||
|
maxPoints: 50
|
||||||
|
feedback:
|
||||||
|
correct: "Excellent! You identified all the key phishing indicators."
|
||||||
|
partial: "Good job! You spotted some red flags, but review the email again carefully."
|
||||||
|
incorrect: "Not quite. Let's review the common signs of phishing emails."
|
||||||
|
|
||||||
|
- id: "question-2"
|
||||||
|
type: "question"
|
||||||
|
questionType: "single_choice"
|
||||||
|
question: "What should you do if you receive a suspicious email like this?"
|
||||||
|
options:
|
||||||
|
- id: "click-link"
|
||||||
|
text: "Click the link to verify my account"
|
||||||
|
isCorrect: false
|
||||||
|
points: 0
|
||||||
|
- id: "reply-email"
|
||||||
|
text: "Reply to the email asking if it's legitimate"
|
||||||
|
isCorrect: false
|
||||||
|
points: 0
|
||||||
|
- id: "delete-report"
|
||||||
|
text: "Delete the email and report it as phishing"
|
||||||
|
isCorrect: true
|
||||||
|
points: 25
|
||||||
|
- id: "forward-friends"
|
||||||
|
text: "Forward it to friends to warn them"
|
||||||
|
isCorrect: false
|
||||||
|
points: 0
|
||||||
|
maxPoints: 25
|
||||||
|
feedback:
|
||||||
|
correct: "Perfect! Deleting and reporting phishing emails is the right approach."
|
||||||
|
incorrect: "That's not safe. Never click links or reply to suspicious emails. Delete and report them."
|
||||||
|
|
||||||
|
- id: "question-3"
|
||||||
|
type: "question"
|
||||||
|
questionType: "free_text"
|
||||||
|
question: "Describe at least three things you should check before clicking a link in an email."
|
||||||
|
validationRules:
|
||||||
|
- type: "contains_keywords"
|
||||||
|
keywords: ["sender", "domain", "url", "link", "https", "hover", "address", "spelling", "grammar"]
|
||||||
|
minMatches: 3
|
||||||
|
- type: "min_length"
|
||||||
|
value: 50
|
||||||
|
maxPoints: 25
|
||||||
|
feedback:
|
||||||
|
correct: "Great answer! You understand the importance of verifying emails before taking action."
|
||||||
|
incorrect: "Consider checking the sender's email address, hovering over links to see the real URL, and looking for HTTPS."
|
||||||
|
|
||||||
|
scoring:
|
||||||
|
passingScore: 70
|
||||||
|
maxTotalPoints: 100
|
||||||
143
backend/lessons/configs/sql-injection-shop.yaml
Normal file
143
backend/lessons/configs/sql-injection-shop.yaml
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
lessonKey: "sql-injection-shop"
|
||||||
|
title: "SQL Injection Attack - Online Shop Demo"
|
||||||
|
description: "Learn how SQL injection vulnerabilities work through a realistic online shop scenario"
|
||||||
|
difficultyLevel: "intermediate"
|
||||||
|
estimatedDuration: 20
|
||||||
|
module: "sql-injection-shop"
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- id: "intro"
|
||||||
|
type: "content"
|
||||||
|
title: "What is SQL Injection?"
|
||||||
|
content: |
|
||||||
|
SQL Injection is one of the most dangerous web application vulnerabilities. It occurs when an attacker can insert malicious SQL code into a query, allowing them to:
|
||||||
|
|
||||||
|
• Access unauthorized data
|
||||||
|
• Modify or delete database records
|
||||||
|
• Bypass authentication
|
||||||
|
• Execute administrative operations
|
||||||
|
|
||||||
|
In this lesson, you'll explore a vulnerable online shop to understand how SQL injection works and why proper input validation is critical.
|
||||||
|
|
||||||
|
- id: "shop-demo"
|
||||||
|
type: "interactive"
|
||||||
|
title: "Vulnerable Online Shop"
|
||||||
|
interactiveComponent: "SQLShopDemo"
|
||||||
|
content: |
|
||||||
|
Below is a simplified online shop with a product search feature. The search functionality is vulnerable to SQL injection.
|
||||||
|
|
||||||
|
Try searching for normal products first, then experiment with SQL injection techniques.
|
||||||
|
|
||||||
|
- id: "question-1"
|
||||||
|
type: "question"
|
||||||
|
questionType: "multiple_choice"
|
||||||
|
question: "Which of the following search inputs could be used to exploit SQL injection?"
|
||||||
|
options:
|
||||||
|
- id: "normal-search"
|
||||||
|
text: "laptop"
|
||||||
|
isCorrect: false
|
||||||
|
points: 0
|
||||||
|
- id: "single-quote"
|
||||||
|
text: "' OR '1'='1"
|
||||||
|
isCorrect: true
|
||||||
|
points: 15
|
||||||
|
- id: "union-select"
|
||||||
|
text: "' UNION SELECT username, password FROM users--"
|
||||||
|
isCorrect: true
|
||||||
|
points: 15
|
||||||
|
- id: "drop-table"
|
||||||
|
text: "'; DROP TABLE products--"
|
||||||
|
isCorrect: true
|
||||||
|
points: 10
|
||||||
|
maxPoints: 40
|
||||||
|
feedback:
|
||||||
|
correct: "Correct! These inputs manipulate the SQL query structure."
|
||||||
|
incorrect: "Review the demo. SQL injection exploits use special characters like quotes and SQL keywords."
|
||||||
|
|
||||||
|
- id: "detection"
|
||||||
|
type: "content"
|
||||||
|
title: "How SQL Injection Works"
|
||||||
|
content: |
|
||||||
|
A vulnerable query might look like:
|
||||||
|
|
||||||
|
SELECT * FROM products WHERE name LIKE '%[USER_INPUT]%'
|
||||||
|
|
||||||
|
When a user searches for "laptop", the query becomes:
|
||||||
|
SELECT * FROM products WHERE name LIKE '%laptop%'
|
||||||
|
|
||||||
|
But if they enter "' OR '1'='1", it becomes:
|
||||||
|
SELECT * FROM products WHERE name LIKE '%' OR '1'='1%'
|
||||||
|
|
||||||
|
The OR '1'='1' condition is always true, so ALL products are returned!
|
||||||
|
|
||||||
|
More dangerous attacks can extract data from other tables or even delete data.
|
||||||
|
|
||||||
|
- id: "question-2"
|
||||||
|
type: "question"
|
||||||
|
questionType: "single_choice"
|
||||||
|
question: "What is the BEST way to prevent SQL injection vulnerabilities?"
|
||||||
|
options:
|
||||||
|
- id: "input-filtering"
|
||||||
|
text: "Filter out dangerous characters like quotes and semicolons"
|
||||||
|
isCorrect: false
|
||||||
|
points: 0
|
||||||
|
- id: "parameterized-queries"
|
||||||
|
text: "Use parameterized queries (prepared statements)"
|
||||||
|
isCorrect: true
|
||||||
|
points: 30
|
||||||
|
- id: "stored-procedures"
|
||||||
|
text: "Only use stored procedures for database access"
|
||||||
|
isCorrect: false
|
||||||
|
points: 0
|
||||||
|
- id: "input-length"
|
||||||
|
text: "Limit the length of user inputs"
|
||||||
|
isCorrect: false
|
||||||
|
points: 0
|
||||||
|
maxPoints: 30
|
||||||
|
feedback:
|
||||||
|
correct: "Excellent! Parameterized queries separate SQL code from user data, making injection impossible."
|
||||||
|
incorrect: "While filtering helps, parameterized queries are the gold standard. They ensure user input is always treated as data, never as SQL code."
|
||||||
|
|
||||||
|
- id: "mitigation"
|
||||||
|
type: "content"
|
||||||
|
title: "Preventing SQL Injection"
|
||||||
|
content: |
|
||||||
|
Best practices to prevent SQL injection:
|
||||||
|
|
||||||
|
1. **Parameterized Queries** (Most Important)
|
||||||
|
• Use prepared statements with bound parameters
|
||||||
|
• Never concatenate user input into SQL strings
|
||||||
|
|
||||||
|
2. **Input Validation**
|
||||||
|
• Validate data types (numbers, emails, etc.)
|
||||||
|
• Use allowlists for expected values
|
||||||
|
|
||||||
|
3. **Least Privilege**
|
||||||
|
• Database accounts should have minimal permissions
|
||||||
|
• Read-only accounts for read operations
|
||||||
|
|
||||||
|
4. **Web Application Firewalls**
|
||||||
|
• Can detect and block SQL injection attempts
|
||||||
|
• Should be used as an additional layer, not primary defense
|
||||||
|
|
||||||
|
5. **Regular Security Audits**
|
||||||
|
• Code reviews and penetration testing
|
||||||
|
• Automated vulnerability scanning
|
||||||
|
|
||||||
|
- id: "question-3"
|
||||||
|
type: "question"
|
||||||
|
questionType: "free_text"
|
||||||
|
question: "In your own words, explain why parameterized queries prevent SQL injection."
|
||||||
|
validationRules:
|
||||||
|
keywords:
|
||||||
|
required: ["parameter", "data", "separate"]
|
||||||
|
partialCredit: 10
|
||||||
|
minLength: 50
|
||||||
|
maxPoints: 30
|
||||||
|
feedback:
|
||||||
|
correct: "Great explanation! You understand that parameterized queries keep SQL structure separate from user data."
|
||||||
|
incorrect: "Think about how parameterized queries treat user input differently than string concatenation. Key concepts: separation of code and data."
|
||||||
|
|
||||||
|
scoring:
|
||||||
|
passingScore: 70
|
||||||
|
maxTotalPoints: 100
|
||||||
230
backend/lessons/modules/base/LessonModule.js
Normal file
230
backend/lessons/modules/base/LessonModule.js
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
/**
|
||||||
|
* Base class for all lesson modules
|
||||||
|
* All lesson modules should extend this class
|
||||||
|
*/
|
||||||
|
class LessonModule {
|
||||||
|
constructor(config) {
|
||||||
|
this.config = config;
|
||||||
|
this.lessonKey = config.lessonKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate an answer for a specific question
|
||||||
|
* @param {string} questionId - The question identifier
|
||||||
|
* @param {any} answer - The participant's answer
|
||||||
|
* @returns {Object} { isCorrect, pointsAwarded, feedback }
|
||||||
|
*/
|
||||||
|
async validateAnswer(questionId, answer) {
|
||||||
|
const step = this.config.steps.find(s => s.id === questionId);
|
||||||
|
if (!step || step.type !== 'question') {
|
||||||
|
throw new Error(`Question ${questionId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this._validateQuestionType(step, answer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal validation based on question type
|
||||||
|
*/
|
||||||
|
_validateQuestionType(step, answer) {
|
||||||
|
switch (step.questionType) {
|
||||||
|
case 'single_choice':
|
||||||
|
return this._validateSingleChoice(step, answer);
|
||||||
|
case 'multiple_choice':
|
||||||
|
return this._validateMultipleChoice(step, answer);
|
||||||
|
case 'free_text':
|
||||||
|
return this._validateFreeText(step, answer);
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown question type: ${step.questionType}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_validateSingleChoice(step, answer) {
|
||||||
|
const selectedOption = step.options.find(opt => opt.id === answer);
|
||||||
|
if (!selectedOption) {
|
||||||
|
return {
|
||||||
|
isCorrect: false,
|
||||||
|
pointsAwarded: 0,
|
||||||
|
feedback: step.feedback?.incorrect || 'Incorrect answer'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isCorrect: selectedOption.isCorrect,
|
||||||
|
pointsAwarded: selectedOption.isCorrect ? selectedOption.points : 0,
|
||||||
|
feedback: selectedOption.isCorrect
|
||||||
|
? (step.feedback?.correct || 'Correct!')
|
||||||
|
: (step.feedback?.incorrect || 'Incorrect answer')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
_validateMultipleChoice(step, answers) {
|
||||||
|
// answers should be an array of option IDs
|
||||||
|
if (!Array.isArray(answers)) {
|
||||||
|
return {
|
||||||
|
isCorrect: false,
|
||||||
|
pointsAwarded: 0,
|
||||||
|
feedback: step.feedback?.incorrect || 'Invalid answer format'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const correctOptions = step.options.filter(opt => opt.isCorrect).map(opt => opt.id);
|
||||||
|
const selectedCorrect = answers.filter(a => correctOptions.includes(a));
|
||||||
|
const selectedIncorrect = answers.filter(a => !correctOptions.includes(a));
|
||||||
|
|
||||||
|
// Calculate points
|
||||||
|
const pointsAwarded = selectedCorrect.reduce((sum, id) => {
|
||||||
|
const option = step.options.find(opt => opt.id === id);
|
||||||
|
return sum + (option?.points || 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const isFullyCorrect = selectedCorrect.length === correctOptions.length &&
|
||||||
|
selectedIncorrect.length === 0;
|
||||||
|
const isPartiallyCorrect = selectedCorrect.length > 0 && !isFullyCorrect;
|
||||||
|
|
||||||
|
let feedback = step.feedback?.incorrect || 'Incorrect answer';
|
||||||
|
if (isFullyCorrect) {
|
||||||
|
feedback = step.feedback?.correct || 'Correct!';
|
||||||
|
} else if (isPartiallyCorrect) {
|
||||||
|
feedback = step.feedback?.partial || step.feedback?.correct || 'Partially correct';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isCorrect: isFullyCorrect,
|
||||||
|
isPartial: isPartiallyCorrect,
|
||||||
|
pointsAwarded,
|
||||||
|
feedback
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
_validateFreeText(step, answer) {
|
||||||
|
if (!answer || typeof answer !== 'string') {
|
||||||
|
return {
|
||||||
|
isCorrect: false,
|
||||||
|
pointsAwarded: 0,
|
||||||
|
feedback: step.feedback?.incorrect || 'Answer is required'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!step.validationRules || step.validationRules.length === 0) {
|
||||||
|
// No validation rules, accept any non-empty answer
|
||||||
|
const points = answer.trim().length > 0 ? step.maxPoints : 0;
|
||||||
|
return {
|
||||||
|
isCorrect: points > 0,
|
||||||
|
pointsAwarded: points,
|
||||||
|
feedback: points > 0
|
||||||
|
? (step.feedback?.correct || 'Answer received')
|
||||||
|
: (step.feedback?.incorrect || 'Answer is too short')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let passedRules = 0;
|
||||||
|
const totalRules = step.validationRules.length;
|
||||||
|
|
||||||
|
for (const rule of step.validationRules) {
|
||||||
|
if (this._checkValidationRule(rule, answer)) {
|
||||||
|
passedRules++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scorePercentage = passedRules / totalRules;
|
||||||
|
const pointsAwarded = Math.round(step.maxPoints * scorePercentage);
|
||||||
|
const isCorrect = scorePercentage >= 0.7; // 70% threshold
|
||||||
|
|
||||||
|
return {
|
||||||
|
isCorrect,
|
||||||
|
pointsAwarded,
|
||||||
|
feedback: isCorrect
|
||||||
|
? (step.feedback?.correct || 'Good answer!')
|
||||||
|
: (step.feedback?.incorrect || 'Please review your answer')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
_checkValidationRule(rule, answer) {
|
||||||
|
const lowerAnswer = (answer || '').toLowerCase();
|
||||||
|
|
||||||
|
switch (rule.type) {
|
||||||
|
case 'contains_keywords':
|
||||||
|
const matches = rule.keywords.filter(keyword =>
|
||||||
|
lowerAnswer.includes(keyword.toLowerCase())
|
||||||
|
).length;
|
||||||
|
return matches >= (rule.minMatches || 1);
|
||||||
|
|
||||||
|
case 'min_length':
|
||||||
|
return answer.length >= rule.value;
|
||||||
|
|
||||||
|
case 'max_length':
|
||||||
|
return answer.length <= rule.value;
|
||||||
|
|
||||||
|
case 'regex':
|
||||||
|
return new RegExp(rule.pattern, rule.flags || 'i').test(answer);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get interactive component data for a step
|
||||||
|
* Can be overridden by subclasses for dynamic content
|
||||||
|
*/
|
||||||
|
async getInteractiveData(stepId) {
|
||||||
|
const step = this.config.steps.find(s => s.id === stepId);
|
||||||
|
if (!step || step.type !== 'interactive') {
|
||||||
|
throw new Error(`Interactive step ${stepId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
component: step.interactiveComponent,
|
||||||
|
props: step.componentProps || {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get lesson content for rendering (without answers)
|
||||||
|
*/
|
||||||
|
getContent() {
|
||||||
|
return {
|
||||||
|
lessonKey: this.lessonKey,
|
||||||
|
title: this.config.title,
|
||||||
|
description: this.config.description,
|
||||||
|
difficultyLevel: this.config.difficultyLevel,
|
||||||
|
estimatedDuration: this.config.estimatedDuration,
|
||||||
|
steps: this.config.steps.map(step => ({
|
||||||
|
id: step.id,
|
||||||
|
type: step.type,
|
||||||
|
title: step.title,
|
||||||
|
content: step.content,
|
||||||
|
// For question steps, don't send correct answers
|
||||||
|
...(step.type === 'question' && {
|
||||||
|
questionType: step.questionType,
|
||||||
|
question: step.question,
|
||||||
|
maxPoints: step.maxPoints,
|
||||||
|
options: step.options?.map(opt => ({
|
||||||
|
id: opt.id,
|
||||||
|
text: opt.text
|
||||||
|
// isCorrect and points are intentionally omitted
|
||||||
|
}))
|
||||||
|
}),
|
||||||
|
// For interactive steps, send component info
|
||||||
|
...(step.type === 'interactive' && {
|
||||||
|
interactiveComponent: step.interactiveComponent,
|
||||||
|
componentProps: step.componentProps
|
||||||
|
})
|
||||||
|
})),
|
||||||
|
scoring: {
|
||||||
|
maxTotalPoints: this.config.scoring?.maxTotalPoints || 100,
|
||||||
|
passingScore: this.config.scoring?.passingScore || 70
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get full configuration (for debugging/admin)
|
||||||
|
*/
|
||||||
|
getFullConfig() {
|
||||||
|
return this.config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = LessonModule;
|
||||||
83
backend/lessons/modules/browser-in-browser-attack/index.js
Normal file
83
backend/lessons/modules/browser-in-browser-attack/index.js
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
const LessonModule = require('../base/LessonModule');
|
||||||
|
|
||||||
|
class BrowserInBrowserLesson extends LessonModule {
|
||||||
|
constructor(config) {
|
||||||
|
super(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get interactive data for the BitB demo
|
||||||
|
getInteractiveData(stepId) {
|
||||||
|
if (stepId === 'bitb-demo') {
|
||||||
|
return {
|
||||||
|
scenarios: [
|
||||||
|
{
|
||||||
|
id: 'legitimate',
|
||||||
|
title: 'Legitimate OAuth Popup',
|
||||||
|
provider: 'Google',
|
||||||
|
domain: 'accounts.google.com',
|
||||||
|
isReal: true,
|
||||||
|
description: 'This simulates how a REAL browser popup would behave',
|
||||||
|
indicators: [
|
||||||
|
'Can be dragged outside browser window',
|
||||||
|
'Has native window controls',
|
||||||
|
'Address bar text is not selectable (real browser UI)',
|
||||||
|
'Right-click shows browser context menu, not page menu',
|
||||||
|
'Appears as separate window in system taskbar'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bitb-attack',
|
||||||
|
title: 'Browser-in-the-Browser Attack',
|
||||||
|
provider: 'Microsoft',
|
||||||
|
domain: 'login.microsoftonline.com',
|
||||||
|
isReal: false,
|
||||||
|
description: 'This is a FAKE popup window created with HTML/CSS/JavaScript',
|
||||||
|
indicators: [
|
||||||
|
'Cannot be dragged outside the main browser window',
|
||||||
|
'Entire window is trapped within the page boundaries',
|
||||||
|
'Address bar is just HTML text/image (right-click shows Inspect)',
|
||||||
|
'Window controls (minimize, maximize, close) are fake buttons',
|
||||||
|
'Does not appear in system taskbar as separate window'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
testInstructions: [
|
||||||
|
'Try to drag each popup window outside the main browser area',
|
||||||
|
'Right-click on the address bar to see if you can inspect it as HTML',
|
||||||
|
'Look for subtle differences in fonts, spacing, or shadows',
|
||||||
|
'Check if the window controls behave like real browser buttons',
|
||||||
|
'Notice if the popup can extend beyond the main window boundaries'
|
||||||
|
],
|
||||||
|
realWorldExamples: [
|
||||||
|
{
|
||||||
|
year: 2022,
|
||||||
|
target: 'Corporate employees',
|
||||||
|
provider: 'Microsoft OAuth',
|
||||||
|
description: 'Attackers used BitB to steal enterprise credentials'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
year: 2022,
|
||||||
|
target: 'Cryptocurrency users',
|
||||||
|
provider: 'Google Sign-in',
|
||||||
|
description: 'Fake crypto platforms used BitB for account takeovers'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
year: 2023,
|
||||||
|
target: 'GitHub developers',
|
||||||
|
provider: 'GitHub OAuth',
|
||||||
|
description: 'Malicious sites mimicked GitHub login to steal tokens'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate specific BitB detection knowledge
|
||||||
|
async validateAnswer(questionId, answer) {
|
||||||
|
// Use base class validation for standard question types
|
||||||
|
return super.validateAnswer(questionId, answer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = BrowserInBrowserLesson;
|
||||||
16
backend/lessons/modules/phishing-email-basics/index.js
Normal file
16
backend/lessons/modules/phishing-email-basics/index.js
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
const LessonModule = require('../base/LessonModule');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phishing Email Detection Basics Lesson
|
||||||
|
* Teaches participants to identify common phishing tactics
|
||||||
|
*/
|
||||||
|
class PhishingEmailBasicsLesson extends LessonModule {
|
||||||
|
constructor(config) {
|
||||||
|
super(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
// This lesson uses the default validation from the base class
|
||||||
|
// No custom validation needed for this beginner lesson
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = PhishingEmailBasicsLesson;
|
||||||
209
backend/lessons/modules/sql-injection-shop/index.js
Normal file
209
backend/lessons/modules/sql-injection-shop/index.js
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
const LessonModule = require('../base/LessonModule');
|
||||||
|
|
||||||
|
class SQLInjectionShopLesson extends LessonModule {
|
||||||
|
constructor(config) {
|
||||||
|
super(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock database with products
|
||||||
|
getMockDatabase() {
|
||||||
|
return {
|
||||||
|
products: [
|
||||||
|
{ id: 1, name: 'Laptop Pro 15', price: 1299.99, category: 'Electronics', stock: 15 },
|
||||||
|
{ id: 2, name: 'Wireless Mouse', price: 29.99, category: 'Accessories', stock: 50 },
|
||||||
|
{ id: 3, name: 'USB-C Cable', price: 12.99, category: 'Accessories', stock: 100 },
|
||||||
|
{ id: 4, name: 'Gaming Keyboard', price: 89.99, category: 'Electronics', stock: 25 },
|
||||||
|
{ id: 5, name: 'Monitor 27"', price: 349.99, category: 'Electronics', stock: 20 },
|
||||||
|
{ id: 6, name: 'Webcam HD', price: 79.99, category: 'Electronics', stock: 30 },
|
||||||
|
{ id: 7, name: 'Desk Lamp', price: 34.99, category: 'Office', stock: 40 },
|
||||||
|
{ id: 8, name: 'Notebook Set', price: 15.99, category: 'Office', stock: 60 }
|
||||||
|
],
|
||||||
|
users: [
|
||||||
|
{ id: 1, username: 'admin', password: 'hashed_admin_password', role: 'admin' },
|
||||||
|
{ id: 2, username: 'john_doe', password: 'hashed_user_password', role: 'customer' },
|
||||||
|
{ id: 3, username: 'jane_smith', password: 'hashed_user_password', role: 'customer' }
|
||||||
|
],
|
||||||
|
orders: [
|
||||||
|
{ id: 1, user_id: 2, total: 1329.98, status: 'shipped' },
|
||||||
|
{ id: 2, user_id: 3, total: 89.99, status: 'processing' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate vulnerable SQL query
|
||||||
|
executeVulnerableQuery(searchTerm) {
|
||||||
|
const db = this.getMockDatabase();
|
||||||
|
|
||||||
|
// Build the "vulnerable" query string for educational display
|
||||||
|
const vulnerableQuery = `SELECT * FROM products WHERE name LIKE '%${searchTerm}%'`;
|
||||||
|
|
||||||
|
// Detect SQL injection attempts
|
||||||
|
const injectionDetected = this.detectInjection(searchTerm);
|
||||||
|
|
||||||
|
let results = [];
|
||||||
|
let injectionType = null;
|
||||||
|
let explanation = '';
|
||||||
|
|
||||||
|
if (injectionDetected) {
|
||||||
|
const injectionInfo = this.analyzeInjection(searchTerm);
|
||||||
|
injectionType = injectionInfo.type;
|
||||||
|
explanation = injectionInfo.explanation;
|
||||||
|
|
||||||
|
// Simulate different injection results
|
||||||
|
if (injectionInfo.type === 'OR_ALWAYS_TRUE') {
|
||||||
|
// Return all products (simulating OR '1'='1')
|
||||||
|
results = db.products;
|
||||||
|
} else if (injectionInfo.type === 'UNION_SELECT') {
|
||||||
|
// Simulate UNION attack showing user data
|
||||||
|
results = [
|
||||||
|
{ id: 'INJECTED', name: 'admin', price: 'hashed_admin_password', category: 'LEAKED DATA', stock: 'admin' },
|
||||||
|
{ id: 'INJECTED', name: 'john_doe', price: 'hashed_user_password', category: 'LEAKED DATA', stock: 'customer' },
|
||||||
|
{ id: 'INJECTED', name: 'jane_smith', price: 'hashed_user_password', category: 'LEAKED DATA', stock: 'customer' }
|
||||||
|
];
|
||||||
|
} else if (injectionInfo.type === 'DROP_TABLE') {
|
||||||
|
// Simulate destructive command
|
||||||
|
results = [];
|
||||||
|
explanation += ' In a real scenario, this could delete the entire products table!';
|
||||||
|
} else if (injectionInfo.type === 'COMMENT_INJECTION') {
|
||||||
|
// Bypass rest of query
|
||||||
|
results = db.products;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Normal search - filter products by name
|
||||||
|
results = db.products.filter(p =>
|
||||||
|
p.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
query: vulnerableQuery,
|
||||||
|
results,
|
||||||
|
injectionDetected,
|
||||||
|
injectionType,
|
||||||
|
explanation,
|
||||||
|
recordCount: results.length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect if input contains SQL injection
|
||||||
|
detectInjection(input) {
|
||||||
|
const injectionPatterns = [
|
||||||
|
/'/, // Single quote
|
||||||
|
/--/, // SQL comment
|
||||||
|
/;/, // Statement separator
|
||||||
|
/union/i, // UNION keyword
|
||||||
|
/select/i, // SELECT keyword
|
||||||
|
/drop/i, // DROP keyword
|
||||||
|
/insert/i, // INSERT keyword
|
||||||
|
/update/i, // UPDATE keyword
|
||||||
|
/delete/i, // DELETE keyword
|
||||||
|
/or\s+['"]?\d+['"]?\s*=\s*['"]?\d+['"]?/i // OR 1=1 pattern
|
||||||
|
];
|
||||||
|
|
||||||
|
return injectionPatterns.some(pattern => pattern.test(input));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analyze the type of SQL injection
|
||||||
|
analyzeInjection(input) {
|
||||||
|
const lowerInput = input.toLowerCase();
|
||||||
|
|
||||||
|
if (lowerInput.includes('union') && lowerInput.includes('select')) {
|
||||||
|
return {
|
||||||
|
type: 'UNION_SELECT',
|
||||||
|
explanation: '⚠️ UNION SELECT injection detected! This technique combines results from multiple tables, potentially exposing sensitive data like usernames and passwords.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lowerInput.includes('drop')) {
|
||||||
|
return {
|
||||||
|
type: 'DROP_TABLE',
|
||||||
|
explanation: '🚨 DROP TABLE injection detected! This is a destructive attack that could delete entire database tables. Critical data loss would occur!'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lowerInput.includes("'") && (lowerInput.includes('or') || lowerInput.includes('||'))) {
|
||||||
|
if (lowerInput.match(/or\s+['"]?\d+['"]?\s*=\s*['"]?\d+['"]?/)) {
|
||||||
|
return {
|
||||||
|
type: 'OR_ALWAYS_TRUE',
|
||||||
|
explanation: "⚠️ OR injection detected! The condition '1'='1' is always true, bypassing the intended filter and returning ALL records."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lowerInput.includes('--') || lowerInput.includes('#')) {
|
||||||
|
return {
|
||||||
|
type: 'COMMENT_INJECTION',
|
||||||
|
explanation: '⚠️ Comment injection detected! The -- sequence comments out the rest of the SQL query, potentially bypassing security checks.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lowerInput.includes(';')) {
|
||||||
|
return {
|
||||||
|
type: 'MULTIPLE_STATEMENTS',
|
||||||
|
explanation: '⚠️ Multiple statement injection detected! The semicolon allows execution of additional SQL commands, enabling complex attacks.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic injection
|
||||||
|
return {
|
||||||
|
type: 'GENERIC',
|
||||||
|
explanation: '⚠️ SQL injection attempt detected! Special characters in the input could manipulate the query structure.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Demonstrate safe parameterized query
|
||||||
|
executeSafeQuery(searchTerm) {
|
||||||
|
const db = this.getMockDatabase();
|
||||||
|
|
||||||
|
// Show the safe query with placeholder
|
||||||
|
const safeQuery = `SELECT * FROM products WHERE name LIKE ?`;
|
||||||
|
const parameter = `%${searchTerm}%`;
|
||||||
|
|
||||||
|
// Execute safe search (treats all input as literal data)
|
||||||
|
const results = db.products.filter(p =>
|
||||||
|
p.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
query: safeQuery,
|
||||||
|
parameter,
|
||||||
|
results,
|
||||||
|
explanation: '✅ Parameterized query used! User input is treated as data only, never as SQL code. Injection is impossible.',
|
||||||
|
recordCount: results.length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get interactive data for the SQL shop demo
|
||||||
|
getInteractiveData(stepId) {
|
||||||
|
if (stepId === 'shop-demo') {
|
||||||
|
return {
|
||||||
|
database: this.getMockDatabase(),
|
||||||
|
examples: [
|
||||||
|
{
|
||||||
|
label: 'Normal Search',
|
||||||
|
input: 'laptop',
|
||||||
|
description: 'Search for products containing "laptop"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'View All Products (OR injection)',
|
||||||
|
input: "' OR '1'='1",
|
||||||
|
description: 'Exploit: Returns all products by making condition always true'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Extract User Data (UNION)',
|
||||||
|
input: "' UNION SELECT id, username, password, role, 'LEAKED' FROM users--",
|
||||||
|
description: 'Exploit: Combines product results with user table data'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Destructive Attack (DROP)',
|
||||||
|
input: "'; DROP TABLE products--",
|
||||||
|
description: 'Exploit: Attempts to delete the products table'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = SQLInjectionShopLesson;
|
||||||
2186
backend/package-lock.json
generated
Normal file
2186
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
backend/package.json
Normal file
35
backend/package.json
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "lernplattform-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Backend API for Security Awareness Learning Platform",
|
||||||
|
"main": "src/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/index.js",
|
||||||
|
"dev": "nodemon src/index.js",
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"security",
|
||||||
|
"learning",
|
||||||
|
"platform",
|
||||||
|
"education"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"pg": "^8.11.3",
|
||||||
|
"bcrypt": "^5.1.1",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"js-yaml": "^4.1.0",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"express-validator": "^7.0.1",
|
||||||
|
"helmet": "^7.1.0",
|
||||||
|
"morgan": "^1.10.0",
|
||||||
|
"uuid": "^9.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
78
backend/seed-new-lessons.js
Normal file
78
backend/seed-new-lessons.js
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
const { pool } = require('./src/config/database');
|
||||||
|
|
||||||
|
const lessons = [
|
||||||
|
{
|
||||||
|
lesson_key: 'sql-injection-shop',
|
||||||
|
title: 'SQL Injection Attack - Online Shop Demo',
|
||||||
|
description: 'Learn how SQL injection vulnerabilities work through a realistic online shop scenario',
|
||||||
|
module_path: 'sql-injection-shop',
|
||||||
|
config_path: 'sql-injection-shop.yaml',
|
||||||
|
difficulty_level: 'intermediate',
|
||||||
|
estimated_duration: 20
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lesson_key: 'browser-in-browser-attack',
|
||||||
|
title: 'Browser-in-the-Browser (BitB) Attack',
|
||||||
|
description: 'Learn to identify sophisticated phishing attacks that mimic legitimate browser windows',
|
||||||
|
module_path: 'browser-in-browser-attack',
|
||||||
|
config_path: 'browser-in-browser-attack.yaml',
|
||||||
|
difficulty_level: 'advanced',
|
||||||
|
estimated_duration: 25
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
async function seedLessons() {
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Starting to seed new lessons...');
|
||||||
|
|
||||||
|
for (const lesson of lessons) {
|
||||||
|
// Check if lesson already exists
|
||||||
|
const existingResult = await client.query(
|
||||||
|
'SELECT id FROM lessons WHERE lesson_key = $1',
|
||||||
|
[lesson.lesson_key]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingResult.rows.length > 0) {
|
||||||
|
console.log(`Lesson "${lesson.lesson_key}" already exists, skipping...`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert new lesson
|
||||||
|
const result = await client.query(
|
||||||
|
`INSERT INTO lessons (lesson_key, title, description, module_path, config_path, difficulty_level, estimated_duration)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING id`,
|
||||||
|
[
|
||||||
|
lesson.lesson_key,
|
||||||
|
lesson.title,
|
||||||
|
lesson.description,
|
||||||
|
lesson.module_path,
|
||||||
|
lesson.config_path,
|
||||||
|
lesson.difficulty_level,
|
||||||
|
lesson.estimated_duration
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`✓ Created lesson: ${lesson.title} (ID: ${result.rows[0].id})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n✅ All new lessons seeded successfully!');
|
||||||
|
console.log('\nYou can now assign these lessons to events via the admin panel.');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error seeding lessons:', error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seedLessons()
|
||||||
|
.then(() => process.exit(0))
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Seed failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
60
backend/src/config/database.js
Normal file
60
backend/src/config/database.js
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
const { Pool } = require('pg');
|
||||||
|
const config = require('./environment');
|
||||||
|
|
||||||
|
// Create PostgreSQL connection pool
|
||||||
|
const pool = new Pool({
|
||||||
|
host: config.database.host,
|
||||||
|
port: config.database.port,
|
||||||
|
database: config.database.name,
|
||||||
|
user: config.database.user,
|
||||||
|
password: config.database.password,
|
||||||
|
max: 20, // Maximum number of clients in the pool
|
||||||
|
idleTimeoutMillis: 30000,
|
||||||
|
connectionTimeoutMillis: 2000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test connection
|
||||||
|
pool.on('connect', () => {
|
||||||
|
console.log('Database connected successfully');
|
||||||
|
});
|
||||||
|
|
||||||
|
pool.on('error', (err) => {
|
||||||
|
console.error('Unexpected database error:', err);
|
||||||
|
process.exit(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Query helper function
|
||||||
|
const query = async (text, params) => {
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
const res = await pool.query(text, params);
|
||||||
|
const duration = Date.now() - start;
|
||||||
|
console.log('Executed query', { text, duration, rows: res.rowCount });
|
||||||
|
return res;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Database query error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Transaction helper
|
||||||
|
const transaction = async (callback) => {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
const result = await callback(client);
|
||||||
|
await client.query('COMMIT');
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
pool,
|
||||||
|
query,
|
||||||
|
transaction
|
||||||
|
};
|
||||||
33
backend/src/config/environment.js
Normal file
33
backend/src/config/environment.js
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
// Server configuration
|
||||||
|
nodeEnv: process.env.NODE_ENV || 'development',
|
||||||
|
port: parseInt(process.env.PORT || '3000', 10),
|
||||||
|
|
||||||
|
// Database configuration
|
||||||
|
database: {
|
||||||
|
host: process.env.DB_HOST || 'localhost',
|
||||||
|
port: parseInt(process.env.DB_PORT || '5432', 10),
|
||||||
|
name: process.env.DB_NAME || 'lernplattform',
|
||||||
|
user: process.env.DB_USER || 'lernplattform_user',
|
||||||
|
password: process.env.DB_PASSWORD || 'changeme123'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Security configuration
|
||||||
|
jwtSecret: process.env.JWT_SECRET || 'change_this_secret_key_in_production',
|
||||||
|
sessionSecret: process.env.SESSION_SECRET || 'change_this_session_secret',
|
||||||
|
sessionTimeout: parseInt(process.env.SESSION_TIMEOUT || '3600000', 10), // 1 hour default
|
||||||
|
|
||||||
|
// Admin configuration
|
||||||
|
adminDefaultPassword: process.env.ADMIN_DEFAULT_PASSWORD || 'admin123',
|
||||||
|
|
||||||
|
// Logging configuration
|
||||||
|
logLevel: process.env.LOG_LEVEL || 'info',
|
||||||
|
|
||||||
|
// CORS configuration
|
||||||
|
corsOrigin: process.env.CORS_ORIGIN || '*',
|
||||||
|
|
||||||
|
// Paths
|
||||||
|
lessonsPath: process.env.LESSONS_PATH || './lessons'
|
||||||
|
};
|
||||||
96
backend/src/controllers/admin.controller.js
Normal file
96
backend/src/controllers/admin.controller.js
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
const { ApiError } = require('../middleware/errorHandler');
|
||||||
|
const { generateAdminToken, verifyPassword } = require('../middleware/auth');
|
||||||
|
const db = require('../config/database');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin login
|
||||||
|
* POST /api/admin/login
|
||||||
|
*/
|
||||||
|
const login = async (req, res) => {
|
||||||
|
const { username, password } = req.body;
|
||||||
|
|
||||||
|
// Validate input
|
||||||
|
if (!username || !password) {
|
||||||
|
throw new ApiError(400, 'Username and password are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get admin from database
|
||||||
|
const result = await db.query(
|
||||||
|
'SELECT id, username, password_hash FROM admin_users WHERE username = $1',
|
||||||
|
[username]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new ApiError(401, 'Invalid credentials');
|
||||||
|
}
|
||||||
|
|
||||||
|
const admin = result.rows[0];
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
const isValidPassword = await verifyPassword(password, admin.password_hash);
|
||||||
|
|
||||||
|
if (!isValidPassword) {
|
||||||
|
throw new ApiError(401, 'Invalid credentials');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last login timestamp
|
||||||
|
await db.query(
|
||||||
|
'UPDATE admin_users SET last_login = CURRENT_TIMESTAMP WHERE id = $1',
|
||||||
|
[admin.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generate token
|
||||||
|
const token = generateAdminToken(admin.id, admin.username);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Login successful',
|
||||||
|
data: {
|
||||||
|
token,
|
||||||
|
admin: {
|
||||||
|
id: admin.id,
|
||||||
|
username: admin.username
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current admin profile
|
||||||
|
* GET /api/admin/profile
|
||||||
|
*/
|
||||||
|
const getProfile = async (req, res) => {
|
||||||
|
const result = await db.query(
|
||||||
|
'SELECT id, username, created_at, last_login FROM admin_users WHERE id = $1',
|
||||||
|
[req.admin.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new ApiError(404, 'Admin not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.rows[0]
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify token (for client-side token validation)
|
||||||
|
* GET /api/admin/verify
|
||||||
|
*/
|
||||||
|
const verifyToken = async (req, res) => {
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Token is valid',
|
||||||
|
data: {
|
||||||
|
admin: req.admin
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
login,
|
||||||
|
getProfile,
|
||||||
|
verifyToken
|
||||||
|
};
|
||||||
156
backend/src/controllers/adminLesson.controller.js
Normal file
156
backend/src/controllers/adminLesson.controller.js
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
const { ApiError } = require('../middleware/errorHandler');
|
||||||
|
const lessonQueries = require('../models/queries/lesson.queries');
|
||||||
|
const eventQueries = require('../models/queries/event.queries');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all lessons
|
||||||
|
* GET /api/admin/lessons
|
||||||
|
*/
|
||||||
|
const getAllLessons = async (req, res) => {
|
||||||
|
const lessons = await lessonQueries.getAllLessons();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: lessons
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assign lesson to event
|
||||||
|
* POST /api/admin/events/:eventId/lessons
|
||||||
|
*/
|
||||||
|
const assignLessonToEvent = async (req, res) => {
|
||||||
|
const { eventId } = req.params;
|
||||||
|
let { lessonId, orderIndex, maxPoints, weight, isRequired, unlockAfterLessonId } = req.body;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!lessonId) {
|
||||||
|
throw new ApiError(400, 'Lesson ID is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if event exists
|
||||||
|
const eventExists = await eventQueries.eventExists(eventId);
|
||||||
|
if (!eventExists) {
|
||||||
|
throw new ApiError(404, 'Event not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if lesson exists
|
||||||
|
const lessonExists = await lessonQueries.lessonExists(lessonId);
|
||||||
|
if (!lessonExists) {
|
||||||
|
throw new ApiError(404, 'Lesson not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if lesson is already assigned to this event
|
||||||
|
const existingLessons = await lessonQueries.getEventLessons(eventId);
|
||||||
|
const alreadyAssigned = existingLessons.find(el => el.lesson_id === parseInt(lessonId));
|
||||||
|
if (alreadyAssigned) {
|
||||||
|
throw new ApiError(400, 'This lesson is already assigned to this event');
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no order index specified, calculate next available
|
||||||
|
if (orderIndex === undefined) {
|
||||||
|
const maxOrder = existingLessons.reduce((max, el) => Math.max(max, el.order_index), 0);
|
||||||
|
orderIndex = maxOrder + 1;
|
||||||
|
} else {
|
||||||
|
// Check if order index conflicts
|
||||||
|
const orderConflict = existingLessons.find(el => el.order_index === parseInt(orderIndex));
|
||||||
|
if (orderConflict) {
|
||||||
|
// Auto-increment to next available
|
||||||
|
const maxOrder = existingLessons.reduce((max, el) => Math.max(max, el.order_index), 0);
|
||||||
|
orderIndex = maxOrder + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign lesson to event
|
||||||
|
const eventLesson = await lessonQueries.assignLessonToEvent(
|
||||||
|
eventId,
|
||||||
|
lessonId,
|
||||||
|
orderIndex,
|
||||||
|
maxPoints || 100,
|
||||||
|
weight || 1.0,
|
||||||
|
isRequired !== undefined ? isRequired : true,
|
||||||
|
unlockAfterLessonId
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Lesson assigned to event',
|
||||||
|
data: eventLesson
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get lessons assigned to an event
|
||||||
|
* GET /api/admin/events/:eventId/lessons
|
||||||
|
*/
|
||||||
|
const getEventLessons = async (req, res) => {
|
||||||
|
const { eventId } = req.params;
|
||||||
|
|
||||||
|
// Check if event exists
|
||||||
|
const eventExists = await eventQueries.eventExists(eventId);
|
||||||
|
if (!eventExists) {
|
||||||
|
throw new ApiError(404, 'Event not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const lessons = await lessonQueries.getEventLessons(eventId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: lessons
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update event lesson configuration
|
||||||
|
* PUT /api/admin/events/:eventId/lessons/:eventLessonId
|
||||||
|
*/
|
||||||
|
const updateEventLesson = async (req, res) => {
|
||||||
|
const { eventLessonId } = req.params;
|
||||||
|
const { orderIndex, maxPoints, weight, isRequired, unlockAfterLessonId } = req.body;
|
||||||
|
|
||||||
|
const updates = {};
|
||||||
|
if (orderIndex !== undefined) updates.order_index = orderIndex;
|
||||||
|
if (maxPoints !== undefined) updates.max_points = maxPoints;
|
||||||
|
if (weight !== undefined) updates.weight = weight;
|
||||||
|
if (isRequired !== undefined) updates.is_required = isRequired;
|
||||||
|
if (unlockAfterLessonId !== undefined) updates.unlock_after_lesson_id = unlockAfterLessonId;
|
||||||
|
|
||||||
|
const updated = await lessonQueries.updateEventLesson(eventLessonId, updates);
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
throw new ApiError(404, 'Event lesson not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Event lesson updated',
|
||||||
|
data: updated
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove lesson from event
|
||||||
|
* DELETE /api/admin/events/:eventId/lessons/:eventLessonId
|
||||||
|
*/
|
||||||
|
const removeEventLesson = async (req, res) => {
|
||||||
|
const { eventLessonId } = req.params;
|
||||||
|
|
||||||
|
const removed = await lessonQueries.removeEventLesson(eventLessonId);
|
||||||
|
|
||||||
|
if (!removed) {
|
||||||
|
throw new ApiError(404, 'Event lesson not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Lesson removed from event'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getAllLessons,
|
||||||
|
assignLessonToEvent,
|
||||||
|
getEventLessons,
|
||||||
|
updateEventLesson,
|
||||||
|
removeEventLesson
|
||||||
|
};
|
||||||
190
backend/src/controllers/event.controller.js
Normal file
190
backend/src/controllers/event.controller.js
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
const { ApiError } = require('../middleware/errorHandler');
|
||||||
|
const eventQueries = require('../models/queries/event.queries');
|
||||||
|
const participantQueries = require('../models/queries/participant.queries');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new event
|
||||||
|
* POST /api/admin/events
|
||||||
|
*/
|
||||||
|
const createEvent = async (req, res) => {
|
||||||
|
const { name, description, startDate, endDate } = req.body;
|
||||||
|
|
||||||
|
// Validate input
|
||||||
|
if (!name) {
|
||||||
|
throw new ApiError(400, 'Event name is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.length < 3 || name.length > 255) {
|
||||||
|
throw new ApiError(400, 'Event name must be between 3 and 255 characters');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate dates if provided
|
||||||
|
if (startDate && endDate) {
|
||||||
|
const start = new Date(startDate);
|
||||||
|
const end = new Date(endDate);
|
||||||
|
|
||||||
|
if (end <= start) {
|
||||||
|
throw new ApiError(400, 'End date must be after start date');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = await eventQueries.createEvent(name, description, startDate, endDate);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Event created successfully',
|
||||||
|
data: event
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all events
|
||||||
|
* GET /api/admin/events
|
||||||
|
*/
|
||||||
|
const getAllEvents = async (req, res) => {
|
||||||
|
const events = await eventQueries.getAllEvents();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: events
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get event by ID
|
||||||
|
* GET /api/admin/events/:eventId
|
||||||
|
*/
|
||||||
|
const getEventById = async (req, res) => {
|
||||||
|
const { eventId } = req.params;
|
||||||
|
|
||||||
|
const event = await eventQueries.getEventById(eventId);
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
throw new ApiError(404, 'Event not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: event
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update event
|
||||||
|
* PUT /api/admin/events/:eventId
|
||||||
|
*/
|
||||||
|
const updateEvent = async (req, res) => {
|
||||||
|
const { eventId } = req.params;
|
||||||
|
const { name, description, startDate, endDate, isActive } = req.body;
|
||||||
|
|
||||||
|
// Check if event exists
|
||||||
|
const exists = await eventQueries.eventExists(eventId);
|
||||||
|
if (!exists) {
|
||||||
|
throw new ApiError(404, 'Event not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate name if provided
|
||||||
|
if (name !== undefined) {
|
||||||
|
if (!name || name.length < 3 || name.length > 255) {
|
||||||
|
throw new ApiError(400, 'Event name must be between 3 and 255 characters');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate dates if both provided
|
||||||
|
if (startDate && endDate) {
|
||||||
|
const start = new Date(startDate);
|
||||||
|
const end = new Date(endDate);
|
||||||
|
|
||||||
|
if (end <= start) {
|
||||||
|
throw new ApiError(400, 'End date must be after start date');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates = {};
|
||||||
|
if (name !== undefined) updates.name = name;
|
||||||
|
if (description !== undefined) updates.description = description;
|
||||||
|
if (startDate !== undefined) updates.start_date = startDate;
|
||||||
|
if (endDate !== undefined) updates.end_date = endDate;
|
||||||
|
if (isActive !== undefined) updates.is_active = isActive;
|
||||||
|
|
||||||
|
const updatedEvent = await eventQueries.updateEvent(eventId, updates);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Event updated successfully',
|
||||||
|
data: updatedEvent
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete event
|
||||||
|
* DELETE /api/admin/events/:eventId
|
||||||
|
*/
|
||||||
|
const deleteEvent = async (req, res) => {
|
||||||
|
const { eventId } = req.params;
|
||||||
|
|
||||||
|
// Check if event exists
|
||||||
|
const exists = await eventQueries.eventExists(eventId);
|
||||||
|
if (!exists) {
|
||||||
|
throw new ApiError(404, 'Event not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await eventQueries.deleteEvent(eventId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Event deleted successfully'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get event participants
|
||||||
|
* GET /api/admin/events/:eventId/participants
|
||||||
|
*/
|
||||||
|
const getEventParticipants = async (req, res) => {
|
||||||
|
const { eventId } = req.params;
|
||||||
|
|
||||||
|
// Check if event exists
|
||||||
|
const exists = await eventQueries.eventExists(eventId);
|
||||||
|
if (!exists) {
|
||||||
|
throw new ApiError(404, 'Event not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const participants = await participantQueries.getParticipantsByEvent(eventId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: participants
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get event statistics
|
||||||
|
* GET /api/admin/events/:eventId/analytics
|
||||||
|
*/
|
||||||
|
const getEventAnalytics = async (req, res) => {
|
||||||
|
const { eventId } = req.params;
|
||||||
|
|
||||||
|
// Check if event exists
|
||||||
|
const exists = await eventQueries.eventExists(eventId);
|
||||||
|
if (!exists) {
|
||||||
|
throw new ApiError(404, 'Event not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const statistics = await eventQueries.getEventStatistics(eventId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: statistics
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createEvent,
|
||||||
|
getAllEvents,
|
||||||
|
getEventById,
|
||||||
|
updateEvent,
|
||||||
|
deleteEvent,
|
||||||
|
getEventParticipants,
|
||||||
|
getEventAnalytics
|
||||||
|
};
|
||||||
287
backend/src/controllers/lesson.controller.js
Normal file
287
backend/src/controllers/lesson.controller.js
Normal file
@ -0,0 +1,287 @@
|
|||||||
|
const { ApiError } = require('../middleware/errorHandler');
|
||||||
|
const lessonQueries = require('../models/queries/lesson.queries');
|
||||||
|
const progressQueries = require('../models/queries/progress.queries');
|
||||||
|
const lessonLoader = require('../services/lessonLoader.service');
|
||||||
|
const scoringService = require('../services/scoring.service');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get lessons for an event (participant view)
|
||||||
|
* GET /api/participant/event/:eventId/lessons
|
||||||
|
*/
|
||||||
|
const getEventLessons = async (req, res) => {
|
||||||
|
const { eventId } = req.params;
|
||||||
|
const participantId = req.participant.id;
|
||||||
|
|
||||||
|
const lessons = await lessonQueries.getEventLessonsWithProgress(eventId, participantId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: lessons.map(lesson => ({
|
||||||
|
eventLessonId: lesson.event_lesson_id,
|
||||||
|
lessonId: lesson.id,
|
||||||
|
lessonKey: lesson.lesson_key,
|
||||||
|
title: lesson.title,
|
||||||
|
description: lesson.description,
|
||||||
|
difficultyLevel: lesson.difficulty_level,
|
||||||
|
estimatedDuration: lesson.estimated_duration,
|
||||||
|
orderIndex: lesson.order_index,
|
||||||
|
maxPoints: lesson.max_points,
|
||||||
|
weight: lesson.weight,
|
||||||
|
isRequired: lesson.is_required,
|
||||||
|
isUnlocked: lesson.is_unlocked,
|
||||||
|
progress: lesson.progress_id ? {
|
||||||
|
status: lesson.status,
|
||||||
|
score: lesson.score,
|
||||||
|
attempts: lesson.attempts,
|
||||||
|
startedAt: lesson.started_at,
|
||||||
|
completedAt: lesson.completed_at
|
||||||
|
} : null
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get lesson content
|
||||||
|
* GET /api/participant/lesson/:eventLessonId
|
||||||
|
*/
|
||||||
|
const getLessonContent = async (req, res) => {
|
||||||
|
const { eventLessonId } = req.params;
|
||||||
|
const participantId = req.participant.id;
|
||||||
|
|
||||||
|
// Get event lesson details
|
||||||
|
const eventLesson = await lessonQueries.getEventLessonById(eventLessonId);
|
||||||
|
|
||||||
|
if (!eventLesson) {
|
||||||
|
throw new ApiError(404, 'Lesson not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if lesson is unlocked
|
||||||
|
const isUnlocked = await progressQueries.isLessonUnlocked(participantId, eventLessonId);
|
||||||
|
|
||||||
|
if (!isUnlocked) {
|
||||||
|
throw new ApiError(403, 'This lesson is locked. Complete previous lessons first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load lesson content from module
|
||||||
|
const content = await lessonLoader.getLessonContent(eventLesson.lesson_key);
|
||||||
|
|
||||||
|
// Get progress if exists
|
||||||
|
const progress = await progressQueries.getLessonProgress(participantId, eventLessonId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
eventLessonId,
|
||||||
|
...content,
|
||||||
|
maxPoints: eventLesson.max_points,
|
||||||
|
weight: eventLesson.weight,
|
||||||
|
progress: progress ? {
|
||||||
|
id: progress.id,
|
||||||
|
status: progress.status,
|
||||||
|
score: progress.score,
|
||||||
|
currentStep: progress.current_step,
|
||||||
|
attempts: progress.attempts
|
||||||
|
} : null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a lesson
|
||||||
|
* POST /api/participant/lesson/:eventLessonId/start
|
||||||
|
*/
|
||||||
|
const startLesson = async (req, res) => {
|
||||||
|
const { eventLessonId } = req.params;
|
||||||
|
const participantId = req.participant.id;
|
||||||
|
|
||||||
|
// Check if lesson is unlocked
|
||||||
|
const isUnlocked = await progressQueries.isLessonUnlocked(participantId, eventLessonId);
|
||||||
|
|
||||||
|
if (!isUnlocked) {
|
||||||
|
throw new ApiError(403, 'This lesson is locked');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start or resume progress
|
||||||
|
const progress = await progressQueries.startLesson(participantId, eventLessonId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Lesson started',
|
||||||
|
data: {
|
||||||
|
progressId: progress.id,
|
||||||
|
status: progress.status,
|
||||||
|
startedAt: progress.started_at
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit an answer
|
||||||
|
* POST /api/participant/lesson/:eventLessonId/answer
|
||||||
|
*/
|
||||||
|
const submitAnswer = async (req, res) => {
|
||||||
|
const { eventLessonId } = req.params;
|
||||||
|
const { questionId, answer } = req.body;
|
||||||
|
const participantId = req.participant.id;
|
||||||
|
|
||||||
|
if (!questionId || answer === undefined) {
|
||||||
|
throw new ApiError(400, 'Question ID and answer are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get event lesson
|
||||||
|
const eventLesson = await lessonQueries.getEventLessonById(eventLessonId);
|
||||||
|
|
||||||
|
if (!eventLesson) {
|
||||||
|
throw new ApiError(404, 'Lesson not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get or create progress
|
||||||
|
let progress = await progressQueries.getLessonProgress(participantId, eventLessonId);
|
||||||
|
|
||||||
|
if (!progress) {
|
||||||
|
// Auto-start lesson if not started
|
||||||
|
progress = await progressQueries.startLesson(participantId, eventLessonId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate answer using lesson module
|
||||||
|
const validation = await lessonLoader.validateAnswer(
|
||||||
|
eventLesson.lesson_key,
|
||||||
|
questionId,
|
||||||
|
answer
|
||||||
|
);
|
||||||
|
|
||||||
|
// Save answer to database
|
||||||
|
await progressQueries.saveAnswer(
|
||||||
|
progress.id,
|
||||||
|
questionId,
|
||||||
|
answer,
|
||||||
|
validation.isCorrect,
|
||||||
|
validation.pointsAwarded,
|
||||||
|
validation.feedback
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update score
|
||||||
|
const newScore = await progressQueries.updateScore(progress.id, validation.pointsAwarded);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
isCorrect: validation.isCorrect,
|
||||||
|
isPartial: validation.isPartial || false,
|
||||||
|
pointsAwarded: validation.pointsAwarded,
|
||||||
|
feedback: validation.feedback,
|
||||||
|
totalScore: newScore
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete a lesson
|
||||||
|
* POST /api/participant/lesson/:eventLessonId/complete
|
||||||
|
*/
|
||||||
|
const completeLesson = async (req, res) => {
|
||||||
|
const { eventLessonId } = req.params;
|
||||||
|
const participantId = req.participant.id;
|
||||||
|
|
||||||
|
// Get progress
|
||||||
|
const progress = await progressQueries.getLessonProgress(participantId, eventLessonId);
|
||||||
|
|
||||||
|
if (!progress) {
|
||||||
|
throw new ApiError(404, 'Lesson progress not found. Start the lesson first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progress.status === 'completed') {
|
||||||
|
throw new ApiError(400, 'Lesson already completed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as completed
|
||||||
|
const updated = await progressQueries.completeLesson(progress.id);
|
||||||
|
|
||||||
|
// Calculate lesson score details
|
||||||
|
const scoreDetails = await scoringService.calculateLessonScore(progress.id);
|
||||||
|
|
||||||
|
// Check if passed
|
||||||
|
const passed = await scoringService.checkLessonPassed(progress.id);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Lesson completed',
|
||||||
|
data: {
|
||||||
|
completedAt: updated.completed_at,
|
||||||
|
finalScore: updated.score,
|
||||||
|
maxPoints: scoreDetails.maxPoints,
|
||||||
|
percentage: scoreDetails.percentage,
|
||||||
|
passed
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get interactive component data
|
||||||
|
* GET /api/lessons/:lessonKey/interactive/:stepId
|
||||||
|
*/
|
||||||
|
const getInteractiveData = async (req, res) => {
|
||||||
|
const { lessonKey, stepId } = req.params;
|
||||||
|
|
||||||
|
const data = await lessonLoader.getInteractiveData(lessonKey, stepId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute lesson-specific action (e.g., SQL query)
|
||||||
|
* POST /api/lesson/:eventLessonId/action/:action
|
||||||
|
*/
|
||||||
|
const executeLessonAction = async (req, res) => {
|
||||||
|
const { eventLessonId, action } = req.params;
|
||||||
|
const participantId = req.participant.id;
|
||||||
|
const actionData = req.body;
|
||||||
|
|
||||||
|
// Get progress to ensure lesson is started
|
||||||
|
const progress = await progressQueries.getLessonProgress(participantId, eventLessonId);
|
||||||
|
|
||||||
|
if (!progress) {
|
||||||
|
throw new ApiError(404, 'Lesson not started. Start the lesson first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get event lesson details to find lesson key
|
||||||
|
const eventLesson = await lessonQueries.getEventLessonById(eventLessonId);
|
||||||
|
const lessonKey = eventLesson.lesson_key;
|
||||||
|
|
||||||
|
// Load lesson module
|
||||||
|
const lessonModule = await lessonLoader.loadLesson(lessonKey);
|
||||||
|
|
||||||
|
// Execute action based on type
|
||||||
|
let result;
|
||||||
|
|
||||||
|
if (action === 'execute-query' && lessonModule.executeVulnerableQuery) {
|
||||||
|
// SQL Injection demo
|
||||||
|
const { searchTerm, mode } = actionData;
|
||||||
|
|
||||||
|
if (mode === 'safe' && lessonModule.executeSafeQuery) {
|
||||||
|
result = lessonModule.executeSafeQuery(searchTerm);
|
||||||
|
} else {
|
||||||
|
result = lessonModule.executeVulnerableQuery(searchTerm);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new ApiError(400, `Unsupported action: ${action}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getEventLessons,
|
||||||
|
getLessonContent,
|
||||||
|
startLesson,
|
||||||
|
submitAnswer,
|
||||||
|
completeLesson,
|
||||||
|
getInteractiveData,
|
||||||
|
executeLessonAction
|
||||||
|
};
|
||||||
129
backend/src/controllers/participant.controller.js
Normal file
129
backend/src/controllers/participant.controller.js
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
const { ApiError } = require('../middleware/errorHandler');
|
||||||
|
const { generateSessionToken } = require('../middleware/auth');
|
||||||
|
const participantQueries = require('../models/queries/participant.queries');
|
||||||
|
const eventQueries = require('../models/queries/event.queries');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Join an event with a pseudonym
|
||||||
|
* POST /api/participant/join
|
||||||
|
*/
|
||||||
|
const joinEvent = async (req, res) => {
|
||||||
|
const { pseudonym, eventId } = req.body;
|
||||||
|
|
||||||
|
// Validate input
|
||||||
|
if (!pseudonym || !eventId) {
|
||||||
|
throw new ApiError(400, 'Pseudonym and eventId are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate pseudonym format
|
||||||
|
if (pseudonym.length < 3 || pseudonym.length > 50) {
|
||||||
|
throw new ApiError(400, 'Pseudonym must be between 3 and 50 characters');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if event exists and is active
|
||||||
|
const event = await eventQueries.getEventById(eventId);
|
||||||
|
if (!event) {
|
||||||
|
throw new ApiError(404, 'Event not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event.is_active) {
|
||||||
|
throw new ApiError(403, 'This event is no longer accepting participants');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if pseudonym is already taken in this event
|
||||||
|
const exists = await participantQueries.pseudonymExists(pseudonym, eventId);
|
||||||
|
if (exists) {
|
||||||
|
throw new ApiError(409, 'Pseudonym already taken in this event. Please choose another.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate session token
|
||||||
|
const sessionToken = generateSessionToken();
|
||||||
|
|
||||||
|
// Create participant
|
||||||
|
const participant = await participantQueries.createParticipant(
|
||||||
|
pseudonym,
|
||||||
|
eventId,
|
||||||
|
sessionToken
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Successfully joined event',
|
||||||
|
data: {
|
||||||
|
participant: {
|
||||||
|
id: participant.id,
|
||||||
|
pseudonym: participant.pseudonym,
|
||||||
|
eventId: participant.event_id
|
||||||
|
},
|
||||||
|
sessionToken,
|
||||||
|
event: {
|
||||||
|
id: event.id,
|
||||||
|
name: event.name,
|
||||||
|
description: event.description
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of active events
|
||||||
|
* GET /api/participant/events
|
||||||
|
*/
|
||||||
|
const getActiveEvents = async (req, res) => {
|
||||||
|
const events = await eventQueries.getActiveEvents();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: events
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get participant's own progress
|
||||||
|
* GET /api/participant/progress
|
||||||
|
*/
|
||||||
|
const getProgress = async (req, res) => {
|
||||||
|
const participantId = req.participant.id;
|
||||||
|
|
||||||
|
const progress = await participantQueries.getParticipantProgress(participantId);
|
||||||
|
|
||||||
|
if (!progress) {
|
||||||
|
throw new ApiError(404, 'Participant not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: progress
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get participant profile
|
||||||
|
* GET /api/participant/profile
|
||||||
|
*/
|
||||||
|
const getProfile = async (req, res) => {
|
||||||
|
const participant = await participantQueries.getParticipantById(req.participant.id);
|
||||||
|
|
||||||
|
if (!participant) {
|
||||||
|
throw new ApiError(404, 'Participant not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
id: participant.id,
|
||||||
|
pseudonym: participant.pseudonym,
|
||||||
|
eventId: participant.event_id,
|
||||||
|
eventName: participant.event_name,
|
||||||
|
createdAt: participant.created_at,
|
||||||
|
lastActive: participant.last_active
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
joinEvent,
|
||||||
|
getActiveEvents,
|
||||||
|
getProgress,
|
||||||
|
getProfile
|
||||||
|
};
|
||||||
106
backend/src/index.js
Normal file
106
backend/src/index.js
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const helmet = require('helmet');
|
||||||
|
const morgan = require('morgan');
|
||||||
|
const config = require('./config/environment');
|
||||||
|
const { pool } = require('./config/database');
|
||||||
|
const { errorHandler, notFoundHandler } = require('./middleware/errorHandler');
|
||||||
|
|
||||||
|
// Import routes
|
||||||
|
const participantRoutes = require('./routes/participant.routes');
|
||||||
|
const adminRoutes = require('./routes/admin.routes');
|
||||||
|
const lessonRoutes = require('./routes/lesson.routes');
|
||||||
|
|
||||||
|
// Initialize Express app
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// Security middleware
|
||||||
|
app.use(helmet());
|
||||||
|
|
||||||
|
// CORS configuration
|
||||||
|
app.use(cors({
|
||||||
|
origin: config.corsOrigin,
|
||||||
|
credentials: true
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Body parsing middleware
|
||||||
|
app.use(express.json({ limit: '10mb' }));
|
||||||
|
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||||
|
|
||||||
|
// Logging middleware
|
||||||
|
if (config.nodeEnv === 'development') {
|
||||||
|
app.use(morgan('dev'));
|
||||||
|
} else {
|
||||||
|
app.use(morgan('combined'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health check endpoint
|
||||||
|
app.get('/health', async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Check database connection
|
||||||
|
await pool.query('SELECT 1');
|
||||||
|
res.status(200).json({
|
||||||
|
status: 'healthy',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
environment: config.nodeEnv,
|
||||||
|
database: 'connected'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(503).json({
|
||||||
|
status: 'unhealthy',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
environment: config.nodeEnv,
|
||||||
|
database: 'disconnected',
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// API routes
|
||||||
|
app.get('/api', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
message: 'Security Awareness Learning Platform API',
|
||||||
|
version: '1.0.0',
|
||||||
|
endpoints: {
|
||||||
|
participant: '/api/participant',
|
||||||
|
admin: '/api/admin',
|
||||||
|
lessons: '/api/lessons'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mount routes
|
||||||
|
app.use('/api/participant', participantRoutes);
|
||||||
|
app.use('/api/admin', adminRoutes);
|
||||||
|
app.use('/api/lesson', lessonRoutes);
|
||||||
|
|
||||||
|
// 404 handler
|
||||||
|
app.use(notFoundHandler);
|
||||||
|
|
||||||
|
// Error handling middleware
|
||||||
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
const PORT = config.port;
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`
|
||||||
|
========================================
|
||||||
|
Security Awareness Learning Platform
|
||||||
|
========================================
|
||||||
|
Environment: ${config.nodeEnv}
|
||||||
|
Server running on port: ${PORT}
|
||||||
|
Database: ${config.database.host}:${config.database.port}/${config.database.name}
|
||||||
|
========================================
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
console.log('SIGTERM signal received: closing HTTP server');
|
||||||
|
pool.end(() => {
|
||||||
|
console.log('Database pool closed');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = app;
|
||||||
168
backend/src/middleware/auth.js
Normal file
168
backend/src/middleware/auth.js
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
const config = require('../config/environment');
|
||||||
|
const { ApiError } = require('./errorHandler');
|
||||||
|
const db = require('../config/database');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate JWT token for admin
|
||||||
|
*/
|
||||||
|
const generateAdminToken = (adminId, username) => {
|
||||||
|
return jwt.sign(
|
||||||
|
{ id: adminId, username, role: 'admin' },
|
||||||
|
config.jwtSecret,
|
||||||
|
{ expiresIn: '24h' }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate session token for participant
|
||||||
|
*/
|
||||||
|
const generateSessionToken = () => {
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
return uuidv4();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hash password with bcrypt
|
||||||
|
*/
|
||||||
|
const hashPassword = async (password) => {
|
||||||
|
return await bcrypt.hash(password, 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify password with bcrypt
|
||||||
|
*/
|
||||||
|
const verifyPassword = async (password, hashedPassword) => {
|
||||||
|
return await bcrypt.compare(password, hashedPassword);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware to verify admin JWT token
|
||||||
|
*/
|
||||||
|
const verifyAdminToken = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
// Get token from Authorization header
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
throw new ApiError(401, 'No token provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.substring(7); // Remove 'Bearer ' prefix
|
||||||
|
|
||||||
|
// Verify token
|
||||||
|
const decoded = jwt.verify(token, config.jwtSecret);
|
||||||
|
|
||||||
|
if (decoded.role !== 'admin') {
|
||||||
|
throw new ApiError(403, 'Access denied. Admin privileges required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify admin exists in database
|
||||||
|
const result = await db.query(
|
||||||
|
'SELECT id, username FROM admin_users WHERE id = $1',
|
||||||
|
[decoded.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new ApiError(401, 'Invalid token');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach admin info to request
|
||||||
|
req.admin = {
|
||||||
|
id: decoded.id,
|
||||||
|
username: decoded.username
|
||||||
|
};
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === 'JsonWebTokenError') {
|
||||||
|
next(new ApiError(401, 'Invalid token'));
|
||||||
|
} else if (error.name === 'TokenExpiredError') {
|
||||||
|
next(new ApiError(401, 'Token expired'));
|
||||||
|
} else {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware to verify participant session token
|
||||||
|
*/
|
||||||
|
const verifyParticipantToken = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
// Get token from Authorization header
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
throw new ApiError(401, 'No session token provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionToken = authHeader.substring(7);
|
||||||
|
|
||||||
|
// Verify token exists in database and get participant info
|
||||||
|
const result = await db.query(
|
||||||
|
`SELECT p.id, p.pseudonym, p.event_id, e.name as event_name, e.is_active
|
||||||
|
FROM participants p
|
||||||
|
JOIN events e ON e.id = p.event_id
|
||||||
|
WHERE p.session_token = $1`,
|
||||||
|
[sessionToken]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new ApiError(401, 'Invalid session token');
|
||||||
|
}
|
||||||
|
|
||||||
|
const participant = result.rows[0];
|
||||||
|
|
||||||
|
// Check if event is still active
|
||||||
|
if (!participant.is_active) {
|
||||||
|
throw new ApiError(403, 'Event is no longer active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last active timestamp
|
||||||
|
await db.query(
|
||||||
|
'UPDATE participants SET last_active = CURRENT_TIMESTAMP WHERE id = $1',
|
||||||
|
[participant.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Attach participant info to request
|
||||||
|
req.participant = {
|
||||||
|
id: participant.id,
|
||||||
|
pseudonym: participant.pseudonym,
|
||||||
|
eventId: participant.event_id,
|
||||||
|
eventName: participant.event_name
|
||||||
|
};
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional participant authentication (doesn't fail if no token)
|
||||||
|
*/
|
||||||
|
const optionalParticipantAuth = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
|
||||||
|
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||||
|
await verifyParticipantToken(req, res, next);
|
||||||
|
} else {
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
generateAdminToken,
|
||||||
|
generateSessionToken,
|
||||||
|
hashPassword,
|
||||||
|
verifyPassword,
|
||||||
|
verifyAdminToken,
|
||||||
|
verifyParticipantToken,
|
||||||
|
optionalParticipantAuth
|
||||||
|
};
|
||||||
73
backend/src/middleware/errorHandler.js
Normal file
73
backend/src/middleware/errorHandler.js
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
const config = require('../config/environment');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom error class for API errors
|
||||||
|
*/
|
||||||
|
class ApiError extends Error {
|
||||||
|
constructor(statusCode, message, errors = null) {
|
||||||
|
super(message);
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
this.errors = errors;
|
||||||
|
this.isOperational = true;
|
||||||
|
Error.captureStackTrace(this, this.constructor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error handling middleware
|
||||||
|
*/
|
||||||
|
const errorHandler = (err, req, res, next) => {
|
||||||
|
let { statusCode = 500, message, errors } = err;
|
||||||
|
|
||||||
|
// Log error
|
||||||
|
console.error('Error:', {
|
||||||
|
statusCode,
|
||||||
|
message,
|
||||||
|
path: req.path,
|
||||||
|
method: req.method,
|
||||||
|
...(config.nodeEnv === 'development' && { stack: err.stack })
|
||||||
|
});
|
||||||
|
|
||||||
|
// Don't leak error details in production
|
||||||
|
if (!err.isOperational && config.nodeEnv === 'production') {
|
||||||
|
message = 'Internal server error';
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(statusCode).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message,
|
||||||
|
...(errors && { errors }),
|
||||||
|
...(config.nodeEnv === 'development' && { stack: err.stack })
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Async error wrapper to catch errors in async route handlers
|
||||||
|
*/
|
||||||
|
const asyncHandler = (fn) => {
|
||||||
|
return (req, res, next) => {
|
||||||
|
Promise.resolve(fn(req, res, next)).catch(next);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 404 handler
|
||||||
|
*/
|
||||||
|
const notFoundHandler = (req, res) => {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Route not found',
|
||||||
|
path: req.path
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ApiError,
|
||||||
|
errorHandler,
|
||||||
|
asyncHandler,
|
||||||
|
notFoundHandler
|
||||||
|
};
|
||||||
167
backend/src/models/queries/event.queries.js
Normal file
167
backend/src/models/queries/event.queries.js
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
const db = require('../../config/database');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new event
|
||||||
|
*/
|
||||||
|
const createEvent = async (name, description, startDate, endDate) => {
|
||||||
|
const query = `
|
||||||
|
INSERT INTO events (name, description, start_date, end_date, is_active)
|
||||||
|
VALUES ($1, $2, $3, $4, true)
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(query, [name, description, startDate, endDate]);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get event by ID
|
||||||
|
*/
|
||||||
|
const getEventById = async (eventId) => {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
e.*,
|
||||||
|
COUNT(DISTINCT p.id) as participant_count,
|
||||||
|
COUNT(DISTINCT el.id) as lesson_count
|
||||||
|
FROM events e
|
||||||
|
LEFT JOIN participants p ON p.event_id = e.id
|
||||||
|
LEFT JOIN event_lessons el ON el.event_id = e.id
|
||||||
|
WHERE e.id = $1
|
||||||
|
GROUP BY e.id
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(query, [eventId]);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all events
|
||||||
|
*/
|
||||||
|
const getAllEvents = async () => {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
e.*,
|
||||||
|
COUNT(DISTINCT p.id) as participant_count,
|
||||||
|
COUNT(DISTINCT el.id) as lesson_count
|
||||||
|
FROM events e
|
||||||
|
LEFT JOIN participants p ON p.event_id = e.id
|
||||||
|
LEFT JOIN event_lessons el ON el.event_id = e.id
|
||||||
|
GROUP BY e.id
|
||||||
|
ORDER BY e.created_at DESC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(query);
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get active events (for participant view)
|
||||||
|
*/
|
||||||
|
const getActiveEvents = async () => {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
e.id,
|
||||||
|
e.name,
|
||||||
|
e.description,
|
||||||
|
e.start_date,
|
||||||
|
e.end_date,
|
||||||
|
COUNT(DISTINCT el.id) as lesson_count
|
||||||
|
FROM events e
|
||||||
|
LEFT JOIN event_lessons el ON el.event_id = e.id
|
||||||
|
WHERE e.is_active = true
|
||||||
|
GROUP BY e.id
|
||||||
|
ORDER BY e.start_date DESC NULLS LAST, e.created_at DESC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(query);
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update event
|
||||||
|
*/
|
||||||
|
const updateEvent = async (eventId, updates) => {
|
||||||
|
const allowedFields = ['name', 'description', 'start_date', 'end_date', 'is_active'];
|
||||||
|
const fields = [];
|
||||||
|
const values = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
Object.keys(updates).forEach(key => {
|
||||||
|
if (allowedFields.includes(key) && updates[key] !== undefined) {
|
||||||
|
fields.push(`${key} = $${paramIndex}`);
|
||||||
|
values.push(updates[key]);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (fields.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(eventId);
|
||||||
|
const query = `
|
||||||
|
UPDATE events
|
||||||
|
SET ${fields.join(', ')}, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $${paramIndex}
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(query, values);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete event (cascades to participants and progress)
|
||||||
|
*/
|
||||||
|
const deleteEvent = async (eventId) => {
|
||||||
|
const query = 'DELETE FROM events WHERE id = $1 RETURNING id';
|
||||||
|
const result = await db.query(query, [eventId]);
|
||||||
|
return result.rows.length > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if event exists
|
||||||
|
*/
|
||||||
|
const eventExists = async (eventId) => {
|
||||||
|
const query = 'SELECT id FROM events WHERE id = $1';
|
||||||
|
const result = await db.query(query, [eventId]);
|
||||||
|
return result.rows.length > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get event statistics
|
||||||
|
*/
|
||||||
|
const getEventStatistics = async (eventId) => {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
e.id,
|
||||||
|
e.name,
|
||||||
|
COUNT(DISTINCT p.id) as total_participants,
|
||||||
|
COUNT(DISTINCT el.id) as total_lessons,
|
||||||
|
COUNT(DISTINCT lp.id) as total_lesson_starts,
|
||||||
|
COUNT(DISTINCT CASE WHEN lp.status = 'completed' THEN lp.id END) as total_completions,
|
||||||
|
ROUND(AVG(lp.score), 2) as average_score,
|
||||||
|
MAX(lp.score) as highest_score,
|
||||||
|
MIN(lp.score) as lowest_score
|
||||||
|
FROM events e
|
||||||
|
LEFT JOIN participants p ON p.event_id = e.id
|
||||||
|
LEFT JOIN event_lessons el ON el.event_id = e.id
|
||||||
|
LEFT JOIN lesson_progress lp ON lp.participant_id = p.id
|
||||||
|
WHERE e.id = $1
|
||||||
|
GROUP BY e.id, e.name
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(query, [eventId]);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createEvent,
|
||||||
|
getEventById,
|
||||||
|
getAllEvents,
|
||||||
|
getActiveEvents,
|
||||||
|
updateEvent,
|
||||||
|
deleteEvent,
|
||||||
|
eventExists,
|
||||||
|
getEventStatistics
|
||||||
|
};
|
||||||
229
backend/src/models/queries/lesson.queries.js
Normal file
229
backend/src/models/queries/lesson.queries.js
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
const db = require('../../config/database');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new lesson in the catalog
|
||||||
|
*/
|
||||||
|
const createLesson = async (lessonKey, title, description, modulePath, configPath, difficultyLevel, estimatedDuration) => {
|
||||||
|
const query = `
|
||||||
|
INSERT INTO lessons (lesson_key, title, description, module_path, config_path, difficulty_level, estimated_duration)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(query, [
|
||||||
|
lessonKey,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
modulePath,
|
||||||
|
configPath,
|
||||||
|
difficultyLevel,
|
||||||
|
estimatedDuration
|
||||||
|
]);
|
||||||
|
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get lesson by ID
|
||||||
|
*/
|
||||||
|
const getLessonById = async (lessonId) => {
|
||||||
|
const query = 'SELECT * FROM lessons WHERE id = $1';
|
||||||
|
const result = await db.query(query, [lessonId]);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get lesson by key
|
||||||
|
*/
|
||||||
|
const getLessonByKey = async (lessonKey) => {
|
||||||
|
const query = 'SELECT * FROM lessons WHERE lesson_key = $1';
|
||||||
|
const result = await db.query(query, [lessonKey]);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all lessons
|
||||||
|
*/
|
||||||
|
const getAllLessons = async () => {
|
||||||
|
const query = `
|
||||||
|
SELECT * FROM lessons
|
||||||
|
ORDER BY title ASC
|
||||||
|
`;
|
||||||
|
const result = await db.query(query);
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assign lesson to event
|
||||||
|
*/
|
||||||
|
const assignLessonToEvent = async (eventId, lessonId, orderIndex, maxPoints, weight, isRequired, unlockAfterLessonId) => {
|
||||||
|
const query = `
|
||||||
|
INSERT INTO event_lessons (event_id, lesson_id, order_index, max_points, weight, is_required, unlock_after_lesson_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(query, [
|
||||||
|
eventId,
|
||||||
|
lessonId,
|
||||||
|
orderIndex,
|
||||||
|
maxPoints || 100,
|
||||||
|
weight || 1.0,
|
||||||
|
isRequired !== undefined ? isRequired : true,
|
||||||
|
unlockAfterLessonId || null
|
||||||
|
]);
|
||||||
|
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get lessons for an event
|
||||||
|
*/
|
||||||
|
const getEventLessons = async (eventId) => {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
el.id as event_lesson_id,
|
||||||
|
el.order_index,
|
||||||
|
el.max_points,
|
||||||
|
el.weight,
|
||||||
|
el.is_required,
|
||||||
|
el.unlock_after_lesson_id,
|
||||||
|
l.*
|
||||||
|
FROM event_lessons el
|
||||||
|
JOIN lessons l ON l.id = el.lesson_id
|
||||||
|
WHERE el.event_id = $1
|
||||||
|
ORDER BY el.order_index ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(query, [eventId]);
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get lessons for an event with participant progress
|
||||||
|
*/
|
||||||
|
const getEventLessonsWithProgress = async (eventId, participantId) => {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
el.id as event_lesson_id,
|
||||||
|
el.order_index,
|
||||||
|
el.max_points,
|
||||||
|
el.weight,
|
||||||
|
el.is_required,
|
||||||
|
el.unlock_after_lesson_id,
|
||||||
|
l.*,
|
||||||
|
lp.id as progress_id,
|
||||||
|
lp.status,
|
||||||
|
lp.score,
|
||||||
|
lp.attempts,
|
||||||
|
lp.started_at,
|
||||||
|
lp.completed_at,
|
||||||
|
CASE
|
||||||
|
WHEN el.unlock_after_lesson_id IS NULL THEN true
|
||||||
|
WHEN EXISTS (
|
||||||
|
SELECT 1 FROM lesson_progress lp2
|
||||||
|
JOIN event_lessons el2 ON el2.id = lp2.event_lesson_id
|
||||||
|
WHERE lp2.participant_id = $2
|
||||||
|
AND el2.lesson_id = el.unlock_after_lesson_id
|
||||||
|
AND lp2.status = 'completed'
|
||||||
|
) THEN true
|
||||||
|
ELSE false
|
||||||
|
END as is_unlocked
|
||||||
|
FROM event_lessons el
|
||||||
|
JOIN lessons l ON l.id = el.lesson_id
|
||||||
|
LEFT JOIN lesson_progress lp ON lp.event_lesson_id = el.id AND lp.participant_id = $2
|
||||||
|
WHERE el.event_id = $1
|
||||||
|
ORDER BY el.order_index ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(query, [eventId, participantId]);
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get event lesson by ID
|
||||||
|
*/
|
||||||
|
const getEventLessonById = async (eventLessonId) => {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
el.*,
|
||||||
|
l.lesson_key,
|
||||||
|
l.title,
|
||||||
|
l.description,
|
||||||
|
l.module_path,
|
||||||
|
l.config_path,
|
||||||
|
l.difficulty_level,
|
||||||
|
l.estimated_duration
|
||||||
|
FROM event_lessons el
|
||||||
|
JOIN lessons l ON l.id = el.lesson_id
|
||||||
|
WHERE el.id = $1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(query, [eventLessonId]);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update event lesson configuration
|
||||||
|
*/
|
||||||
|
const updateEventLesson = async (eventLessonId, updates) => {
|
||||||
|
const allowedFields = ['order_index', 'max_points', 'weight', 'is_required', 'unlock_after_lesson_id'];
|
||||||
|
const fields = [];
|
||||||
|
const values = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
Object.keys(updates).forEach(key => {
|
||||||
|
if (allowedFields.includes(key) && updates[key] !== undefined) {
|
||||||
|
fields.push(`${key} = $${paramIndex}`);
|
||||||
|
values.push(updates[key]);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (fields.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(eventLessonId);
|
||||||
|
const query = `
|
||||||
|
UPDATE event_lessons
|
||||||
|
SET ${fields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex}
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(query, values);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove lesson from event
|
||||||
|
*/
|
||||||
|
const removeEventLesson = async (eventLessonId) => {
|
||||||
|
const query = 'DELETE FROM event_lessons WHERE id = $1 RETURNING id';
|
||||||
|
const result = await db.query(query, [eventLessonId]);
|
||||||
|
return result.rows.length > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if lesson exists
|
||||||
|
*/
|
||||||
|
const lessonExists = async (lessonId) => {
|
||||||
|
const query = 'SELECT id FROM lessons WHERE id = $1';
|
||||||
|
const result = await db.query(query, [lessonId]);
|
||||||
|
return result.rows.length > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createLesson,
|
||||||
|
getLessonById,
|
||||||
|
getLessonByKey,
|
||||||
|
getAllLessons,
|
||||||
|
assignLessonToEvent,
|
||||||
|
getEventLessons,
|
||||||
|
getEventLessonsWithProgress,
|
||||||
|
getEventLessonById,
|
||||||
|
updateEventLesson,
|
||||||
|
removeEventLesson,
|
||||||
|
lessonExists
|
||||||
|
};
|
||||||
142
backend/src/models/queries/participant.queries.js
Normal file
142
backend/src/models/queries/participant.queries.js
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
const db = require('../../config/database');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new participant
|
||||||
|
*/
|
||||||
|
const createParticipant = async (pseudonym, eventId, sessionToken) => {
|
||||||
|
const query = `
|
||||||
|
INSERT INTO participants (pseudonym, event_id, session_token)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING id, pseudonym, event_id, session_token, created_at
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(query, [pseudonym, eventId, sessionToken]);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get participant by ID
|
||||||
|
*/
|
||||||
|
const getParticipantById = async (participantId) => {
|
||||||
|
const query = `
|
||||||
|
SELECT p.*, e.name as event_name, e.is_active as event_active
|
||||||
|
FROM participants p
|
||||||
|
JOIN events e ON e.id = p.event_id
|
||||||
|
WHERE p.id = $1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(query, [participantId]);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get participant by session token
|
||||||
|
*/
|
||||||
|
const getParticipantByToken = async (sessionToken) => {
|
||||||
|
const query = `
|
||||||
|
SELECT p.*, e.name as event_name, e.is_active as event_active
|
||||||
|
FROM participants p
|
||||||
|
JOIN events e ON e.id = p.event_id
|
||||||
|
WHERE p.session_token = $1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(query, [sessionToken]);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if pseudonym exists in event
|
||||||
|
*/
|
||||||
|
const pseudonymExists = async (pseudonym, eventId) => {
|
||||||
|
const query = `
|
||||||
|
SELECT id FROM participants
|
||||||
|
WHERE pseudonym = $1 AND event_id = $2
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(query, [pseudonym, eventId]);
|
||||||
|
return result.rows.length > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all participants for an event
|
||||||
|
*/
|
||||||
|
const getParticipantsByEvent = async (eventId) => {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
p.id,
|
||||||
|
p.pseudonym,
|
||||||
|
p.created_at,
|
||||||
|
p.last_active,
|
||||||
|
COUNT(DISTINCT lp.id) as total_lessons_started,
|
||||||
|
COUNT(DISTINCT CASE WHEN lp.status = 'completed' THEN lp.id END) as lessons_completed,
|
||||||
|
COALESCE(SUM(lp.score), 0) as total_score
|
||||||
|
FROM participants p
|
||||||
|
LEFT JOIN lesson_progress lp ON lp.participant_id = p.id
|
||||||
|
WHERE p.event_id = $1
|
||||||
|
GROUP BY p.id, p.pseudonym, p.created_at, p.last_active
|
||||||
|
ORDER BY total_score DESC, p.pseudonym ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(query, [eventId]);
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get participant progress summary
|
||||||
|
*/
|
||||||
|
const getParticipantProgress = async (participantId) => {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
p.pseudonym,
|
||||||
|
p.event_id,
|
||||||
|
e.name as event_name,
|
||||||
|
COUNT(DISTINCT lp.id) as total_lessons_started,
|
||||||
|
COUNT(DISTINCT CASE WHEN lp.status = 'completed' THEN lp.id END) as lessons_completed,
|
||||||
|
COALESCE(SUM(lp.score), 0) as total_score,
|
||||||
|
COUNT(DISTINCT el.id) as total_lessons_available
|
||||||
|
FROM participants p
|
||||||
|
JOIN events e ON e.id = p.event_id
|
||||||
|
LEFT JOIN event_lessons el ON el.event_id = p.event_id
|
||||||
|
LEFT JOIN lesson_progress lp ON lp.participant_id = p.id
|
||||||
|
WHERE p.id = $1
|
||||||
|
GROUP BY p.id, p.pseudonym, p.event_id, e.name
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(query, [participantId]);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete participant
|
||||||
|
*/
|
||||||
|
const deleteParticipant = async (participantId) => {
|
||||||
|
const query = 'DELETE FROM participants WHERE id = $1 RETURNING id';
|
||||||
|
const result = await db.query(query, [participantId]);
|
||||||
|
return result.rows.length > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update last active timestamp
|
||||||
|
*/
|
||||||
|
const updateLastActive = async (participantId) => {
|
||||||
|
const query = `
|
||||||
|
UPDATE participants
|
||||||
|
SET last_active = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING last_active
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(query, [participantId]);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createParticipant,
|
||||||
|
getParticipantById,
|
||||||
|
getParticipantByToken,
|
||||||
|
pseudonymExists,
|
||||||
|
getParticipantsByEvent,
|
||||||
|
getParticipantProgress,
|
||||||
|
deleteParticipant,
|
||||||
|
updateLastActive
|
||||||
|
};
|
||||||
206
backend/src/models/queries/progress.queries.js
Normal file
206
backend/src/models/queries/progress.queries.js
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
const db = require('../../config/database');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a lesson (create or update progress record)
|
||||||
|
*/
|
||||||
|
const startLesson = async (participantId, eventLessonId) => {
|
||||||
|
const query = `
|
||||||
|
INSERT INTO lesson_progress (participant_id, event_lesson_id, status, started_at, current_step)
|
||||||
|
VALUES ($1, $2, 'in_progress', CURRENT_TIMESTAMP, 0)
|
||||||
|
ON CONFLICT (participant_id, event_lesson_id)
|
||||||
|
DO UPDATE SET
|
||||||
|
status = 'in_progress',
|
||||||
|
started_at = COALESCE(lesson_progress.started_at, CURRENT_TIMESTAMP),
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(query, [participantId, eventLessonId]);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update current step in lesson
|
||||||
|
*/
|
||||||
|
const updateStep = async (progressId, stepIndex) => {
|
||||||
|
const query = `
|
||||||
|
UPDATE lesson_progress
|
||||||
|
SET current_step = $1, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(query, [stepIndex, progressId]);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete a lesson
|
||||||
|
*/
|
||||||
|
const completeLesson = async (progressId) => {
|
||||||
|
const query = `
|
||||||
|
UPDATE lesson_progress
|
||||||
|
SET status = 'completed', completed_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(query, [progressId]);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get progress for a specific lesson
|
||||||
|
*/
|
||||||
|
const getLessonProgress = async (participantId, eventLessonId) => {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
lp.*,
|
||||||
|
el.max_points,
|
||||||
|
el.weight,
|
||||||
|
l.title as lesson_title,
|
||||||
|
l.lesson_key
|
||||||
|
FROM lesson_progress lp
|
||||||
|
JOIN event_lessons el ON el.id = lp.event_lesson_id
|
||||||
|
JOIN lessons l ON l.id = el.lesson_id
|
||||||
|
WHERE lp.participant_id = $1 AND lp.event_lesson_id = $2
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(query, [participantId, eventLessonId]);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get progress by ID
|
||||||
|
*/
|
||||||
|
const getProgressById = async (progressId) => {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
lp.*,
|
||||||
|
el.max_points,
|
||||||
|
el.weight,
|
||||||
|
l.lesson_key
|
||||||
|
FROM lesson_progress lp
|
||||||
|
JOIN event_lessons el ON el.id = lp.event_lesson_id
|
||||||
|
JOIN lessons l ON l.id = el.lesson_id
|
||||||
|
WHERE lp.id = $1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(query, [progressId]);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all progress for a participant
|
||||||
|
*/
|
||||||
|
const getParticipantProgress = async (participantId) => {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
lp.*,
|
||||||
|
el.max_points,
|
||||||
|
el.weight,
|
||||||
|
el.order_index,
|
||||||
|
l.lesson_key,
|
||||||
|
l.title,
|
||||||
|
l.description,
|
||||||
|
l.difficulty_level
|
||||||
|
FROM lesson_progress lp
|
||||||
|
JOIN event_lessons el ON el.id = lp.event_lesson_id
|
||||||
|
JOIN lessons l ON l.id = el.lesson_id
|
||||||
|
WHERE lp.participant_id = $1
|
||||||
|
ORDER BY el.order_index
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(query, [participantId]);
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save an answer
|
||||||
|
*/
|
||||||
|
const saveAnswer = async (progressId, questionKey, answerData, isCorrect, pointsAwarded, feedback) => {
|
||||||
|
const query = `
|
||||||
|
INSERT INTO lesson_answers (lesson_progress_id, question_key, answer_data, is_correct, points_awarded, feedback)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(query, [
|
||||||
|
progressId,
|
||||||
|
questionKey,
|
||||||
|
JSON.stringify(answerData),
|
||||||
|
isCorrect,
|
||||||
|
pointsAwarded,
|
||||||
|
feedback
|
||||||
|
]);
|
||||||
|
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update score for lesson progress
|
||||||
|
*/
|
||||||
|
const updateScore = async (progressId, pointsToAdd) => {
|
||||||
|
const query = `
|
||||||
|
UPDATE lesson_progress
|
||||||
|
SET score = score + $1, attempts = attempts + 1, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2
|
||||||
|
RETURNING score
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(query, [pointsToAdd, progressId]);
|
||||||
|
return result.rows[0]?.score || 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get answers for a lesson progress
|
||||||
|
*/
|
||||||
|
const getAnswers = async (progressId) => {
|
||||||
|
const query = `
|
||||||
|
SELECT * FROM lesson_answers
|
||||||
|
WHERE lesson_progress_id = $1
|
||||||
|
ORDER BY submitted_at ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(query, [progressId]);
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if lesson is unlocked for participant
|
||||||
|
*/
|
||||||
|
const isLessonUnlocked = async (participantId, eventLessonId) => {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
el.unlock_after_lesson_id,
|
||||||
|
CASE
|
||||||
|
WHEN el.unlock_after_lesson_id IS NULL THEN true
|
||||||
|
WHEN EXISTS (
|
||||||
|
SELECT 1 FROM lesson_progress lp2
|
||||||
|
JOIN event_lessons el2 ON el2.id = lp2.event_lesson_id
|
||||||
|
WHERE lp2.participant_id = $1
|
||||||
|
AND el2.lesson_id = el.unlock_after_lesson_id
|
||||||
|
AND lp2.status = 'completed'
|
||||||
|
) THEN true
|
||||||
|
ELSE false
|
||||||
|
END as is_unlocked
|
||||||
|
FROM event_lessons el
|
||||||
|
WHERE el.id = $2
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(query, [participantId, eventLessonId]);
|
||||||
|
return result.rows[0]?.is_unlocked || false;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
startLesson,
|
||||||
|
updateStep,
|
||||||
|
completeLesson,
|
||||||
|
getLessonProgress,
|
||||||
|
getProgressById,
|
||||||
|
getParticipantProgress,
|
||||||
|
saveAnswer,
|
||||||
|
updateScore,
|
||||||
|
getAnswers,
|
||||||
|
isLessonUnlocked
|
||||||
|
};
|
||||||
32
backend/src/routes/admin.routes.js
Normal file
32
backend/src/routes/admin.routes.js
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { asyncHandler } = require('../middleware/errorHandler');
|
||||||
|
const { verifyAdminToken } = require('../middleware/auth');
|
||||||
|
const adminController = require('../controllers/admin.controller');
|
||||||
|
const eventController = require('../controllers/event.controller');
|
||||||
|
const adminLessonController = require('../controllers/adminLesson.controller');
|
||||||
|
|
||||||
|
// Admin authentication routes
|
||||||
|
router.post('/login', asyncHandler(adminController.login));
|
||||||
|
router.get('/profile', verifyAdminToken, asyncHandler(adminController.getProfile));
|
||||||
|
router.get('/verify', verifyAdminToken, asyncHandler(adminController.verifyToken));
|
||||||
|
|
||||||
|
// Event management routes (admin only)
|
||||||
|
router.get('/events', verifyAdminToken, asyncHandler(eventController.getAllEvents));
|
||||||
|
router.post('/events', verifyAdminToken, asyncHandler(eventController.createEvent));
|
||||||
|
router.get('/events/:eventId', verifyAdminToken, asyncHandler(eventController.getEventById));
|
||||||
|
router.put('/events/:eventId', verifyAdminToken, asyncHandler(eventController.updateEvent));
|
||||||
|
router.delete('/events/:eventId', verifyAdminToken, asyncHandler(eventController.deleteEvent));
|
||||||
|
|
||||||
|
// Event participant management
|
||||||
|
router.get('/events/:eventId/participants', verifyAdminToken, asyncHandler(eventController.getEventParticipants));
|
||||||
|
router.get('/events/:eventId/analytics', verifyAdminToken, asyncHandler(eventController.getEventAnalytics));
|
||||||
|
|
||||||
|
// Lesson management routes (admin only)
|
||||||
|
router.get('/lessons', verifyAdminToken, asyncHandler(adminLessonController.getAllLessons));
|
||||||
|
router.post('/events/:eventId/lessons', verifyAdminToken, asyncHandler(adminLessonController.assignLessonToEvent));
|
||||||
|
router.get('/events/:eventId/lessons', verifyAdminToken, asyncHandler(adminLessonController.getEventLessons));
|
||||||
|
router.put('/events/:eventId/lessons/:eventLessonId', verifyAdminToken, asyncHandler(adminLessonController.updateEventLesson));
|
||||||
|
router.delete('/events/:eventId/lessons/:eventLessonId', verifyAdminToken, asyncHandler(adminLessonController.removeEventLesson));
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
31
backend/src/routes/lesson.routes.js
Normal file
31
backend/src/routes/lesson.routes.js
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { asyncHandler } = require('../middleware/errorHandler');
|
||||||
|
const { verifyParticipantToken } = require('../middleware/auth');
|
||||||
|
const lessonController = require('../controllers/lesson.controller');
|
||||||
|
|
||||||
|
// All lesson routes require participant authentication
|
||||||
|
router.use(verifyParticipantToken);
|
||||||
|
|
||||||
|
// Get lessons for an event
|
||||||
|
router.get('/event/:eventId/lessons', asyncHandler(lessonController.getEventLessons));
|
||||||
|
|
||||||
|
// Get lesson content
|
||||||
|
router.get('/:eventLessonId', asyncHandler(lessonController.getLessonContent));
|
||||||
|
|
||||||
|
// Start a lesson
|
||||||
|
router.post('/:eventLessonId/start', asyncHandler(lessonController.startLesson));
|
||||||
|
|
||||||
|
// Submit an answer
|
||||||
|
router.post('/:eventLessonId/answer', asyncHandler(lessonController.submitAnswer));
|
||||||
|
|
||||||
|
// Complete a lesson
|
||||||
|
router.post('/:eventLessonId/complete', asyncHandler(lessonController.completeLesson));
|
||||||
|
|
||||||
|
// Execute lesson-specific action
|
||||||
|
router.post('/:eventLessonId/action/:action', asyncHandler(lessonController.executeLessonAction));
|
||||||
|
|
||||||
|
// Get interactive component data
|
||||||
|
router.get('/:lessonKey/interactive/:stepId', asyncHandler(lessonController.getInteractiveData));
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
15
backend/src/routes/participant.routes.js
Normal file
15
backend/src/routes/participant.routes.js
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { asyncHandler } = require('../middleware/errorHandler');
|
||||||
|
const { verifyParticipantToken } = require('../middleware/auth');
|
||||||
|
const participantController = require('../controllers/participant.controller');
|
||||||
|
|
||||||
|
// Public routes (no authentication required)
|
||||||
|
router.post('/join', asyncHandler(participantController.joinEvent));
|
||||||
|
router.get('/events', asyncHandler(participantController.getActiveEvents));
|
||||||
|
|
||||||
|
// Protected routes (require participant session token)
|
||||||
|
router.get('/profile', verifyParticipantToken, asyncHandler(participantController.getProfile));
|
||||||
|
router.get('/progress', verifyParticipantToken, asyncHandler(participantController.getProgress));
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
131
backend/src/services/lessonLoader.service.js
Normal file
131
backend/src/services/lessonLoader.service.js
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const yaml = require('js-yaml');
|
||||||
|
const config = require('../config/environment');
|
||||||
|
|
||||||
|
// Cache for loaded lessons
|
||||||
|
const lessonCache = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a lesson module and its configuration
|
||||||
|
*/
|
||||||
|
const loadLesson = async (lessonKey) => {
|
||||||
|
// Check cache first
|
||||||
|
if (lessonCache.has(lessonKey)) {
|
||||||
|
return lessonCache.get(lessonKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Load YAML configuration
|
||||||
|
const configPath = path.join(
|
||||||
|
process.cwd(),
|
||||||
|
config.lessonsPath,
|
||||||
|
'configs',
|
||||||
|
`${lessonKey}.yaml`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!fs.existsSync(configPath)) {
|
||||||
|
throw new Error(`Lesson configuration not found: ${lessonKey}.yaml`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const configContent = fs.readFileSync(configPath, 'utf8');
|
||||||
|
const lessonConfig = yaml.load(configContent);
|
||||||
|
|
||||||
|
// Validate config has required fields
|
||||||
|
if (!lessonConfig.lessonKey || !lessonConfig.title || !lessonConfig.module) {
|
||||||
|
throw new Error(`Invalid lesson configuration for ${lessonKey}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load JavaScript module
|
||||||
|
const modulePath = path.join(
|
||||||
|
process.cwd(),
|
||||||
|
config.lessonsPath,
|
||||||
|
'modules',
|
||||||
|
lessonConfig.module,
|
||||||
|
'index.js'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!fs.existsSync(modulePath)) {
|
||||||
|
throw new Error(`Lesson module not found: ${lessonConfig.module}/index.js`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Require the module
|
||||||
|
const LessonClass = require(modulePath);
|
||||||
|
|
||||||
|
// Instantiate the lesson module with config
|
||||||
|
const lessonInstance = new LessonClass(lessonConfig);
|
||||||
|
|
||||||
|
// Cache the instance
|
||||||
|
lessonCache.set(lessonKey, lessonInstance);
|
||||||
|
|
||||||
|
return lessonInstance;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error loading lesson ${lessonKey}:`, error);
|
||||||
|
throw new Error(`Failed to load lesson: ${error.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get lesson content (for rendering to participant)
|
||||||
|
*/
|
||||||
|
const getLessonContent = async (lessonKey) => {
|
||||||
|
const lesson = await loadLesson(lessonKey);
|
||||||
|
return lesson.getContent();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate an answer using the lesson module
|
||||||
|
*/
|
||||||
|
const validateAnswer = async (lessonKey, questionId, answer) => {
|
||||||
|
const lesson = await loadLesson(lessonKey);
|
||||||
|
return await lesson.validateAnswer(questionId, answer);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get interactive component data
|
||||||
|
*/
|
||||||
|
const getInteractiveData = async (lessonKey, stepId) => {
|
||||||
|
const lesson = await loadLesson(lessonKey);
|
||||||
|
return await lesson.getInteractiveData(stepId);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear lesson cache (useful for development/testing)
|
||||||
|
*/
|
||||||
|
const clearCache = () => {
|
||||||
|
lessonCache.clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reload a specific lesson from disk
|
||||||
|
*/
|
||||||
|
const reloadLesson = async (lessonKey) => {
|
||||||
|
lessonCache.delete(lessonKey);
|
||||||
|
return await loadLesson(lessonKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all available lesson configurations
|
||||||
|
*/
|
||||||
|
const listAvailableLessons = () => {
|
||||||
|
const configsPath = path.join(process.cwd(), config.lessonsPath, 'configs');
|
||||||
|
|
||||||
|
if (!fs.existsSync(configsPath)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = fs.readdirSync(configsPath);
|
||||||
|
return files
|
||||||
|
.filter(file => file.endsWith('.yaml') || file.endsWith('.yml'))
|
||||||
|
.map(file => file.replace(/\.(yaml|yml)$/, ''));
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
loadLesson,
|
||||||
|
getLessonContent,
|
||||||
|
validateAnswer,
|
||||||
|
getInteractiveData,
|
||||||
|
clearCache,
|
||||||
|
reloadLesson,
|
||||||
|
listAvailableLessons
|
||||||
|
};
|
||||||
155
backend/src/services/scoring.service.js
Normal file
155
backend/src/services/scoring.service.js
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
const db = require('../config/database');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate total score for a participant in an event
|
||||||
|
*/
|
||||||
|
const calculateTotalScore = async (participantId) => {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
SUM(lp.score) as total_score,
|
||||||
|
SUM(el.max_points * el.weight) as max_possible_score,
|
||||||
|
COUNT(DISTINCT el.id) as total_lessons,
|
||||||
|
COUNT(DISTINCT CASE WHEN lp.status = 'completed' THEN el.id END) as completed_lessons
|
||||||
|
FROM participants p
|
||||||
|
JOIN event_lessons el ON el.event_id = p.event_id
|
||||||
|
LEFT JOIN lesson_progress lp ON lp.participant_id = p.id AND lp.event_lesson_id = el.id
|
||||||
|
WHERE p.id = $1
|
||||||
|
GROUP BY p.id
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(query, [participantId]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return {
|
||||||
|
totalScore: 0,
|
||||||
|
maxPossibleScore: 0,
|
||||||
|
percentage: 0,
|
||||||
|
completedLessons: 0,
|
||||||
|
totalLessons: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = result.rows[0];
|
||||||
|
return {
|
||||||
|
totalScore: parseInt(data.total_score) || 0,
|
||||||
|
maxPossibleScore: parseFloat(data.max_possible_score) || 0,
|
||||||
|
percentage: data.max_possible_score > 0
|
||||||
|
? ((data.total_score / data.max_possible_score) * 100).toFixed(2)
|
||||||
|
: 0,
|
||||||
|
completedLessons: parseInt(data.completed_lessons) || 0,
|
||||||
|
totalLessons: parseInt(data.total_lessons) || 0
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate weighted score for a specific lesson
|
||||||
|
*/
|
||||||
|
const calculateLessonScore = async (progressId) => {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
lp.score,
|
||||||
|
el.max_points,
|
||||||
|
el.weight
|
||||||
|
FROM lesson_progress lp
|
||||||
|
JOIN event_lessons el ON el.id = lp.event_lesson_id
|
||||||
|
WHERE lp.id = $1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(query, [progressId]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return { score: 0, weightedScore: 0, percentage: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = result.rows[0];
|
||||||
|
const percentage = (data.score / data.max_points) * 100;
|
||||||
|
const weightedScore = (data.score / data.max_points) * data.max_points * data.weight;
|
||||||
|
|
||||||
|
return {
|
||||||
|
score: data.score,
|
||||||
|
maxPoints: data.max_points,
|
||||||
|
weight: data.weight,
|
||||||
|
percentage: percentage.toFixed(2),
|
||||||
|
weightedScore: weightedScore.toFixed(2)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get leaderboard for an event
|
||||||
|
*/
|
||||||
|
const getEventLeaderboard = async (eventId, limit = 10) => {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
p.pseudonym,
|
||||||
|
SUM(lp.score) as total_score,
|
||||||
|
COUNT(DISTINCT CASE WHEN lp.status = 'completed' THEN lp.id END) as completed_lessons,
|
||||||
|
MAX(lp.updated_at) as last_activity
|
||||||
|
FROM participants p
|
||||||
|
LEFT JOIN lesson_progress lp ON lp.participant_id = p.id
|
||||||
|
WHERE p.event_id = $1
|
||||||
|
GROUP BY p.id, p.pseudonym
|
||||||
|
ORDER BY total_score DESC, completed_lessons DESC, last_activity DESC
|
||||||
|
LIMIT $2
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(query, [eventId, limit]);
|
||||||
|
|
||||||
|
return result.rows.map((row, index) => ({
|
||||||
|
rank: index + 1,
|
||||||
|
pseudonym: row.pseudonym,
|
||||||
|
totalScore: parseInt(row.total_score) || 0,
|
||||||
|
completedLessons: parseInt(row.completed_lessons) || 0,
|
||||||
|
lastActivity: row.last_activity
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Award points for a correct answer
|
||||||
|
*/
|
||||||
|
const awardPoints = async (progressId, points) => {
|
||||||
|
const query = `
|
||||||
|
UPDATE lesson_progress
|
||||||
|
SET score = score + $1, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2
|
||||||
|
RETURNING score
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(query, [points, progressId]);
|
||||||
|
return result.rows[0]?.score || 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if participant passed the lesson
|
||||||
|
*/
|
||||||
|
const checkLessonPassed = async (progressId) => {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
lp.score,
|
||||||
|
el.max_points,
|
||||||
|
l.lesson_key
|
||||||
|
FROM lesson_progress lp
|
||||||
|
JOIN event_lessons el ON el.id = lp.event_lesson_id
|
||||||
|
JOIN lessons l ON l.id = el.lesson_id
|
||||||
|
WHERE lp.id = $1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(query, [progressId]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = result.rows[0];
|
||||||
|
const percentage = (data.score / data.max_points) * 100;
|
||||||
|
|
||||||
|
// Default passing threshold is 70%
|
||||||
|
return percentage >= 70;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
calculateTotalScore,
|
||||||
|
calculateLessonScore,
|
||||||
|
getEventLeaderboard,
|
||||||
|
awardPoints,
|
||||||
|
checkLessonPassed
|
||||||
|
};
|
||||||
61
backend/src/utils/seedLessons.js
Normal file
61
backend/src/utils/seedLessons.js
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
const db = require('../config/database');
|
||||||
|
const lessonQueries = require('../models/queries/lesson.queries');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed lessons into the database
|
||||||
|
*/
|
||||||
|
const seedLessons = async () => {
|
||||||
|
const lessons = [
|
||||||
|
{
|
||||||
|
lessonKey: 'phishing-email-basics',
|
||||||
|
title: 'Phishing Email Detection Basics',
|
||||||
|
description: 'Learn to identify common phishing tactics in emails and protect yourself from email-based attacks',
|
||||||
|
modulePath: 'phishing-email-basics',
|
||||||
|
configPath: 'phishing-email-basics.yaml',
|
||||||
|
difficultyLevel: 'beginner',
|
||||||
|
estimatedDuration: 15
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const lesson of lessons) {
|
||||||
|
try {
|
||||||
|
// Check if lesson already exists
|
||||||
|
const existing = await lessonQueries.getLessonByKey(lesson.lessonKey);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
console.log(`Lesson "${lesson.lessonKey}" already exists, skipping...`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create lesson
|
||||||
|
await lessonQueries.createLesson(
|
||||||
|
lesson.lessonKey,
|
||||||
|
lesson.title,
|
||||||
|
lesson.description,
|
||||||
|
lesson.modulePath,
|
||||||
|
lesson.configPath,
|
||||||
|
lesson.difficultyLevel,
|
||||||
|
lesson.estimatedDuration
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`✓ Created lesson: ${lesson.title}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`✗ Error creating lesson "${lesson.lessonKey}":`, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Run if called directly
|
||||||
|
if (require.main === module) {
|
||||||
|
seedLessons()
|
||||||
|
.then(() => {
|
||||||
|
console.log('\n✓ Lesson seeding complete');
|
||||||
|
process.exit(0);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('\n✗ Lesson seeding failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { seedLessons };
|
||||||
138
database/init/01-schema.sql
Normal file
138
database/init/01-schema.sql
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
-- Security Awareness Learning Platform Database Schema
|
||||||
|
-- PostgreSQL 15+
|
||||||
|
|
||||||
|
-- Events table: Represents training events/sessions
|
||||||
|
CREATE TABLE events (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
start_date TIMESTAMP,
|
||||||
|
end_date TIMESTAMP,
|
||||||
|
is_active BOOLEAN DEFAULT true,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Participants table: Anonymous participants with pseudonyms
|
||||||
|
CREATE TABLE participants (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
pseudonym VARCHAR(100) NOT NULL,
|
||||||
|
event_id INTEGER NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||||
|
session_token VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(pseudonym, event_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Lessons table: Catalog of available lessons
|
||||||
|
CREATE TABLE lessons (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
lesson_key VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
module_path VARCHAR(255) NOT NULL,
|
||||||
|
config_path VARCHAR(255) NOT NULL,
|
||||||
|
difficulty_level VARCHAR(50),
|
||||||
|
estimated_duration INTEGER,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Event_Lessons table: Lessons assigned to events with configuration
|
||||||
|
CREATE TABLE event_lessons (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
event_id INTEGER NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||||
|
lesson_id INTEGER NOT NULL REFERENCES lessons(id) ON DELETE CASCADE,
|
||||||
|
order_index INTEGER NOT NULL,
|
||||||
|
max_points INTEGER NOT NULL DEFAULT 100,
|
||||||
|
weight DECIMAL(5,2) DEFAULT 1.0,
|
||||||
|
is_required BOOLEAN DEFAULT true,
|
||||||
|
unlock_after_lesson_id INTEGER REFERENCES lessons(id),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(event_id, lesson_id),
|
||||||
|
UNIQUE(event_id, order_index)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Lesson_Progress table: Tracks participant progress through lessons
|
||||||
|
CREATE TABLE lesson_progress (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
participant_id INTEGER NOT NULL REFERENCES participants(id) ON DELETE CASCADE,
|
||||||
|
event_lesson_id INTEGER NOT NULL REFERENCES event_lessons(id) ON DELETE CASCADE,
|
||||||
|
status VARCHAR(50) NOT NULL DEFAULT 'not_started',
|
||||||
|
started_at TIMESTAMP,
|
||||||
|
completed_at TIMESTAMP,
|
||||||
|
score INTEGER DEFAULT 0,
|
||||||
|
attempts INTEGER DEFAULT 0,
|
||||||
|
current_step INTEGER DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(participant_id, event_lesson_id),
|
||||||
|
CHECK (status IN ('not_started', 'in_progress', 'completed'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Lesson_Answers table: Stores participant answers
|
||||||
|
CREATE TABLE lesson_answers (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
lesson_progress_id INTEGER NOT NULL REFERENCES lesson_progress(id) ON DELETE CASCADE,
|
||||||
|
question_key VARCHAR(100) NOT NULL,
|
||||||
|
answer_data JSONB NOT NULL,
|
||||||
|
is_correct BOOLEAN,
|
||||||
|
points_awarded INTEGER DEFAULT 0,
|
||||||
|
submitted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
feedback TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Admin_Users table: Admin authentication
|
||||||
|
CREATE TABLE admin_users (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
username VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_login TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for performance
|
||||||
|
CREATE INDEX idx_participants_event ON participants(event_id);
|
||||||
|
CREATE INDEX idx_participants_session ON participants(session_token);
|
||||||
|
CREATE INDEX idx_participants_pseudonym ON participants(pseudonym);
|
||||||
|
CREATE INDEX idx_event_lessons_event ON event_lessons(event_id);
|
||||||
|
CREATE INDEX idx_event_lessons_lesson ON event_lessons(lesson_id);
|
||||||
|
CREATE INDEX idx_event_lessons_order ON event_lessons(event_id, order_index);
|
||||||
|
CREATE INDEX idx_lesson_progress_participant ON lesson_progress(participant_id);
|
||||||
|
CREATE INDEX idx_lesson_progress_event_lesson ON lesson_progress(event_lesson_id);
|
||||||
|
CREATE INDEX idx_lesson_progress_status ON lesson_progress(status);
|
||||||
|
CREATE INDEX idx_lesson_answers_progress ON lesson_answers(lesson_progress_id);
|
||||||
|
CREATE INDEX idx_lesson_answers_question ON lesson_answers(question_key);
|
||||||
|
|
||||||
|
-- Trigger function to update updated_at timestamps
|
||||||
|
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ language 'plpgsql';
|
||||||
|
|
||||||
|
-- Apply triggers to tables
|
||||||
|
CREATE TRIGGER update_events_updated_at BEFORE UPDATE ON events
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER update_lessons_updated_at BEFORE UPDATE ON lessons
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER update_lesson_progress_updated_at BEFORE UPDATE ON lesson_progress
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
-- Insert default admin user (password: admin123 - CHANGE IN PRODUCTION!)
|
||||||
|
-- bcrypt hash for 'admin123' with 10 rounds
|
||||||
|
INSERT INTO admin_users (username, password_hash) VALUES
|
||||||
|
('admin', '$2b$10$mP8BvCik6In9lvWqxV57VuKglR3IqW4GfMoF.5fsT8HrTxRqscElW');
|
||||||
|
|
||||||
|
-- Comments for documentation
|
||||||
|
COMMENT ON TABLE events IS 'Training events or sessions that participants can join';
|
||||||
|
COMMENT ON TABLE participants IS 'Anonymous participants identified by pseudonym within events';
|
||||||
|
COMMENT ON TABLE lessons IS 'Catalog of available lesson modules';
|
||||||
|
COMMENT ON TABLE event_lessons IS 'Lessons assigned to specific events with custom configuration';
|
||||||
|
COMMENT ON TABLE lesson_progress IS 'Tracks individual participant progress through lessons';
|
||||||
|
COMMENT ON TABLE lesson_answers IS 'Stores submitted answers with scoring information';
|
||||||
|
COMMENT ON TABLE admin_users IS 'Administrative users with full system access';
|
||||||
80
docker-compose.yml
Normal file
80
docker-compose.yml
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
services:
|
||||||
|
# PostgreSQL Database
|
||||||
|
database:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: lernplattform_db
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${DB_NAME:-lernplattform}
|
||||||
|
POSTGRES_USER: ${DB_USER:-lernplattform_user}
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-changeme123}
|
||||||
|
PGDATA: /var/lib/postgresql/data/pgdata
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
- ./database/init:/docker-entrypoint-initdb.d:ro
|
||||||
|
ports:
|
||||||
|
- "${DB_PORT:-5432}:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-lernplattform_user}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- lernplattform_network
|
||||||
|
|
||||||
|
# Backend API
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: lernplattform_backend
|
||||||
|
environment:
|
||||||
|
NODE_ENV: ${NODE_ENV:-production}
|
||||||
|
PORT: ${BACKEND_PORT:-3000}
|
||||||
|
DB_HOST: database
|
||||||
|
DB_PORT: 5432
|
||||||
|
DB_NAME: ${DB_NAME:-lernplattform}
|
||||||
|
DB_USER: ${DB_USER:-lernplattform_user}
|
||||||
|
DB_PASSWORD: ${DB_PASSWORD:-changeme123}
|
||||||
|
JWT_SECRET: ${JWT_SECRET:-change_this_secret_key_in_production}
|
||||||
|
SESSION_SECRET: ${SESSION_SECRET:-change_this_session_secret}
|
||||||
|
ADMIN_DEFAULT_PASSWORD: ${ADMIN_DEFAULT_PASSWORD:-admin123}
|
||||||
|
volumes:
|
||||||
|
- ./backend/lessons:/app/lessons:ro
|
||||||
|
ports:
|
||||||
|
- "${BACKEND_PORT:-3000}:3000"
|
||||||
|
depends_on:
|
||||||
|
database:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
networks:
|
||||||
|
- lernplattform_network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
VITE_API_URL: ${VITE_API_URL:-http://localhost:3000/api}
|
||||||
|
container_name: lernplattform_frontend
|
||||||
|
ports:
|
||||||
|
- "${FRONTEND_PORT:-80}:80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
networks:
|
||||||
|
- lernplattform_network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
lernplattform_network:
|
||||||
|
driver: bridge
|
||||||
41
frontend/Dockerfile
Normal file
41
frontend/Dockerfile
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
# Multi-stage build for frontend
|
||||||
|
|
||||||
|
# Build stage
|
||||||
|
FROM node:18-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build argument for API URL
|
||||||
|
ARG VITE_API_URL
|
||||||
|
ENV VITE_API_URL=$VITE_API_URL
|
||||||
|
|
||||||
|
# Build application
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# Copy built files
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Copy nginx configuration
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s \
|
||||||
|
CMD wget --quiet --tries=1 --spider http://localhost:80 || exit 1
|
||||||
|
|
||||||
|
# Start nginx
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
14
frontend/index.html
Normal file
14
frontend/index.html
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="Security Awareness Learning Platform" />
|
||||||
|
<title>Security Learning Platform</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
44
frontend/nginx.conf
Normal file
44
frontend/nginx.conf
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Enable gzip compression
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||||
|
|
||||||
|
# Handle React Router - all routes go to index.html
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache static assets
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Prevent access to hidden files
|
||||||
|
location ~ /\. {
|
||||||
|
deny all;
|
||||||
|
access_log off;
|
||||||
|
log_not_found off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Prevent access to backup files
|
||||||
|
location ~ ~$ {
|
||||||
|
deny all;
|
||||||
|
access_log off;
|
||||||
|
log_not_found off;
|
||||||
|
}
|
||||||
|
}
|
||||||
2033
frontend/package-lock.json
generated
Normal file
2033
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
frontend/package.json
Normal file
23
frontend/package.json
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "lernplattform-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Frontend for Security Awareness Learning Platform",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^6.20.1",
|
||||||
|
"axios": "^1.6.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.43",
|
||||||
|
"@types/react-dom": "^18.2.17",
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"vite": "^5.0.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
19
frontend/src/App.jsx
Normal file
19
frontend/src/App.jsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import { ParticipantProvider } from './contexts/ParticipantContext';
|
||||||
|
import { AdminProvider } from './contexts/AdminContext';
|
||||||
|
import AppRoutes from './routes/AppRoutes';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<ParticipantProvider>
|
||||||
|
<AdminProvider>
|
||||||
|
<AppRoutes />
|
||||||
|
</AdminProvider>
|
||||||
|
</ParticipantProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
367
frontend/src/components/lessons/InteractiveContent/BitBDemo.jsx
Normal file
367
frontend/src/components/lessons/InteractiveContent/BitBDemo.jsx
Normal file
@ -0,0 +1,367 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
const BitBDemo = ({ lessonData }) => {
|
||||||
|
const [activeScenario, setActiveScenario] = useState(null);
|
||||||
|
const [dragAttempted, setDragAttempted] = useState(false);
|
||||||
|
const [inspectAttempted, setInspectAttempted] = useState(false);
|
||||||
|
|
||||||
|
const interactiveData = lessonData?.interactiveData || {};
|
||||||
|
const scenarios = interactiveData.scenarios || [];
|
||||||
|
const testInstructions = interactiveData.testInstructions || [];
|
||||||
|
|
||||||
|
const closeScenario = () => {
|
||||||
|
setActiveScenario(null);
|
||||||
|
setDragAttempted(false);
|
||||||
|
setInspectAttempted(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragStart = (e, isReal) => {
|
||||||
|
setDragAttempted(true);
|
||||||
|
if (!isReal) {
|
||||||
|
e.preventDefault(); // Fake window can't be dragged
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddressBarRightClick = (e, isReal) => {
|
||||||
|
if (!isReal) {
|
||||||
|
setInspectAttempted(true);
|
||||||
|
// Allow right-click menu to show "Inspect Element" on fake popup
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderFakeBrowser = (scenario) => {
|
||||||
|
const providerStyles = {
|
||||||
|
Google: { primary: '#4285f4', secondary: '#ea4335' },
|
||||||
|
Microsoft: { primary: '#00a4ef', secondary: '#7fba00' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const colors = providerStyles[scenario.provider] || providerStyles.Google;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
width: '500px',
|
||||||
|
background: 'white',
|
||||||
|
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
zIndex: 9999,
|
||||||
|
overflow: 'hidden',
|
||||||
|
cursor: scenario.isReal ? 'move' : 'default'
|
||||||
|
}}
|
||||||
|
draggable={scenario.isReal}
|
||||||
|
onDragStart={(e) => handleDragStart(e, scenario.isReal)}
|
||||||
|
>
|
||||||
|
{/* Fake Browser Chrome */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: '#f1f3f4',
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderBottom: '1px solid #dadce0',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px'
|
||||||
|
}}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
if (!scenario.isReal) {
|
||||||
|
handleAddressBarRightClick(e, scenario.isReal);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Window Controls */}
|
||||||
|
<div style={{ display: 'flex', gap: '6px', marginRight: '8px' }}>
|
||||||
|
<div style={{ width: '12px', height: '12px', borderRadius: '50%', background: '#ff5f56' }}></div>
|
||||||
|
<div style={{ width: '12px', height: '12px', borderRadius: '50%', background: '#ffbd2e' }}></div>
|
||||||
|
<div style={{ width: '12px', height: '12px', borderRadius: '50%', background: '#27c93f' }}></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fake Address Bar */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '20px',
|
||||||
|
padding: '6px 12px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
fontSize: '13px',
|
||||||
|
color: '#5f6368',
|
||||||
|
border: '1px solid #dadce0',
|
||||||
|
userSelect: scenario.isReal ? 'text' : 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: '#0f9d58', fontWeight: 'bold' }}>🔒</span>
|
||||||
|
<span style={{ color: '#202124' }}>{scenario.domain}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Login Form Content */}
|
||||||
|
<div style={{ padding: '40px', textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: '24px', marginBottom: '8px' }}>
|
||||||
|
{scenario.provider === 'Google' ? (
|
||||||
|
<span style={{ color: colors.primary, fontWeight: '500' }}>Google</span>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: colors.primary, fontWeight: '500' }}>Microsoft</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '16px', color: '#5f6368', marginBottom: '24px' }}>
|
||||||
|
Sign in to continue
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="Email or phone"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px',
|
||||||
|
border: '1px solid #dadce0',
|
||||||
|
borderRadius: '4px',
|
||||||
|
marginBottom: '16px',
|
||||||
|
fontSize: '14px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Password"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px',
|
||||||
|
border: '1px solid #dadce0',
|
||||||
|
borderRadius: '4px',
|
||||||
|
marginBottom: '24px',
|
||||||
|
fontSize: '14px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px',
|
||||||
|
background: colors.primary,
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Warning Badge */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
right: '-120px',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
background: scenario.isReal ? '#10b981' : '#ef4444',
|
||||||
|
color: 'white',
|
||||||
|
padding: '8px 16px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '600',
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.2)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{scenario.isReal ? '✅ REAL' : '⚠️ FAKE'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Close Button */}
|
||||||
|
<button
|
||||||
|
onClick={closeScenario}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '16px',
|
||||||
|
right: '16px',
|
||||||
|
background: '#ef4444',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
padding: '8px 16px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '600',
|
||||||
|
zIndex: 10000
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Close Demo
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Feedback Messages */}
|
||||||
|
{dragAttempted && !scenario.isReal && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '16px',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
background: '#fef3c7',
|
||||||
|
color: '#92400e',
|
||||||
|
padding: '8px 16px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '500'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⚠️ Notice: This fake window cannot be dragged!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{inspectAttempted && !scenario.isReal && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '80px',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
background: '#fef3c7',
|
||||||
|
color: '#92400e',
|
||||||
|
padding: '8px 16px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '500',
|
||||||
|
width: '80%',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⚠️ Right-click works! This reveals it's just HTML, not a real browser.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ border: '1px solid #e5e7eb', borderRadius: '0.5rem', padding: '1.5rem', background: 'white' }}>
|
||||||
|
<h4 style={{ marginBottom: '1rem', color: '#1f2937' }}>🎭 Browser-in-the-Browser Attack Demo</h4>
|
||||||
|
|
||||||
|
{/* Test Instructions */}
|
||||||
|
<div style={{ marginBottom: '1.5rem', padding: '1rem', background: '#eff6ff', border: '1px solid #3b82f6', borderRadius: '0.375rem' }}>
|
||||||
|
<div style={{ fontWeight: '600', color: '#1e40af', marginBottom: '0.5rem' }}>
|
||||||
|
🔍 How to Test:
|
||||||
|
</div>
|
||||||
|
<ul style={{ margin: 0, paddingLeft: '1.5rem', fontSize: '0.875rem', color: '#1e3a8a' }}>
|
||||||
|
{testInstructions.map((instruction, idx) => (
|
||||||
|
<li key={idx} style={{ marginBottom: '0.25rem' }}>{instruction}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scenario Buttons */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem', marginBottom: '1.5rem' }}>
|
||||||
|
{scenarios.map((scenario) => (
|
||||||
|
<div
|
||||||
|
key={scenario.id}
|
||||||
|
style={{
|
||||||
|
border: '2px solid #e5e7eb',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
padding: '1rem',
|
||||||
|
background: 'white',
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.5rem' }}>
|
||||||
|
<span style={{ fontSize: '1.5rem' }}>
|
||||||
|
{scenario.isReal ? '✅' : '⚠️'}
|
||||||
|
</span>
|
||||||
|
<h5 style={{ margin: 0, color: '#1f2937' }}>{scenario.title}</h5>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: '0.875rem', color: '#6b7280', marginBottom: '1rem' }}>
|
||||||
|
{scenario.description}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveScenario(scenario)}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '0.75rem',
|
||||||
|
background: scenario.isReal ? '#10b981' : '#ef4444',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: '500'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Launch {scenario.provider} Login
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Indicators */}
|
||||||
|
<div style={{ marginTop: '1rem', fontSize: '0.75rem', color: '#6b7280' }}>
|
||||||
|
<div style={{ fontWeight: '600', marginBottom: '0.25rem' }}>Key Indicators:</div>
|
||||||
|
<ul style={{ margin: 0, paddingLeft: '1.25rem' }}>
|
||||||
|
{scenario.indicators.slice(0, 3).map((indicator, idx) => (
|
||||||
|
<li key={idx} style={{ marginBottom: '0.125rem' }}>{indicator}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Educational Note */}
|
||||||
|
<div style={{ padding: '1rem', background: '#fef3c7', border: '1px solid #f59e0b', borderRadius: '0.375rem', fontSize: '0.875rem' }}>
|
||||||
|
<div style={{ fontWeight: '600', color: '#92400e', marginBottom: '0.5rem' }}>
|
||||||
|
⚠️ Important
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#78350f' }}>
|
||||||
|
In a real Browser-in-the-Browser attack, the fake popup would look identical to the real one.
|
||||||
|
The ONLY reliable way to detect it is by testing the physical behavior: Can you drag it outside the browser window?
|
||||||
|
Can you right-click the address bar and inspect it as HTML?
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Real World Examples */}
|
||||||
|
{interactiveData.realWorldExamples && (
|
||||||
|
<div style={{ marginTop: '1.5rem', padding: '1rem', background: '#f9fafb', borderRadius: '0.375rem' }}>
|
||||||
|
<div style={{ fontWeight: '600', fontSize: '0.875rem', marginBottom: '0.75rem', color: '#1f2937' }}>
|
||||||
|
📰 Real-World BitB Attacks:
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gap: '0.5rem' }}>
|
||||||
|
{interactiveData.realWorldExamples.map((example, idx) => (
|
||||||
|
<div key={idx} style={{ fontSize: '0.875rem', padding: '0.5rem', background: 'white', borderRadius: '0.25rem', border: '1px solid #e5e7eb' }}>
|
||||||
|
<span style={{ fontWeight: '600', color: '#ef4444' }}>{example.year}</span>
|
||||||
|
{' - '}
|
||||||
|
<span style={{ color: '#1f2937' }}>{example.target}</span>
|
||||||
|
{' via '}
|
||||||
|
<span style={{ color: '#2563eb' }}>{example.provider}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Active Scenario Overlay */}
|
||||||
|
{activeScenario && (
|
||||||
|
<>
|
||||||
|
{/* Dark Overlay */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
background: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
zIndex: 9998
|
||||||
|
}}
|
||||||
|
onClick={closeScenario}
|
||||||
|
></div>
|
||||||
|
|
||||||
|
{/* Fake Browser Window */}
|
||||||
|
{renderFakeBrowser(activeScenario)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BitBDemo;
|
||||||
@ -0,0 +1,272 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { participantAPI } from '../../../services/api.service';
|
||||||
|
|
||||||
|
const SQLShopDemo = ({ lessonData, eventLessonId }) => {
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [queryResult, setQueryResult] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [showSafeComparison, setShowSafeComparison] = useState(false);
|
||||||
|
|
||||||
|
const interactiveData = lessonData?.interactiveData || {};
|
||||||
|
const examples = interactiveData.examples || [];
|
||||||
|
|
||||||
|
const executeSearch = async (term = searchTerm) => {
|
||||||
|
if (!term.trim()) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await participantAPI.executeLessonAction(
|
||||||
|
eventLessonId,
|
||||||
|
'execute-query',
|
||||||
|
{ searchTerm: term, mode: 'vulnerable' }
|
||||||
|
);
|
||||||
|
setQueryResult(response.data.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to execute query:', error);
|
||||||
|
setQueryResult({
|
||||||
|
query: 'Error executing query',
|
||||||
|
results: [],
|
||||||
|
explanation: 'Failed to execute search'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const executeSafeSearch = async () => {
|
||||||
|
if (!searchTerm.trim()) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await participantAPI.executeLessonAction(
|
||||||
|
eventLessonId,
|
||||||
|
'execute-query',
|
||||||
|
{ searchTerm, mode: 'safe' }
|
||||||
|
);
|
||||||
|
setQueryResult(response.data.data);
|
||||||
|
setShowSafeComparison(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to execute safe query:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadExample = (example) => {
|
||||||
|
setSearchTerm(example.input);
|
||||||
|
setShowSafeComparison(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ border: '1px solid #e5e7eb', borderRadius: '0.5rem', padding: '1.5rem', background: 'white' }}>
|
||||||
|
<div style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<h4 style={{ marginBottom: '1rem', color: '#1f2937' }}>🛒 TechShop - Product Search</h4>
|
||||||
|
<p style={{ fontSize: '0.875rem', color: '#6b7280', marginBottom: '1rem' }}>
|
||||||
|
This is a vulnerable online shop. Try searching for products, then experiment with SQL injection.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Example Queries */}
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<div style={{ fontSize: '0.875rem', fontWeight: '500', marginBottom: '0.5rem' }}>
|
||||||
|
Try these examples:
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||||
|
{examples.map((example, idx) => (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
onClick={() => loadExample(example)}
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
background: '#f3f4f6',
|
||||||
|
border: '1px solid #d1d5db',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseOver={(e) => {
|
||||||
|
e.target.style.background = '#e5e7eb';
|
||||||
|
}}
|
||||||
|
onMouseOut={(e) => {
|
||||||
|
e.target.style.background = '#f3f4f6';
|
||||||
|
}}
|
||||||
|
title={example.description}
|
||||||
|
>
|
||||||
|
{example.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search Input */}
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem' }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
onKeyPress={(e) => e.key === 'Enter' && executeSearch()}
|
||||||
|
placeholder="Search products... (try SQL injection!)"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '0.75rem',
|
||||||
|
border: '2px solid #e5e7eb',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontFamily: 'monospace'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => executeSearch()}
|
||||||
|
disabled={loading || !searchTerm.trim()}
|
||||||
|
style={{
|
||||||
|
padding: '0.75rem 1.5rem',
|
||||||
|
background: '#ef4444',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
cursor: loading ? 'not-allowed' : 'pointer',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: '500'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? 'Searching...' : '🔍 Vulnerable Search'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={executeSafeSearch}
|
||||||
|
disabled={loading || !searchTerm.trim()}
|
||||||
|
style={{
|
||||||
|
padding: '0.75rem 1.5rem',
|
||||||
|
background: '#10b981',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
cursor: loading ? 'not-allowed' : 'pointer',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: '500'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? 'Searching...' : '✅ Safe Search'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Query Results */}
|
||||||
|
{queryResult && (
|
||||||
|
<div>
|
||||||
|
{/* SQL Query Display */}
|
||||||
|
<div style={{ marginBottom: '1rem', padding: '1rem', background: '#1f2937', color: '#f3f4f6', borderRadius: '0.375rem', fontFamily: 'monospace', fontSize: '0.875rem' }}>
|
||||||
|
<div style={{ color: '#9ca3af', marginBottom: '0.5rem' }}>Executed SQL Query:</div>
|
||||||
|
<div style={{ color: '#fbbf24' }}>{queryResult.query}</div>
|
||||||
|
{queryResult.parameter && (
|
||||||
|
<div style={{ marginTop: '0.5rem' }}>
|
||||||
|
<span style={{ color: '#9ca3af' }}>Parameter: </span>
|
||||||
|
<span style={{ color: '#34d399' }}>'{queryResult.parameter}'</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Injection Detection */}
|
||||||
|
{queryResult.injectionDetected && (
|
||||||
|
<div style={{
|
||||||
|
padding: '1rem',
|
||||||
|
background: '#fee2e2',
|
||||||
|
border: '2px solid #ef4444',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
marginBottom: '1rem'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontWeight: '600', color: '#991b1b', marginBottom: '0.5rem' }}>
|
||||||
|
⚠️ SQL Injection Detected: {queryResult.injectionType?.replace(/_/g, ' ')}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.875rem', color: '#7f1d1d' }}>
|
||||||
|
{queryResult.explanation}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Safe Query Explanation */}
|
||||||
|
{showSafeComparison && queryResult.explanation && queryResult.explanation.includes('✅') && (
|
||||||
|
<div style={{
|
||||||
|
padding: '1rem',
|
||||||
|
background: '#d1fae5',
|
||||||
|
border: '2px solid #10b981',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
marginBottom: '1rem'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontWeight: '600', color: '#065f46', marginBottom: '0.5rem' }}>
|
||||||
|
✅ Secure Query
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.875rem', color: '#047857' }}>
|
||||||
|
{queryResult.explanation}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results Table */}
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.875rem', fontWeight: '500', marginBottom: '0.5rem', color: '#374151' }}>
|
||||||
|
Results: {queryResult.recordCount} {queryResult.recordCount === 1 ? 'record' : 'records'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{queryResult.results.length > 0 ? (
|
||||||
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: '#f9fafb', borderBottom: '2px solid #e5e7eb' }}>
|
||||||
|
<th style={{ padding: '0.75rem', textAlign: 'left', fontWeight: '600' }}>ID</th>
|
||||||
|
<th style={{ padding: '0.75rem', textAlign: 'left', fontWeight: '600' }}>Name</th>
|
||||||
|
<th style={{ padding: '0.75rem', textAlign: 'left', fontWeight: '600' }}>Price</th>
|
||||||
|
<th style={{ padding: '0.75rem', textAlign: 'left', fontWeight: '600' }}>Category</th>
|
||||||
|
<th style={{ padding: '0.75rem', textAlign: 'left', fontWeight: '600' }}>Stock</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{queryResult.results.map((product, idx) => (
|
||||||
|
<tr
|
||||||
|
key={idx}
|
||||||
|
style={{
|
||||||
|
borderBottom: '1px solid #e5e7eb',
|
||||||
|
background: product.id === 'INJECTED' ? '#fee2e2' : 'white'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td style={{ padding: '0.75rem' }}>{product.id}</td>
|
||||||
|
<td style={{ padding: '0.75rem', fontFamily: product.id === 'INJECTED' ? 'monospace' : 'inherit' }}>
|
||||||
|
{product.name}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '0.75rem', fontFamily: product.id === 'INJECTED' ? 'monospace' : 'inherit' }}>
|
||||||
|
{typeof product.price === 'number' ? `$${product.price.toFixed(2)}` : product.price}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '0.75rem' }}>{product.category}</td>
|
||||||
|
<td style={{ padding: '0.75rem' }}>{product.stock}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ padding: '2rem', textAlign: 'center', color: '#6b7280', background: '#f9fafb', borderRadius: '0.375rem' }}>
|
||||||
|
{queryResult.injectionType === 'DROP_TABLE'
|
||||||
|
? '💥 Table would be deleted! (Simulated - no actual data was harmed)'
|
||||||
|
: 'No products found'
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Educational Note */}
|
||||||
|
{!queryResult && (
|
||||||
|
<div style={{ padding: '1.5rem', background: '#eff6ff', border: '1px solid #3b82f6', borderRadius: '0.375rem', fontSize: '0.875rem' }}>
|
||||||
|
<div style={{ fontWeight: '600', color: '#1e40af', marginBottom: '0.5rem' }}>
|
||||||
|
💡 Learning Tip
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#1e3a8a' }}>
|
||||||
|
Start with a normal search like "laptop" to see how the query works. Then try the SQL injection examples to understand how attackers manipulate queries.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SQLShopDemo;
|
||||||
80
frontend/src/contexts/AdminContext.jsx
Normal file
80
frontend/src/contexts/AdminContext.jsx
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||||
|
import { adminSessionService } from '../services/session.service';
|
||||||
|
import { adminAPI } from '../services/api.service';
|
||||||
|
|
||||||
|
const AdminContext = createContext();
|
||||||
|
|
||||||
|
export const useAdmin = () => {
|
||||||
|
const context = useContext(AdminContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAdmin must be used within AdminProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AdminProvider = ({ children }) => {
|
||||||
|
const [admin, setAdmin] = useState(null);
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check for existing session
|
||||||
|
const token = adminSessionService.getToken();
|
||||||
|
if (token) {
|
||||||
|
const savedAdmin = adminSessionService.getAdmin();
|
||||||
|
if (savedAdmin) {
|
||||||
|
setAdmin(savedAdmin);
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
// Set token in axios
|
||||||
|
localStorage.setItem('token', token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = async (username, password) => {
|
||||||
|
try {
|
||||||
|
const response = await adminAPI.login(username, password);
|
||||||
|
const { token, admin: newAdmin } = response.data.data;
|
||||||
|
|
||||||
|
// Save to localStorage
|
||||||
|
adminSessionService.saveSession(token, newAdmin);
|
||||||
|
// Also set for axios interceptor
|
||||||
|
localStorage.setItem('token', token);
|
||||||
|
|
||||||
|
// Update state
|
||||||
|
setAdmin(newAdmin);
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.error?.message || 'Login failed'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
adminSessionService.clearSession();
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
setAdmin(null);
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
admin,
|
||||||
|
isAuthenticated,
|
||||||
|
loading,
|
||||||
|
login,
|
||||||
|
logout
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</AdminContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminContext;
|
||||||
81
frontend/src/contexts/ParticipantContext.jsx
Normal file
81
frontend/src/contexts/ParticipantContext.jsx
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||||
|
import { sessionService } from '../services/session.service';
|
||||||
|
import { participantAPI } from '../services/api.service';
|
||||||
|
|
||||||
|
const ParticipantContext = createContext();
|
||||||
|
|
||||||
|
export const useParticipant = () => {
|
||||||
|
const context = useContext(ParticipantContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useParticipant must be used within ParticipantProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ParticipantProvider = ({ children }) => {
|
||||||
|
const [participant, setParticipant] = useState(null);
|
||||||
|
const [event, setEvent] = useState(null);
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check for existing session
|
||||||
|
const token = sessionService.getToken();
|
||||||
|
if (token) {
|
||||||
|
const savedParticipant = sessionService.getParticipant();
|
||||||
|
const savedEvent = sessionService.getEvent();
|
||||||
|
if (savedParticipant && savedEvent) {
|
||||||
|
setParticipant(savedParticipant);
|
||||||
|
setEvent(savedEvent);
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const joinEvent = async (pseudonym, eventId) => {
|
||||||
|
try {
|
||||||
|
const response = await participantAPI.joinEvent(pseudonym, eventId);
|
||||||
|
const { sessionToken, participant: newParticipant, event: newEvent } = response.data.data;
|
||||||
|
|
||||||
|
// Save to localStorage
|
||||||
|
sessionService.saveSession(sessionToken, newParticipant, newEvent);
|
||||||
|
|
||||||
|
// Update state
|
||||||
|
setParticipant(newParticipant);
|
||||||
|
setEvent(newEvent);
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.error?.message || 'Failed to join event'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
sessionService.clearSession();
|
||||||
|
setParticipant(null);
|
||||||
|
setEvent(null);
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
participant,
|
||||||
|
event,
|
||||||
|
isAuthenticated,
|
||||||
|
loading,
|
||||||
|
joinEvent,
|
||||||
|
logout
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ParticipantContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</ParticipantContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ParticipantContext;
|
||||||
10
frontend/src/main.jsx
Normal file
10
frontend/src/main.jsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
import './styles/index.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
120
frontend/src/pages/EventLanding.jsx
Normal file
120
frontend/src/pages/EventLanding.jsx
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useParticipant } from '../contexts/ParticipantContext';
|
||||||
|
import { participantAPI } from '../services/api.service';
|
||||||
|
|
||||||
|
const EventLanding = () => {
|
||||||
|
const [lessons, setLessons] = useState([]);
|
||||||
|
const [progress, setProgress] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const { participant, event, logout } = useParticipant();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
const [lessonsRes, progressRes] = await Promise.all([
|
||||||
|
participantAPI.getEventLessons(event.id),
|
||||||
|
participantAPI.getProgress()
|
||||||
|
]);
|
||||||
|
setLessons(lessonsRes.data.data);
|
||||||
|
setProgress(progressRes.data.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load data:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div style={{ padding: '2rem', textAlign: 'center' }}>Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight: '100vh', background: '#f9fafb' }}>
|
||||||
|
<nav style={{ background: 'white', padding: '1rem 2rem', borderBottom: '1px solid #e5e7eb', boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }}>
|
||||||
|
<div style={{ maxWidth: '1200px', margin: '0 auto', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<h2 style={{ margin: 0, color: '#2563eb' }}>{event.name}</h2>
|
||||||
|
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
|
||||||
|
<span style={{ color: '#6b7280' }}>👤 {participant.pseudonym}</span>
|
||||||
|
<button onClick={logout} style={{ padding: '0.5rem 1rem', background: '#ef4444', color: 'white', border: 'none', borderRadius: '0.375rem', cursor: 'pointer' }}>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div style={{ maxWidth: '1200px', margin: '0 auto', padding: '2rem' }}>
|
||||||
|
<div className="card" style={{ marginBottom: '2rem' }}>
|
||||||
|
<h3>Your Progress</h3>
|
||||||
|
<div style={{ display: 'flex', gap: '2rem', marginTop: '1rem' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '2rem', fontWeight: 'bold', color: '#2563eb' }}>{progress.total_score}</div>
|
||||||
|
<div style={{ fontSize: '0.875rem', color: '#6b7280' }}>Total Score</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '2rem', fontWeight: 'bold', color: '#10b981' }}>
|
||||||
|
{progress.lessons_completed}/{progress.total_lessons_available}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.875rem', color: '#6b7280' }}>Lessons Completed</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 style={{ marginBottom: '1rem' }}>Lessons</h3>
|
||||||
|
<div style={{ display: 'grid', gap: '1rem' }}>
|
||||||
|
{lessons.map(lesson => (
|
||||||
|
<div
|
||||||
|
key={lesson.eventLessonId}
|
||||||
|
className="card"
|
||||||
|
style={{
|
||||||
|
opacity: lesson.isUnlocked ? 1 : 0.6,
|
||||||
|
cursor: lesson.isUnlocked ? 'pointer' : 'not-allowed',
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
}}
|
||||||
|
onClick={() => lesson.isUnlocked && navigate(`/lesson/${lesson.eventLessonId}`)}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.5rem' }}>
|
||||||
|
<h4 style={{ margin: 0 }}>{lesson.title}</h4>
|
||||||
|
{!lesson.isUnlocked && (
|
||||||
|
<span style={{ fontSize: '1.25rem' }}>🔒</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p style={{ color: '#6b7280', marginBottom: '0.5rem' }}>{lesson.description}</p>
|
||||||
|
<div style={{ display: 'flex', gap: '1rem', fontSize: '0.875rem', color: '#6b7280' }}>
|
||||||
|
<span>⏱️ {lesson.estimatedDuration} min</span>
|
||||||
|
<span>⭐ {lesson.maxPoints} points</span>
|
||||||
|
<span style={{ textTransform: 'capitalize' }}>📊 {lesson.difficultyLevel}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{lesson.progress && (
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
{lesson.progress.status === 'completed' ? (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '1.5rem' }}>✅</div>
|
||||||
|
<div style={{ fontSize: '0.875rem', color: '#10b981', fontWeight: '500' }}>
|
||||||
|
{lesson.progress.score}/{lesson.maxPoints}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : lesson.progress.status === 'in_progress' ? (
|
||||||
|
<span style={{ padding: '0.25rem 0.75rem', background: '#fef3c7', color: '#f59e0b', borderRadius: '9999px', fontSize: '0.875rem' }}>
|
||||||
|
In Progress
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EventLanding;
|
||||||
116
frontend/src/pages/Hub.jsx
Normal file
116
frontend/src/pages/Hub.jsx
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useParticipant } from '../contexts/ParticipantContext';
|
||||||
|
import { participantAPI } from '../services/api.service';
|
||||||
|
|
||||||
|
const Hub = () => {
|
||||||
|
const [events, setEvents] = useState([]);
|
||||||
|
const [pseudonym, setPseudonym] = useState('');
|
||||||
|
const [selectedEvent, setSelectedEvent] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { isAuthenticated, joinEvent } = useParticipant();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
navigate('/event');
|
||||||
|
}
|
||||||
|
loadEvents();
|
||||||
|
}, [isAuthenticated, navigate]);
|
||||||
|
|
||||||
|
const loadEvents = async () => {
|
||||||
|
try {
|
||||||
|
const response = await participantAPI.getEvents();
|
||||||
|
setEvents(response.data.data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load events:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJoin = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
if (!pseudonym || !selectedEvent) {
|
||||||
|
setError('Please enter a pseudonym and select an event');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
const result = await joinEvent(pseudonym, selectedEvent);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
navigate('/event');
|
||||||
|
} else {
|
||||||
|
setError(result.error);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#f9fafb' }}>
|
||||||
|
<div className="card" style={{ maxWidth: '500px', width: '100%' }}>
|
||||||
|
<h1 style={{ color: '#2563eb', marginBottom: '0.5rem' }}>Security Learning Platform</h1>
|
||||||
|
<p style={{ color: '#6b7280', marginBottom: '2rem' }}>Join an event to start learning</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{ padding: '0.75rem', background: '#fee2e2', border: '1px solid #ef4444', borderRadius: '0.375rem', color: '#dc2626', marginBottom: '1rem' }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleJoin}>
|
||||||
|
<div style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<label style={{ display: 'block', fontWeight: '500', marginBottom: '0.5rem' }}>
|
||||||
|
Your Pseudonym
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={pseudonym}
|
||||||
|
onChange={(e) => setPseudonym(e.target.value)}
|
||||||
|
placeholder="Enter a pseudonym"
|
||||||
|
disabled={loading}
|
||||||
|
style={{ width: '100%', padding: '0.75rem', border: '1px solid #e5e7eb', borderRadius: '0.375rem' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<label style={{ display: 'block', fontWeight: '500', marginBottom: '0.5rem' }}>
|
||||||
|
Select Event
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedEvent}
|
||||||
|
onChange={(e) => setSelectedEvent(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
style={{ width: '100%', padding: '0.75rem', border: '1px solid #e5e7eb', borderRadius: '0.375rem' }}
|
||||||
|
>
|
||||||
|
<option value="">Choose an event...</option>
|
||||||
|
{events.map(event => (
|
||||||
|
<option key={event.id} value={event.id}>
|
||||||
|
{event.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
style={{ width: '100%', padding: '0.75rem', background: '#2563eb', color: 'white', border: 'none', borderRadius: '0.375rem', fontWeight: '500', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
{loading ? 'Joining...' : 'Join Event'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div style={{ marginTop: '2rem', textAlign: 'center' }}>
|
||||||
|
<a href="/admin/login" style={{ color: '#6b7280', fontSize: '0.875rem' }}>
|
||||||
|
Admin Login
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Hub;
|
||||||
258
frontend/src/pages/LessonView.jsx
Normal file
258
frontend/src/pages/LessonView.jsx
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { participantAPI } from '../services/api.service';
|
||||||
|
import SQLShopDemo from '../components/lessons/InteractiveContent/SQLShopDemo';
|
||||||
|
import BitBDemo from '../components/lessons/InteractiveContent/BitBDemo';
|
||||||
|
|
||||||
|
const LessonView = () => {
|
||||||
|
const { eventLessonId } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [lesson, setLesson] = useState(null);
|
||||||
|
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
||||||
|
const [answers, setAnswers] = useState({});
|
||||||
|
const [feedback, setFeedback] = useState({});
|
||||||
|
const [totalScore, setTotalScore] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadLesson();
|
||||||
|
}, [eventLessonId]);
|
||||||
|
|
||||||
|
const loadLesson = async () => {
|
||||||
|
try {
|
||||||
|
const response = await participantAPI.getLessonContent(eventLessonId);
|
||||||
|
setLesson(response.data.data);
|
||||||
|
await participantAPI.startLesson(eventLessonId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load lesson:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAnswer = async (questionId, answer) => {
|
||||||
|
try {
|
||||||
|
const response = await participantAPI.submitAnswer(eventLessonId, questionId, answer);
|
||||||
|
const result = response.data.data;
|
||||||
|
|
||||||
|
setFeedback(prev => ({ ...prev, [questionId]: result }));
|
||||||
|
setTotalScore(result.totalScore);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to submit answer:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleComplete = async () => {
|
||||||
|
try {
|
||||||
|
await participantAPI.completeLesson(eventLessonId);
|
||||||
|
navigate('/event');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to complete lesson:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading || !lesson) {
|
||||||
|
return <div style={{ padding: '2rem', textAlign: 'center' }}>Loading lesson...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentStep = lesson.steps[currentStepIndex];
|
||||||
|
const isLastStep = currentStepIndex === lesson.steps.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight: '100vh', background: '#f9fafb' }}>
|
||||||
|
<nav style={{ background: 'white', padding: '1rem 2rem', borderBottom: '1px solid #e5e7eb' }}>
|
||||||
|
<div style={{ maxWidth: '800px', margin: '0 auto' }}>
|
||||||
|
<h3 style={{ margin: 0, color: '#2563eb' }}>{lesson.title}</h3>
|
||||||
|
<div style={{ fontSize: '0.875rem', color: '#6b7280', marginTop: '0.5rem' }}>
|
||||||
|
Step {currentStepIndex + 1} of {lesson.steps.length} | Score: {totalScore}/{lesson.maxPoints}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div style={{ maxWidth: '800px', margin: '0 auto', padding: '2rem' }}>
|
||||||
|
<div className="card">
|
||||||
|
<h2>{currentStep.title}</h2>
|
||||||
|
|
||||||
|
{currentStep.type === 'content' && (
|
||||||
|
<div style={{ whiteSpace: 'pre-wrap', lineHeight: '1.8' }}>
|
||||||
|
{currentStep.content}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentStep.type === 'interactive' && (
|
||||||
|
<div>
|
||||||
|
{currentStep.content && (
|
||||||
|
<div style={{ whiteSpace: 'pre-wrap', lineHeight: '1.8', marginBottom: '1.5rem' }}>
|
||||||
|
{currentStep.content}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{currentStep.interactiveComponent === 'SQLShopDemo' && (
|
||||||
|
<SQLShopDemo lessonData={lesson} eventLessonId={eventLessonId} />
|
||||||
|
)}
|
||||||
|
{currentStep.interactiveComponent === 'BitBDemo' && (
|
||||||
|
<BitBDemo lessonData={lesson} eventLessonId={eventLessonId} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentStep.type === 'question' && (
|
||||||
|
<div>
|
||||||
|
<p style={{ fontWeight: '500', marginBottom: '1rem' }}>{currentStep.question}</p>
|
||||||
|
|
||||||
|
{currentStep.questionType === 'single_choice' && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
|
{currentStep.options.map(option => (
|
||||||
|
<label
|
||||||
|
key={option.id}
|
||||||
|
style={{
|
||||||
|
padding: '1rem',
|
||||||
|
border: '2px solid #e5e7eb',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={currentStep.id}
|
||||||
|
value={option.id}
|
||||||
|
onChange={(e) => setAnswers(prev => ({ ...prev, [currentStep.id]: e.target.value }))}
|
||||||
|
style={{ marginRight: '0.5rem' }}
|
||||||
|
/>
|
||||||
|
{option.text}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentStep.questionType === 'multiple_choice' && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
|
{currentStep.options.map(option => (
|
||||||
|
<label
|
||||||
|
key={option.id}
|
||||||
|
style={{
|
||||||
|
padding: '1rem',
|
||||||
|
border: '2px solid #e5e7eb',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
value={option.id}
|
||||||
|
onChange={(e) => {
|
||||||
|
const current = answers[currentStep.id] || [];
|
||||||
|
const updated = e.target.checked
|
||||||
|
? [...current, option.id]
|
||||||
|
: current.filter(id => id !== option.id);
|
||||||
|
setAnswers(prev => ({ ...prev, [currentStep.id]: updated }));
|
||||||
|
}}
|
||||||
|
style={{ marginRight: '0.5rem' }}
|
||||||
|
/>
|
||||||
|
{option.text}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentStep.questionType === 'free_text' && (
|
||||||
|
<textarea
|
||||||
|
value={answers[currentStep.id] || ''}
|
||||||
|
onChange={(e) => setAnswers(prev => ({ ...prev, [currentStep.id]: e.target.value }))}
|
||||||
|
rows={5}
|
||||||
|
style={{ width: '100%', padding: '0.75rem', border: '1px solid #e5e7eb', borderRadius: '0.375rem' }}
|
||||||
|
placeholder="Type your answer here..."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!feedback[currentStep.id] && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleAnswer(currentStep.id, answers[currentStep.id])}
|
||||||
|
disabled={!answers[currentStep.id]}
|
||||||
|
style={{
|
||||||
|
marginTop: '1rem',
|
||||||
|
padding: '0.75rem 1.5rem',
|
||||||
|
background: '#2563eb',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Submit Answer
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{feedback[currentStep.id] && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: '1rem',
|
||||||
|
padding: '1rem',
|
||||||
|
background: feedback[currentStep.id].isCorrect ? '#d1fae5' : '#fee2e2',
|
||||||
|
border: `1px solid ${feedback[currentStep.id].isCorrect ? '#10b981' : '#ef4444'}`,
|
||||||
|
borderRadius: '0.375rem'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontWeight: '500', marginBottom: '0.5rem' }}>
|
||||||
|
{feedback[currentStep.id].isCorrect ? '✅ Correct!' : '❌ Incorrect'}
|
||||||
|
</div>
|
||||||
|
<div>{feedback[currentStep.id].feedback}</div>
|
||||||
|
<div style={{ marginTop: '0.5rem', fontSize: '0.875rem' }}>
|
||||||
|
Points awarded: {feedback[currentStep.id].pointsAwarded}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '2rem' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentStepIndex(Math.max(0, currentStepIndex - 1))}
|
||||||
|
disabled={currentStepIndex === 0}
|
||||||
|
style={{
|
||||||
|
padding: '0.75rem 1.5rem',
|
||||||
|
background: '#6b7280',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isLastStep ? (
|
||||||
|
<button
|
||||||
|
onClick={handleComplete}
|
||||||
|
style={{
|
||||||
|
padding: '0.75rem 1.5rem',
|
||||||
|
background: '#10b981',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Complete Lesson
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentStepIndex(currentStepIndex + 1)}
|
||||||
|
style={{
|
||||||
|
padding: '0.75rem 1.5rem',
|
||||||
|
background: '#2563eb',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LessonView;
|
||||||
18
frontend/src/pages/ParticipantProgress.jsx
Normal file
18
frontend/src/pages/ParticipantProgress.jsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
const ParticipantProgress = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '2rem' }}>
|
||||||
|
<button onClick={() => navigate('/event')} style={{ marginBottom: '1rem' }}>
|
||||||
|
← Back to Event
|
||||||
|
</button>
|
||||||
|
<h2>Participant Progress</h2>
|
||||||
|
<p>Detailed progress view (placeholder)</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ParticipantProgress;
|
||||||
84
frontend/src/pages/admin/AdminDashboard.jsx
Normal file
84
frontend/src/pages/admin/AdminDashboard.jsx
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAdmin } from '../../contexts/AdminContext';
|
||||||
|
import { adminAPI } from '../../services/api.service';
|
||||||
|
|
||||||
|
const AdminDashboard = () => {
|
||||||
|
const [events, setEvents] = useState([]);
|
||||||
|
const { logout } = useAdmin();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadEvents();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadEvents = async () => {
|
||||||
|
try {
|
||||||
|
const response = await adminAPI.getEvents();
|
||||||
|
setEvents(response.data.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load events:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight: '100vh', background: '#f9fafb' }}>
|
||||||
|
<nav style={{ background: 'white', padding: '1rem 2rem', borderBottom: '1px solid #e5e7eb' }}>
|
||||||
|
<div style={{ maxWidth: '1200px', margin: '0 auto', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<h2 style={{ margin: 0, color: '#2563eb' }}>Admin Dashboard</h2>
|
||||||
|
<button onClick={logout} style={{ padding: '0.5rem 1rem', background: '#ef4444', color: 'white', border: 'none', borderRadius: '0.375rem', cursor: 'pointer' }}>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div style={{ maxWidth: '1200px', margin: '0 auto', padding: '2rem' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
|
||||||
|
<h3>Events</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/admin/events')}
|
||||||
|
style={{ padding: '0.75rem 1.5rem', background: '#2563eb', color: 'white', border: 'none', borderRadius: '0.375rem', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
Manage Events
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<div className="card">
|
||||||
|
<p>No events yet. Create your first event!</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gap: '1rem' }}>
|
||||||
|
{events.map(event => (
|
||||||
|
<div key={event.id} className="card">
|
||||||
|
<h4>{event.name}</h4>
|
||||||
|
<p style={{ color: '#6b7280', marginBottom: '1rem' }}>{event.description}</p>
|
||||||
|
<div style={{ display: 'flex', gap: '1rem', fontSize: '0.875rem', color: '#6b7280' }}>
|
||||||
|
<span>👥 {event.participant_count} participants</span>
|
||||||
|
<span>📚 {event.lesson_count} lessons</span>
|
||||||
|
<span>{event.is_active ? '🟢 Active' : '🔴 Inactive'}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/admin/events/${event.id}/lessons`)}
|
||||||
|
style={{ padding: '0.5rem 1rem', background: '#10b981', color: 'white', border: 'none', borderRadius: '0.375rem', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
Manage Lessons
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/admin/events/${event.id}/participants`)}
|
||||||
|
style={{ padding: '0.5rem 1rem', background: '#6b7280', color: 'white', border: 'none', borderRadius: '0.375rem', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
View Participants
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminDashboard;
|
||||||
87
frontend/src/pages/admin/AdminLogin.jsx
Normal file
87
frontend/src/pages/admin/AdminLogin.jsx
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAdmin } from '../../contexts/AdminContext';
|
||||||
|
|
||||||
|
const AdminLogin = () => {
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { login, isAuthenticated } = useAdmin();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
navigate('/admin');
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, navigate]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const result = await login(username, password);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
navigate('/admin');
|
||||||
|
} else {
|
||||||
|
setError(result.error);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#f9fafb' }}>
|
||||||
|
<div className="card" style={{ maxWidth: '400px', width: '100%' }}>
|
||||||
|
<h1 style={{ color: '#2563eb', marginBottom: '2rem' }}>Admin Login</h1>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{ padding: '0.75rem', background: '#fee2e2', border: '1px solid #ef4444', borderRadius: '0.375rem', color: '#dc2626', marginBottom: '1rem' }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<label style={{ display: 'block', fontWeight: '500', marginBottom: '0.5rem' }}>Username</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
style={{ width: '100%', padding: '0.75rem', border: '1px solid #e5e7eb', borderRadius: '0.375rem' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<label style={{ display: 'block', fontWeight: '500', marginBottom: '0.5rem' }}>Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
style={{ width: '100%', padding: '0.75rem', border: '1px solid #e5e7eb', borderRadius: '0.375rem' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
style={{ width: '100%', padding: '0.75rem', background: '#2563eb', color: 'white', border: 'none', borderRadius: '0.375rem', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
{loading ? 'Logging in...' : 'Login'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div style={{ marginTop: '2rem', textAlign: 'center' }}>
|
||||||
|
<a href="/" style={{ color: '#6b7280', fontSize: '0.875rem' }}>
|
||||||
|
← Back to Hub
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminLogin;
|
||||||
232
frontend/src/pages/admin/EventManagement.jsx
Normal file
232
frontend/src/pages/admin/EventManagement.jsx
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { adminAPI } from '../../services/api.service';
|
||||||
|
|
||||||
|
const EventManagement = () => {
|
||||||
|
const [events, setEvents] = useState([]);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [editingEvent, setEditingEvent] = useState(null);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
startDate: '',
|
||||||
|
endDate: '',
|
||||||
|
isActive: true
|
||||||
|
});
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadEvents();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadEvents = async () => {
|
||||||
|
try {
|
||||||
|
const response = await adminAPI.getEvents();
|
||||||
|
setEvents(response.data.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load events:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
if (editingEvent) {
|
||||||
|
await adminAPI.updateEvent(editingEvent.id, formData);
|
||||||
|
} else {
|
||||||
|
await adminAPI.createEvent(formData);
|
||||||
|
}
|
||||||
|
setShowForm(false);
|
||||||
|
setEditingEvent(null);
|
||||||
|
setFormData({ name: '', description: '', startDate: '', endDate: '', isActive: true });
|
||||||
|
loadEvents();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save event:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (event) => {
|
||||||
|
setEditingEvent(event);
|
||||||
|
setFormData({
|
||||||
|
name: event.name,
|
||||||
|
description: event.description,
|
||||||
|
startDate: event.start_date?.split('T')[0] || '',
|
||||||
|
endDate: event.end_date?.split('T')[0] || '',
|
||||||
|
isActive: event.is_active
|
||||||
|
});
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (eventId) => {
|
||||||
|
if (!window.confirm('Are you sure you want to delete this event? This will delete all associated data.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await adminAPI.deleteEvent(eventId);
|
||||||
|
loadEvents();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete event:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setShowForm(false);
|
||||||
|
setEditingEvent(null);
|
||||||
|
setFormData({ name: '', description: '', startDate: '', endDate: '', isActive: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight: '100vh', background: '#f9fafb' }}>
|
||||||
|
<nav style={{ background: 'white', padding: '1rem 2rem', borderBottom: '1px solid #e5e7eb' }}>
|
||||||
|
<div style={{ maxWidth: '1200px', margin: '0 auto', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<h2 style={{ margin: 0, color: '#2563eb' }}>Event Management</h2>
|
||||||
|
<button onClick={() => navigate('/admin')} style={{ padding: '0.5rem 1rem', background: '#6b7280', color: 'white', border: 'none', borderRadius: '0.375rem', cursor: 'pointer' }}>
|
||||||
|
Back to Dashboard
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div style={{ maxWidth: '1200px', margin: '0 auto', padding: '2rem' }}>
|
||||||
|
{!showForm && (
|
||||||
|
<div style={{ marginBottom: '2rem' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowForm(true)}
|
||||||
|
style={{ padding: '0.75rem 1.5rem', background: '#10b981', color: 'white', border: 'none', borderRadius: '0.375rem', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
+ Create New Event
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<div className="card" style={{ marginBottom: '2rem' }}>
|
||||||
|
<h3>{editingEvent ? 'Edit Event' : 'Create New Event'}</h3>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<label style={{ display: 'block', fontWeight: '500', marginBottom: '0.5rem' }}>Event Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
required
|
||||||
|
style={{ width: '100%', padding: '0.75rem', border: '1px solid #e5e7eb', borderRadius: '0.375rem' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<label style={{ display: 'block', fontWeight: '500', marginBottom: '0.5rem' }}>Description</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
rows={3}
|
||||||
|
style={{ width: '100%', padding: '0.75rem', border: '1px solid #e5e7eb', borderRadius: '0.375rem' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem', marginBottom: '1rem' }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontWeight: '500', marginBottom: '0.5rem' }}>Start Date</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={formData.startDate}
|
||||||
|
onChange={(e) => setFormData({ ...formData, startDate: e.target.value })}
|
||||||
|
style={{ width: '100%', padding: '0.75rem', border: '1px solid #e5e7eb', borderRadius: '0.375rem' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontWeight: '500', marginBottom: '0.5rem' }}>End Date</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={formData.endDate}
|
||||||
|
onChange={(e) => setFormData({ ...formData, endDate: e.target.value })}
|
||||||
|
style={{ width: '100%', padding: '0.75rem', border: '1px solid #e5e7eb', borderRadius: '0.375rem' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', fontWeight: '500' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.isActive}
|
||||||
|
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
|
||||||
|
style={{ marginRight: '0.5rem' }}
|
||||||
|
/>
|
||||||
|
Active Event
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
style={{ padding: '0.75rem 1.5rem', background: '#2563eb', color: 'white', border: 'none', borderRadius: '0.375rem', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
{editingEvent ? 'Update Event' : 'Create Event'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCancel}
|
||||||
|
style={{ padding: '0.75rem 1.5rem', background: '#6b7280', color: 'white', border: 'none', borderRadius: '0.375rem', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gap: '1rem' }}>
|
||||||
|
{events.map(event => (
|
||||||
|
<div key={event.id} className="card">
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<h4 style={{ marginBottom: '0.5rem' }}>{event.name}</h4>
|
||||||
|
<p style={{ color: '#6b7280', marginBottom: '1rem' }}>{event.description}</p>
|
||||||
|
<div style={{ display: 'flex', gap: '1rem', fontSize: '0.875rem', color: '#6b7280' }}>
|
||||||
|
<span>👥 {event.participant_count} participants</span>
|
||||||
|
<span>📚 {event.lesson_count} lessons</span>
|
||||||
|
<span>{event.is_active ? '🟢 Active' : '🔴 Inactive'}</span>
|
||||||
|
</div>
|
||||||
|
{event.start_date && (
|
||||||
|
<div style={{ fontSize: '0.875rem', color: '#6b7280', marginTop: '0.5rem' }}>
|
||||||
|
📅 {new Date(event.start_date).toLocaleDateString()} - {event.end_date ? new Date(event.end_date).toLocaleDateString() : 'No end date'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => handleEdit(event)}
|
||||||
|
style={{ padding: '0.5rem 1rem', background: '#2563eb', color: 'white', border: 'none', borderRadius: '0.375rem', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/admin/events/${event.id}/lessons`)}
|
||||||
|
style={{ padding: '0.5rem 1rem', background: '#10b981', color: 'white', border: 'none', borderRadius: '0.375rem', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
Lessons
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(event.id)}
|
||||||
|
style={{ padding: '0.5rem 1rem', background: '#ef4444', color: 'white', border: 'none', borderRadius: '0.375rem', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{events.length === 0 && (
|
||||||
|
<div className="card" style={{ textAlign: 'center', padding: '3rem' }}>
|
||||||
|
<p style={{ color: '#6b7280' }}>No events yet. Create your first event to get started!</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EventManagement;
|
||||||
271
frontend/src/pages/admin/LessonConfiguration.jsx
Normal file
271
frontend/src/pages/admin/LessonConfiguration.jsx
Normal file
@ -0,0 +1,271 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { adminAPI } from '../../services/api.service';
|
||||||
|
|
||||||
|
const LessonConfiguration = () => {
|
||||||
|
const { eventId } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [event, setEvent] = useState(null);
|
||||||
|
const [assignedLessons, setAssignedLessons] = useState([]);
|
||||||
|
const [availableLessons, setAvailableLessons] = useState([]);
|
||||||
|
const [showAssignForm, setShowAssignForm] = useState(false);
|
||||||
|
const [selectedLesson, setSelectedLesson] = useState('');
|
||||||
|
const [assignFormData, setAssignFormData] = useState({
|
||||||
|
orderIndex: 1,
|
||||||
|
maxPoints: 100,
|
||||||
|
weight: 1.0,
|
||||||
|
isRequired: true
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [eventId]);
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
const [eventsRes, lessonsRes, eventLessonsRes] = await Promise.all([
|
||||||
|
adminAPI.getEvents(),
|
||||||
|
adminAPI.getAllLessons(),
|
||||||
|
adminAPI.getEventLessons(eventId)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const currentEvent = eventsRes.data.data.find(e => e.id === parseInt(eventId));
|
||||||
|
setEvent(currentEvent);
|
||||||
|
setAssignedLessons(eventLessonsRes.data.data);
|
||||||
|
setAvailableLessons(lessonsRes.data.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load data:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAssignLesson = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await adminAPI.assignLessonToEvent(eventId, {
|
||||||
|
lessonId: parseInt(selectedLesson),
|
||||||
|
...assignFormData
|
||||||
|
});
|
||||||
|
setShowAssignForm(false);
|
||||||
|
setSelectedLesson('');
|
||||||
|
setAssignFormData({ orderIndex: 1, maxPoints: 100, weight: 1.0, isRequired: true });
|
||||||
|
loadData();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to assign lesson:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateLesson = async (eventLessonId, updates) => {
|
||||||
|
try {
|
||||||
|
await adminAPI.updateEventLesson(eventLessonId, updates);
|
||||||
|
loadData();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update lesson:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveLesson = async (eventLessonId) => {
|
||||||
|
if (!window.confirm('Are you sure you want to remove this lesson from the event?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await adminAPI.removeEventLesson(eventLessonId);
|
||||||
|
loadData();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to remove lesson:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAvailableToAssign = () => {
|
||||||
|
const assignedIds = assignedLessons.map(al => al.lesson_id);
|
||||||
|
return availableLessons.filter(l => !assignedIds.includes(l.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return <div style={{ padding: '2rem', textAlign: 'center' }}>Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight: '100vh', background: '#f9fafb' }}>
|
||||||
|
<nav style={{ background: 'white', padding: '1rem 2rem', borderBottom: '1px solid #e5e7eb' }}>
|
||||||
|
<div style={{ maxWidth: '1200px', margin: '0 auto', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ margin: 0, color: '#2563eb' }}>Lesson Configuration</h2>
|
||||||
|
<p style={{ margin: '0.25rem 0 0 0', fontSize: '0.875rem', color: '#6b7280' }}>{event.name}</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => navigate('/admin')} style={{ padding: '0.5rem 1rem', background: '#6b7280', color: 'white', border: 'none', borderRadius: '0.375rem', cursor: 'pointer' }}>
|
||||||
|
Back to Dashboard
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div style={{ maxWidth: '1200px', margin: '0 auto', padding: '2rem' }}>
|
||||||
|
{!showAssignForm && getAvailableToAssign().length > 0 && (
|
||||||
|
<div style={{ marginBottom: '2rem' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAssignForm(true)}
|
||||||
|
style={{ padding: '0.75rem 1.5rem', background: '#10b981', color: 'white', border: 'none', borderRadius: '0.375rem', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
+ Assign Lesson
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showAssignForm && (
|
||||||
|
<div className="card" style={{ marginBottom: '2rem' }}>
|
||||||
|
<h3>Assign Lesson to Event</h3>
|
||||||
|
<form onSubmit={handleAssignLesson}>
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<label style={{ display: 'block', fontWeight: '500', marginBottom: '0.5rem' }}>Select Lesson</label>
|
||||||
|
<select
|
||||||
|
value={selectedLesson}
|
||||||
|
onChange={(e) => setSelectedLesson(e.target.value)}
|
||||||
|
required
|
||||||
|
style={{ width: '100%', padding: '0.75rem', border: '1px solid #e5e7eb', borderRadius: '0.375rem' }}
|
||||||
|
>
|
||||||
|
<option value="">Choose a lesson...</option>
|
||||||
|
{getAvailableToAssign().map(lesson => (
|
||||||
|
<option key={lesson.id} value={lesson.id}>
|
||||||
|
{lesson.title} ({lesson.difficulty_level})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem', marginBottom: '1rem' }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontWeight: '500', marginBottom: '0.5rem' }}>Order Index</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={assignFormData.orderIndex}
|
||||||
|
onChange={(e) => setAssignFormData({ ...assignFormData, orderIndex: parseInt(e.target.value) })}
|
||||||
|
required
|
||||||
|
style={{ width: '100%', padding: '0.75rem', border: '1px solid #e5e7eb', borderRadius: '0.375rem' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontWeight: '500', marginBottom: '0.5rem' }}>Max Points</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={assignFormData.maxPoints}
|
||||||
|
onChange={(e) => setAssignFormData({ ...assignFormData, maxPoints: parseInt(e.target.value) })}
|
||||||
|
required
|
||||||
|
style={{ width: '100%', padding: '0.75rem', border: '1px solid #e5e7eb', borderRadius: '0.375rem' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<label style={{ display: 'block', fontWeight: '500', marginBottom: '0.5rem' }}>Weight (multiplier for final score)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0.1"
|
||||||
|
step="0.1"
|
||||||
|
value={assignFormData.weight}
|
||||||
|
onChange={(e) => setAssignFormData({ ...assignFormData, weight: parseFloat(e.target.value) })}
|
||||||
|
required
|
||||||
|
style={{ width: '100%', padding: '0.75rem', border: '1px solid #e5e7eb', borderRadius: '0.375rem' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', fontWeight: '500' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={assignFormData.isRequired}
|
||||||
|
onChange={(e) => setAssignFormData({ ...assignFormData, isRequired: e.target.checked })}
|
||||||
|
style={{ marginRight: '0.5rem' }}
|
||||||
|
/>
|
||||||
|
Required Lesson
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
style={{ padding: '0.75rem 1.5rem', background: '#2563eb', color: 'white', border: 'none', borderRadius: '0.375rem', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
Assign Lesson
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowAssignForm(false);
|
||||||
|
setSelectedLesson('');
|
||||||
|
}}
|
||||||
|
style={{ padding: '0.75rem 1.5rem', background: '#6b7280', color: 'white', border: 'none', borderRadius: '0.375rem', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h3 style={{ marginBottom: '1rem' }}>Assigned Lessons</h3>
|
||||||
|
|
||||||
|
{assignedLessons.length === 0 ? (
|
||||||
|
<div className="card" style={{ textAlign: 'center', padding: '3rem' }}>
|
||||||
|
<p style={{ color: '#6b7280' }}>No lessons assigned yet. Add lessons to this event to get started!</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gap: '1rem' }}>
|
||||||
|
{assignedLessons
|
||||||
|
.sort((a, b) => a.order_index - b.order_index)
|
||||||
|
.map(eventLesson => (
|
||||||
|
<div key={eventLesson.id} className="card">
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.5rem' }}>
|
||||||
|
<span style={{ background: '#e5e7eb', padding: '0.25rem 0.5rem', borderRadius: '0.25rem', fontSize: '0.875rem', fontWeight: '500' }}>
|
||||||
|
#{eventLesson.order_index}
|
||||||
|
</span>
|
||||||
|
<h4 style={{ margin: 0 }}>{eventLesson.lesson_title}</h4>
|
||||||
|
{eventLesson.is_required && (
|
||||||
|
<span style={{ background: '#fef3c7', color: '#92400e', padding: '0.25rem 0.5rem', borderRadius: '0.25rem', fontSize: '0.75rem' }}>
|
||||||
|
Required
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p style={{ color: '#6b7280', marginBottom: '1rem', fontSize: '0.875rem' }}>
|
||||||
|
{eventLesson.lesson_description}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: '1rem', fontSize: '0.875rem', color: '#6b7280' }}>
|
||||||
|
<span>🎯 Max Points: {eventLesson.max_points}</span>
|
||||||
|
<span>⚖️ Weight: {eventLesson.weight}×</span>
|
||||||
|
<span>📊 Difficulty: {eventLesson.lesson_difficulty}</span>
|
||||||
|
<span>⏱️ Est. {eventLesson.lesson_duration} min</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const newWeight = parseFloat(prompt('Enter new weight:', eventLesson.weight));
|
||||||
|
if (newWeight && !isNaN(newWeight)) {
|
||||||
|
handleUpdateLesson(eventLesson.id, { weight: newWeight });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ padding: '0.5rem 1rem', background: '#2563eb', color: 'white', border: 'none', borderRadius: '0.375rem', cursor: 'pointer', fontSize: '0.875rem' }}
|
||||||
|
>
|
||||||
|
Edit Weight
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveLesson(eventLesson.id)}
|
||||||
|
style={{ padding: '0.5rem 1rem', background: '#ef4444', color: 'white', border: 'none', borderRadius: '0.375rem', cursor: 'pointer', fontSize: '0.875rem' }}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LessonConfiguration;
|
||||||
234
frontend/src/pages/admin/ParticipantData.jsx
Normal file
234
frontend/src/pages/admin/ParticipantData.jsx
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { adminAPI } from '../../services/api.service';
|
||||||
|
|
||||||
|
const ParticipantData = () => {
|
||||||
|
const { eventId } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [event, setEvent] = useState(null);
|
||||||
|
const [participants, setParticipants] = useState([]);
|
||||||
|
const [selectedParticipant, setSelectedParticipant] = useState(null);
|
||||||
|
const [participantDetails, setParticipantDetails] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [eventId]);
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
const [eventsRes, participantsRes] = await Promise.all([
|
||||||
|
adminAPI.getEvents(),
|
||||||
|
adminAPI.getEventParticipants(eventId)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const currentEvent = eventsRes.data.data.find(e => e.id === parseInt(eventId));
|
||||||
|
setEvent(currentEvent);
|
||||||
|
setParticipants(participantsRes.data.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load data:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadParticipantDetails = async (participantId) => {
|
||||||
|
try {
|
||||||
|
const response = await adminAPI.getParticipantProgress(participantId);
|
||||||
|
setParticipantDetails(response.data.data);
|
||||||
|
setSelectedParticipant(participantId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load participant details:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed': return '#10b981';
|
||||||
|
case 'in_progress': return '#f59e0b';
|
||||||
|
default: return '#6b7280';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusText = (status) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed': return '✅ Completed';
|
||||||
|
case 'in_progress': return '🔄 In Progress';
|
||||||
|
default: return '⭕ Not Started';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading || !event) {
|
||||||
|
return <div style={{ padding: '2rem', textAlign: 'center' }}>Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight: '100vh', background: '#f9fafb' }}>
|
||||||
|
<nav style={{ background: 'white', padding: '1rem 2rem', borderBottom: '1px solid #e5e7eb' }}>
|
||||||
|
<div style={{ maxWidth: '1400px', margin: '0 auto', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ margin: 0, color: '#2563eb' }}>Participant Data</h2>
|
||||||
|
<p style={{ margin: '0.25rem 0 0 0', fontSize: '0.875rem', color: '#6b7280' }}>{event.name}</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => navigate('/admin')} style={{ padding: '0.5rem 1rem', background: '#6b7280', color: 'white', border: 'none', borderRadius: '0.375rem', cursor: 'pointer' }}>
|
||||||
|
Back to Dashboard
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div style={{ maxWidth: '1400px', margin: '0 auto', padding: '2rem' }}>
|
||||||
|
{participants.length === 0 ? (
|
||||||
|
<div className="card" style={{ textAlign: 'center', padding: '3rem' }}>
|
||||||
|
<p style={{ color: '#6b7280' }}>No participants have joined this event yet.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: selectedParticipant ? '400px 1fr' : '1fr', gap: '1.5rem' }}>
|
||||||
|
{/* Participants List */}
|
||||||
|
<div>
|
||||||
|
<h3 style={{ marginBottom: '1rem' }}>Participants ({participants.length})</h3>
|
||||||
|
<div style={{ display: 'grid', gap: '0.75rem' }}>
|
||||||
|
{participants
|
||||||
|
.sort((a, b) => b.total_score - a.total_score)
|
||||||
|
.map((participant, index) => (
|
||||||
|
<div
|
||||||
|
key={participant.id}
|
||||||
|
className="card"
|
||||||
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: selectedParticipant === participant.id ? '#eff6ff' : 'white',
|
||||||
|
border: selectedParticipant === participant.id ? '2px solid #2563eb' : '1px solid #e5e7eb'
|
||||||
|
}}
|
||||||
|
onClick={() => loadParticipantDetails(participant.id)}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
|
<span style={{ fontSize: '1.25rem', fontWeight: '600', color: '#6b7280' }}>
|
||||||
|
#{index + 1}
|
||||||
|
</span>
|
||||||
|
<h4 style={{ margin: 0 }}>{participant.pseudonym}</h4>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.875rem', color: '#6b7280', marginTop: '0.5rem' }}>
|
||||||
|
<div>Score: {participant.total_score} points</div>
|
||||||
|
<div>{participant.completed_lessons}/{participant.total_lessons} lessons completed</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'right', fontSize: '0.75rem', color: '#6b7280' }}>
|
||||||
|
Last active:<br />
|
||||||
|
{new Date(participant.last_active).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Participant Details */}
|
||||||
|
{selectedParticipant && participantDetails && (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||||||
|
<h3>Detailed Progress</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedParticipant(null);
|
||||||
|
setParticipantDetails(null);
|
||||||
|
}}
|
||||||
|
style={{ padding: '0.5rem 1rem', background: '#6b7280', color: 'white', border: 'none', borderRadius: '0.375rem', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Card */}
|
||||||
|
<div className="card" style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<h4 style={{ marginBottom: '1rem' }}>
|
||||||
|
{participants.find(p => p.id === selectedParticipant)?.pseudonym}
|
||||||
|
</h4>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '1rem' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.875rem', color: '#6b7280' }}>Total Score</div>
|
||||||
|
<div style={{ fontSize: '1.5rem', fontWeight: '600', color: '#2563eb' }}>
|
||||||
|
{participantDetails.totalScore}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.875rem', color: '#6b7280' }}>Completed</div>
|
||||||
|
<div style={{ fontSize: '1.5rem', fontWeight: '600', color: '#10b981' }}>
|
||||||
|
{participantDetails.completedLessons}/{participantDetails.totalLessons}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.875rem', color: '#6b7280' }}>Completion Rate</div>
|
||||||
|
<div style={{ fontSize: '1.5rem', fontWeight: '600', color: '#6b7280' }}>
|
||||||
|
{Math.round((participantDetails.completedLessons / participantDetails.totalLessons) * 100)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lesson Progress */}
|
||||||
|
<h4 style={{ marginBottom: '1rem' }}>Lesson Progress</h4>
|
||||||
|
<div style={{ display: 'grid', gap: '1rem' }}>
|
||||||
|
{participantDetails.lessons.map(lesson => (
|
||||||
|
<div key={lesson.event_lesson_id} className="card">
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<h4 style={{ marginBottom: '0.5rem' }}>{lesson.lesson_title}</h4>
|
||||||
|
<div style={{ display: 'flex', gap: '1rem', fontSize: '0.875rem', color: '#6b7280', marginBottom: '1rem' }}>
|
||||||
|
<span style={{ color: getStatusColor(lesson.status) }}>
|
||||||
|
{getStatusText(lesson.status)}
|
||||||
|
</span>
|
||||||
|
{lesson.score !== null && (
|
||||||
|
<span>Score: {lesson.score}/{lesson.max_points} ({lesson.weighted_score} weighted)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lesson.status === 'completed' && (
|
||||||
|
<div style={{ fontSize: '0.875rem', color: '#6b7280' }}>
|
||||||
|
<div>Started: {new Date(lesson.started_at).toLocaleString()}</div>
|
||||||
|
<div>Completed: {new Date(lesson.completed_at).toLocaleString()}</div>
|
||||||
|
<div>Attempts: {lesson.attempts}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{lesson.status === 'in_progress' && (
|
||||||
|
<div style={{ fontSize: '0.875rem', color: '#6b7280' }}>
|
||||||
|
<div>Started: {new Date(lesson.started_at).toLocaleString()}</div>
|
||||||
|
<div>Current step: {lesson.current_step}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show answers for completed lessons */}
|
||||||
|
{lesson.status === 'completed' && lesson.answers && lesson.answers.length > 0 && (
|
||||||
|
<div style={{ marginTop: '1rem', padding: '1rem', background: '#f9fafb', borderRadius: '0.375rem' }}>
|
||||||
|
<div style={{ fontSize: '0.875rem', fontWeight: '600', marginBottom: '0.5rem' }}>Answers:</div>
|
||||||
|
{lesson.answers.map((answer, idx) => (
|
||||||
|
<div key={idx} style={{ fontSize: '0.875rem', marginBottom: '0.5rem', padding: '0.5rem', background: 'white', borderRadius: '0.25rem' }}>
|
||||||
|
<div style={{ fontWeight: '500', marginBottom: '0.25rem' }}>
|
||||||
|
Q{idx + 1}: {answer.question_key}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#6b7280' }}>
|
||||||
|
Answer: {typeof answer.answer_data === 'object' ? JSON.stringify(answer.answer_data) : answer.answer_data}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: answer.is_correct ? '#10b981' : '#ef4444', marginTop: '0.25rem' }}>
|
||||||
|
{answer.is_correct ? '✅' : '❌'} {answer.points_awarded} points
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ParticipantData;
|
||||||
112
frontend/src/routes/AppRoutes.jsx
Normal file
112
frontend/src/routes/AppRoutes.jsx
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { useParticipant } from '../contexts/ParticipantContext';
|
||||||
|
import { useAdmin } from '../contexts/AdminContext';
|
||||||
|
|
||||||
|
// Pages
|
||||||
|
import Hub from '../pages/Hub';
|
||||||
|
import EventLanding from '../pages/EventLanding';
|
||||||
|
import LessonView from '../pages/LessonView';
|
||||||
|
import ParticipantProgress from '../pages/ParticipantProgress';
|
||||||
|
import AdminLogin from '../pages/admin/AdminLogin';
|
||||||
|
import AdminDashboard from '../pages/admin/AdminDashboard';
|
||||||
|
import EventManagement from '../pages/admin/EventManagement';
|
||||||
|
import LessonConfiguration from '../pages/admin/LessonConfiguration';
|
||||||
|
import ParticipantData from '../pages/admin/ParticipantData';
|
||||||
|
|
||||||
|
// Protected route for participants
|
||||||
|
const ParticipantRoute = ({ children }) => {
|
||||||
|
const { isAuthenticated, loading } = useParticipant();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="loading">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isAuthenticated ? children : <Navigate to="/" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Protected route for admin
|
||||||
|
const AdminRoute = ({ children }) => {
|
||||||
|
const { isAuthenticated, loading } = useAdmin();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="loading">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isAuthenticated ? children : <Navigate to="/admin/login" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AppRoutes = () => {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
{/* Public routes */}
|
||||||
|
<Route path="/" element={<Hub />} />
|
||||||
|
<Route path="/admin/login" element={<AdminLogin />} />
|
||||||
|
|
||||||
|
{/* Participant routes */}
|
||||||
|
<Route
|
||||||
|
path="/event"
|
||||||
|
element={
|
||||||
|
<ParticipantRoute>
|
||||||
|
<EventLanding />
|
||||||
|
</ParticipantRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/lesson/:eventLessonId"
|
||||||
|
element={
|
||||||
|
<ParticipantRoute>
|
||||||
|
<LessonView />
|
||||||
|
</ParticipantRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/progress"
|
||||||
|
element={
|
||||||
|
<ParticipantRoute>
|
||||||
|
<ParticipantProgress />
|
||||||
|
</ParticipantRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Admin routes */}
|
||||||
|
<Route
|
||||||
|
path="/admin"
|
||||||
|
element={
|
||||||
|
<AdminRoute>
|
||||||
|
<AdminDashboard />
|
||||||
|
</AdminRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/events"
|
||||||
|
element={
|
||||||
|
<AdminRoute>
|
||||||
|
<EventManagement />
|
||||||
|
</AdminRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/events/:eventId/lessons"
|
||||||
|
element={
|
||||||
|
<AdminRoute>
|
||||||
|
<LessonConfiguration />
|
||||||
|
</AdminRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/events/:eventId/participants"
|
||||||
|
element={
|
||||||
|
<AdminRoute>
|
||||||
|
<ParticipantData />
|
||||||
|
</AdminRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 404 */}
|
||||||
|
<Route path="*" element={<Navigate to="/" />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AppRoutes;
|
||||||
141
frontend/src/services/api.service.js
Normal file
141
frontend/src/services/api.service.js
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api';
|
||||||
|
|
||||||
|
// Create axios instance
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add auth token to requests
|
||||||
|
api.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle errors
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
// Clear token on unauthorized
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Participant API
|
||||||
|
export const participantAPI = {
|
||||||
|
// Join an event
|
||||||
|
joinEvent: (pseudonym, eventId) =>
|
||||||
|
api.post('/participant/join', { pseudonym, eventId }),
|
||||||
|
|
||||||
|
// Get active events
|
||||||
|
getEvents: () =>
|
||||||
|
api.get('/participant/events'),
|
||||||
|
|
||||||
|
// Get profile
|
||||||
|
getProfile: () =>
|
||||||
|
api.get('/participant/profile'),
|
||||||
|
|
||||||
|
// Get progress
|
||||||
|
getProgress: () =>
|
||||||
|
api.get('/participant/progress'),
|
||||||
|
|
||||||
|
// Get lessons for event
|
||||||
|
getEventLessons: (eventId) =>
|
||||||
|
api.get(`/lesson/event/${eventId}/lessons`),
|
||||||
|
|
||||||
|
// Get lesson content
|
||||||
|
getLessonContent: (eventLessonId) =>
|
||||||
|
api.get(`/lesson/${eventLessonId}`),
|
||||||
|
|
||||||
|
// Start lesson
|
||||||
|
startLesson: (eventLessonId) =>
|
||||||
|
api.post(`/lesson/${eventLessonId}/start`),
|
||||||
|
|
||||||
|
// Submit answer
|
||||||
|
submitAnswer: (eventLessonId, questionId, answer) =>
|
||||||
|
api.post(`/lesson/${eventLessonId}/answer`, { questionId, answer }),
|
||||||
|
|
||||||
|
// Complete lesson
|
||||||
|
completeLesson: (eventLessonId) =>
|
||||||
|
api.post(`/lesson/${eventLessonId}/complete`),
|
||||||
|
|
||||||
|
// Execute lesson-specific actions (e.g., SQL query, interactive demos)
|
||||||
|
executeLessonAction: (eventLessonId, action, data) =>
|
||||||
|
api.post(`/lesson/${eventLessonId}/action/${action}`, data)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Admin API
|
||||||
|
export const adminAPI = {
|
||||||
|
// Login
|
||||||
|
login: (username, password) =>
|
||||||
|
api.post('/admin/login', { username, password }),
|
||||||
|
|
||||||
|
// Get profile
|
||||||
|
getProfile: () =>
|
||||||
|
api.get('/admin/profile'),
|
||||||
|
|
||||||
|
// Verify token
|
||||||
|
verifyToken: () =>
|
||||||
|
api.get('/admin/verify'),
|
||||||
|
|
||||||
|
// Events
|
||||||
|
getEvents: () =>
|
||||||
|
api.get('/admin/events'),
|
||||||
|
|
||||||
|
getEvent: (eventId) =>
|
||||||
|
api.get(`/admin/events/${eventId}`),
|
||||||
|
|
||||||
|
createEvent: (eventData) =>
|
||||||
|
api.post('/admin/events', eventData),
|
||||||
|
|
||||||
|
updateEvent: (eventId, eventData) =>
|
||||||
|
api.put(`/admin/events/${eventId}`, eventData),
|
||||||
|
|
||||||
|
deleteEvent: (eventId) =>
|
||||||
|
api.delete(`/admin/events/${eventId}`),
|
||||||
|
|
||||||
|
// Participants
|
||||||
|
getEventParticipants: (eventId) =>
|
||||||
|
api.get(`/admin/events/${eventId}/participants`),
|
||||||
|
|
||||||
|
getEventAnalytics: (eventId) =>
|
||||||
|
api.get(`/admin/events/${eventId}/analytics`),
|
||||||
|
|
||||||
|
// Lessons
|
||||||
|
getAllLessons: () =>
|
||||||
|
api.get('/admin/lessons'),
|
||||||
|
|
||||||
|
getEventLessons: (eventId) =>
|
||||||
|
api.get(`/admin/events/${eventId}/lessons`),
|
||||||
|
|
||||||
|
assignLessonToEvent: (eventId, lessonData) =>
|
||||||
|
api.post(`/admin/events/${eventId}/lessons`, lessonData),
|
||||||
|
|
||||||
|
updateEventLesson: (eventLessonId, updates) =>
|
||||||
|
api.put(`/admin/event-lessons/${eventLessonId}`, updates),
|
||||||
|
|
||||||
|
removeEventLesson: (eventLessonId) =>
|
||||||
|
api.delete(`/admin/event-lessons/${eventLessonId}`),
|
||||||
|
|
||||||
|
// Participant progress
|
||||||
|
getParticipantProgress: (participantId) =>
|
||||||
|
api.get(`/admin/participants/${participantId}/progress`)
|
||||||
|
};
|
||||||
|
|
||||||
|
export default api;
|
||||||
71
frontend/src/services/session.service.js
Normal file
71
frontend/src/services/session.service.js
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
// Session management for participant
|
||||||
|
export const sessionService = {
|
||||||
|
// Save session
|
||||||
|
saveSession: (token, participant, event) => {
|
||||||
|
localStorage.setItem('token', token);
|
||||||
|
localStorage.setItem('participant', JSON.stringify(participant));
|
||||||
|
localStorage.setItem('event', JSON.stringify(event));
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get token
|
||||||
|
getToken: () => {
|
||||||
|
return localStorage.getItem('token');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get participant
|
||||||
|
getParticipant: () => {
|
||||||
|
const data = localStorage.getItem('participant');
|
||||||
|
return data ? JSON.parse(data) : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get event
|
||||||
|
getEvent: () => {
|
||||||
|
const data = localStorage.getItem('event');
|
||||||
|
return data ? JSON.parse(data) : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Clear session
|
||||||
|
clearSession: () => {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('participant');
|
||||||
|
localStorage.removeItem('event');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Check if has session
|
||||||
|
hasSession: () => {
|
||||||
|
return !!localStorage.getItem('token');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Admin session management
|
||||||
|
export const adminSessionService = {
|
||||||
|
// Save admin session
|
||||||
|
saveSession: (token, admin) => {
|
||||||
|
localStorage.setItem('adminToken', token);
|
||||||
|
localStorage.setItem('admin', JSON.stringify(admin));
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get admin token
|
||||||
|
getToken: () => {
|
||||||
|
return localStorage.getItem('adminToken');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get admin
|
||||||
|
getAdmin: () => {
|
||||||
|
const data = localStorage.getItem('admin');
|
||||||
|
return data ? JSON.parse(data) : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Clear admin session
|
||||||
|
clearSession: () => {
|
||||||
|
localStorage.removeItem('adminToken');
|
||||||
|
localStorage.removeItem('admin');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Check if has admin session
|
||||||
|
hasSession: () => {
|
||||||
|
return !!localStorage.getItem('adminToken');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default sessionService;
|
||||||
95
frontend/src/styles/index.css
Normal file
95
frontend/src/styles/index.css
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
/* Reset and base styles */
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--primary-color: #2563eb;
|
||||||
|
--secondary-color: #10b981;
|
||||||
|
--danger-color: #ef4444;
|
||||||
|
--warning-color: #f59e0b;
|
||||||
|
--bg-color: #f9fafb;
|
||||||
|
--text-color: #111827;
|
||||||
|
--border-color: #e5e7eb;
|
||||||
|
--shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
|
||||||
|
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||||
|
sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus, textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
22
frontend/vite.config.js
Normal file
22
frontend/vite.config.js
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: true,
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: process.env.VITE_API_URL || 'http://localhost:3000',
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/api/, '/api')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
sourcemap: false,
|
||||||
|
chunkSizeWarningLimit: 1000
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user