initial commit

This commit is contained in:
Marius Rometsch 2026-02-05 22:42:30 +01:00
commit 0068785924
66 changed files with 13795 additions and 0 deletions

18
.dockerignore Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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"]

View 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
View 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

View 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

View 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

View 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

View 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;

View 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;

View 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;

View 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

File diff suppressed because it is too large Load Diff

35
backend/package.json Normal file
View 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"
}
}

View 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);
});

View 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
};

View 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'
};

View 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
};

View 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
};

View 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
};

View 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
};

View 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
View 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;

View 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
};

View 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
};

View 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
};

View 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
};

View 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
};

View 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
};

View 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;

View 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;

View 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;

View 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
};

View 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
};

View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

23
frontend/package.json Normal file
View 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
View 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;

View 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;

View File

@ -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;

View 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;

View 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
View 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>
);

View 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
View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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
View 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
}
});