Compare commits
194 Commits
c9813a7ff4
..
gege
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ac5ead63a | |||
| 63533c0313 | |||
| 1af7bdc3f0 | |||
| 129ea694f8 | |||
| 9f3a5b6fd7 | |||
| 79786d8bb1 | |||
| f8917f6862 | |||
| 384456ffd3 | |||
| 3c85fd72ef | |||
| 6065ab2800 | |||
| bfcdd3ec9d | |||
| 46369ed112 | |||
| d915a7fe1c | |||
| 99ed8fea54 | |||
| a818d49701 | |||
| 04954cec4a | |||
| dbe06c5c0c | |||
| 8ce04afe8b | |||
| e21980d07d | |||
| 39e0d36a7f | |||
| d3dcb7f7da | |||
| d0741c273f | |||
| 825d7a91e2 | |||
| fe8d5a53a5 | |||
| b75d27c7c8 | |||
| 63b261c023 | |||
| 7b7938ed08 | |||
| 8c25c56e88 | |||
| ab35f73158 | |||
| 4b06a65bd9 | |||
| 94943d4988 | |||
| 18110ba410 | |||
| f746cfd23f | |||
| 44645bb3fc | |||
| 7a9a676fc0 | |||
| 1ca0f54032 | |||
| d90f92c91c | |||
| 1ad4af5864 | |||
| 6867cb2b72 | |||
| cea9062f91 | |||
| e3f752ce8a | |||
| b9fedb3601 | |||
| 0ae66b3307 | |||
| 630283e922 | |||
| 0ed75beb3f | |||
| 8ff8e80e31 | |||
| 5722846da3 | |||
| a64829f8cb | |||
| a5f38f791d | |||
| 8960bd9dce | |||
| df75095651 | |||
| 94cdf54b83 | |||
| b73d1528c4 | |||
| 387ebbc64d | |||
| 3bbd3f1e8a | |||
| f2a54154f5 | |||
| edca8f84cd | |||
| 4501257a15 | |||
| 38a2aeb58a | |||
| 0ca0e95540 | |||
| ec001fb39f | |||
| 00b13de70c | |||
| 83efb91f52 | |||
| 9673d564a0 | |||
| 5ba043cff8 | |||
| 46ad6caefd | |||
| f56ebbf2c3 | |||
| c207fa5961 | |||
| 0a811741c7 | |||
| d16d481d86 | |||
| 3ad9ba3e3f | |||
| 825e9d1a08 | |||
| ad5f13a8e1 | |||
| 237378c208 | |||
| a1cf327837 | |||
| c31bf9d4fb | |||
| ef0b1916f2 | |||
| 1c01e4ce24 | |||
| 8b5cf2c1e5 | |||
| 023219e41b | |||
| 2d7778f7d1 | |||
| aa3587b60a | |||
| 99fa7ebd98 | |||
| 23c4b838d4 | |||
| bfe977d35b | |||
| 5194308f7c | |||
| 8d24e8ffa6 | |||
| 1bf3253128 | |||
| 96487fb065 | |||
| 9ef83f7963 | |||
| 27fc028bad | |||
| d1b4141e63 | |||
| 76fa204ae8 | |||
| 75f2b215a1 | |||
| 367524d611 | |||
| 86bf2675eb | |||
| 2c190dc874 | |||
| 36db09e5e7 | |||
| f7885dc440 | |||
| a9c2f63adc | |||
| bec9d83ef3 | |||
| cf68530fc2 | |||
| f2b154d491 | |||
| 1e10a93e32 | |||
| a5dd9003c1 | |||
| 1db1776217 | |||
| 87dc8ffff4 | |||
| 04a87b8293 | |||
| a25807aca1 | |||
| 9e88eba43f | |||
| 14a94ea03f | |||
| e392ade3f8 | |||
| 8980d98394 | |||
| 8f6634b03f | |||
| c690fb602e | |||
| bba4044eaf | |||
| b4d31f3660 | |||
| f27a1df90f | |||
| bf9ae5f01f | |||
| 83fad59878 | |||
| 016b5632e1 | |||
| 1cf8066cf3 | |||
| cf157643d7 | |||
| 638f78da94 | |||
| 173109d352 | |||
| 74a4cd4f1d | |||
| 3af8de2797 | |||
| df532a0e2a | |||
| d1377291ab | |||
| 37f81f25a7 | |||
| a1d33d9318 | |||
| 7963f28021 | |||
| 8bc5e0e130 | |||
| 14fd1fa189 | |||
| f216435dd0 | |||
| 0b90e4217a | |||
| 137b110c74 | |||
| 68335a9d5f | |||
| 684216ab40 | |||
| e9af77200d | |||
| 19cfa031d0 | |||
| d8598755e0 | |||
| a1ff3beb35 | |||
| b288b29e35 | |||
| 2c8f1bcca0 | |||
| 34a6df5949 | |||
| 3e82b19480 | |||
| 6720375fa1 | |||
| 1893d0006d | |||
| 725516ad6c | |||
| aba7a506ad | |||
| 585e7c96fb | |||
| 4bf667a1ac | |||
| 8600fa7c1d | |||
| 270bb79451 | |||
| b10143ba1a | |||
| 19c762fe67 | |||
| 9296782fc1 | |||
| fa868e7c1d | |||
| 724162b9c9 | |||
| 85e188b5e2 | |||
| fe08dd3603 | |||
| 370dc9934b | |||
| 3012707ba8 | |||
| 48c29d81d0 | |||
| c4b86143bf | |||
| 8948751bbc | |||
| e5b601e483 | |||
| 76c513d8fb | |||
| f68540f511 | |||
| 2bc2138d0e | |||
| 87c790aa05 | |||
| 38a54f9005 | |||
| 7fa4150b3a | |||
| ea83034e9a | |||
| 298c31597f | |||
| 59160cbbcb | |||
| 6d452ab71f | |||
| 4765d14123 | |||
| f65696ce32 | |||
| eb696d9d27 | |||
| 8acc7d30fc | |||
| 8a7500eb69 | |||
| f398183332 | |||
| 94702a33aa | |||
| f089d314ca | |||
| 7eaf2408a1 | |||
| a231fa4b5e | |||
| 68cec47d09 | |||
| b93363330f | |||
| 175db04ec6 | |||
| d5cddb186d | |||
| 28ced1c764 | |||
| ceeab2647d |
+4
-1
@@ -6,4 +6,7 @@ Archive_*/**
|
||||
**/node_modules/**
|
||||
|
||||
#ignore dist folder
|
||||
**/dist/**
|
||||
**/dist/**
|
||||
|
||||
#ignore log files
|
||||
**/*.log
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+62
@@ -0,0 +1,62 @@
|
||||
Javitás
|
||||
|
||||
Deckeck:
|
||||
- Következmény csak szerencse kártyánál
|
||||
- Egy fajta következmény (/lap, automatikusan kerül végrehajtásra)
|
||||
- Hibás kártya pakli mentésekor is törlödjön
|
||||
- extra kör, kimarad bármennyi 1-től 5-ig
|
||||
- megnyitás, szerkesztés, adatok betöltése
|
||||
- Mentési ADATOK Csekkolása | ZSOLA
|
||||
- Closer option
|
||||
|
||||
navbar:
|
||||
- tegnapiak
|
||||
|
||||
TEGNAPI HIBÁK JAVÍTÁSA:
|
||||
- kapcs fel routing
|
||||
- navbar széthúz
|
||||
- footer kapcsolat
|
||||
- navabar gomboksorrend
|
||||
- vagy kontat vagy kapcsolat
|
||||
- navbar bejelent
|
||||
- navbar layout finomít
|
||||
- palki info get
|
||||
|
||||
|
||||
GET /ap/decks/page/:from/:to (0-49) 50db (50-99) 50db ... (0-29) 30db => (30-59) 30db
|
||||
- from: (oldalsz-1)*dbsz (pl: (1-1)*30=0; (2-1)*30=30)
|
||||
- to: (oldalsz*dbsz) - 1 (pl: (1*30)-1=29; (2*30)-1 =59)
|
||||
|
||||
email verifikáció:
|
||||
- verify-email/:code => Email címe hitelesítés alatt: stb
|
||||
- ha sikeres => login => toastify => email címe hitelesítve
|
||||
- ha sikertelen => home/register => toastify/pushup => sikertelen vegye fel velünk a kapcsolatot
|
||||
|
||||
- POST api/users/verify-email/:code <= BACKEND URI
|
||||
|
||||
|
||||
|
||||
HOLNAP ESTE 19:00 => Jó lenne, ha ezek megvannak
|
||||
HOLNAPTÓL => JÁTÉK => SOCKET IO működése
|
||||
|
||||
|
||||
Mobil nézet:
|
||||
- landing page
|
||||
- navbar
|
||||
- footer
|
||||
- pakli fő nézet => bar
|
||||
- pakli összerakás és szerkesztés
|
||||
- bejelentkezés
|
||||
- regisztráció
|
||||
|
||||
User felület:
|
||||
- Saját adatok lekérése
|
||||
- Saját adatok módosítása:
|
||||
- email-cím
|
||||
- telefonszám
|
||||
- jelszó
|
||||
- felhasználó név
|
||||
- Saját profil törlése
|
||||
- Elfelelejtett jelszó
|
||||
- Kérése => email-cím alapján => POST /api/users/forgot-password
|
||||
- password-reset/:token => POST /api/users/reset-password
|
||||
@@ -0,0 +1,41 @@
|
||||
# Development Environment Variables for Local Build
|
||||
# These are used when running build scripts outside of Docker containers
|
||||
|
||||
NODE_ENV=development
|
||||
PORT=3000
|
||||
|
||||
# Database Configuration (Docker containers)
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_NAME=serpentrace
|
||||
DB_USERNAME=postgres
|
||||
DB_PASSWORD=postgres
|
||||
|
||||
# Redis Configuration (Docker containers)
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
# JWT Configuration
|
||||
JWT_SECRET=dev_jwt_secret_change_in_production
|
||||
JWT_EXPIRATION=24h
|
||||
JWT_REFRESH_EXPIRATION=7d
|
||||
|
||||
# MinIO Configuration (Docker containers)
|
||||
MINIO_ENDPOINT=localhost
|
||||
MINIO_PORT=9000
|
||||
MINIO_ACCESS_KEY=serpentrace
|
||||
MINIO_SECRET_KEY=serpentrace123!
|
||||
MINIO_USE_SSL=false
|
||||
|
||||
# Board Generation Configuration
|
||||
MAX_SPECIAL_FIELDS_PERCENTAGE=67
|
||||
MAX_GENERATION_TIME_SECONDS=20
|
||||
GENERATION_ERROR_TOLERANCE=15
|
||||
|
||||
# EMAIL SERVICE CONFIGURATION
|
||||
EMAIL_HOST=smtp.gmail.com
|
||||
EMAIL_PORT=587
|
||||
EMAIL_USER=your_email@domain.com
|
||||
EMAIL_PASS=your_email_password
|
||||
EMAIL_FROM=noreply@serpentrace.com
|
||||
@@ -20,10 +20,28 @@ REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
# JWT CONFIGURATION
|
||||
JWT_SECRET=your_super_secret_jwt_key_change_in_production
|
||||
JWT_EXPIRY=86400
|
||||
JWT_EXPIRATION=24h
|
||||
# JWT AUTHENTICATION CONFIGURATION
|
||||
JWT_SECRET=your-super-secure-secret-key-here
|
||||
JWT_REFRESH_SECRET=your-super-secure-refresh-secret-key-here
|
||||
|
||||
# Access Token Expiry (choose ONE option, priority order listed):
|
||||
JWT_ACCESS_TOKEN_EXPIRY=1800 # Seconds (recommended for production)
|
||||
# JWT_ACCESS_TOKEN_EXPIRATION=30m # Duration string (user-friendly)
|
||||
# JWT_EXPIRY=1800 # Legacy: seconds
|
||||
# JWT_EXPIRATION=30m # Legacy: duration string
|
||||
|
||||
# Refresh Token Expiry (choose ONE option, priority order listed):
|
||||
JWT_REFRESH_TOKEN_EXPIRY=604800 # Seconds (7 days)
|
||||
# JWT_REFRESH_TOKEN_EXPIRATION=7d # Duration string (recommended)
|
||||
# JWT_REFRESH_EXPIRATION=7d # Legacy: duration string
|
||||
|
||||
# Cookie Names (optional)
|
||||
JWT_COOKIE_NAME=auth_token
|
||||
JWT_REFRESH_COOKIE_NAME=refresh_token
|
||||
|
||||
# Legacy JWT Configuration (deprecated - use above options)
|
||||
# JWT_EXPIRY=86400
|
||||
# JWT_EXPIRATION=24h
|
||||
GAME_TOKEN_EXPIRY=86400
|
||||
|
||||
# EMAIL SERVICE CONFIGURATION
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
./node_modules/*
|
||||
./Archive_*/*
|
||||
./Archive_*
|
||||
./logs/*
|
||||
./logs/*
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
# JWT Refresh Token Implementation Guide
|
||||
|
||||
## Overview
|
||||
|
||||
The JWT authentication system supports both **cookie-based** and **header-based** (Bearer token) authentication with comprehensive refresh token functionality and proper logout logic. **All authentication methods now use refresh tokens** - there is no legacy single-token mode.
|
||||
|
||||
## Features
|
||||
|
||||
- **Dual Authentication Methods**: Support for both cookie-based and Bearer token authentication
|
||||
- **Universal Refresh Tokens**: All logins receive both access and refresh tokens
|
||||
- **Automatic Token Refresh**: Tokens are refreshed when 75% of their lifetime has passed
|
||||
- **Logout Functionality**: Proper token blacklisting and cleanup
|
||||
- **Security**: Short-lived access tokens (30 minutes) and longer-lived refresh tokens (7 days)
|
||||
|
||||
## Authentication Methods
|
||||
|
||||
### 1. Cookie-Based Authentication
|
||||
- Access token stored in `auth_token` cookie
|
||||
- Refresh token stored in `refresh_token` cookie
|
||||
- Suitable for web applications with same-origin requests
|
||||
- Tokens also returned in response body
|
||||
|
||||
### 2. Bearer Token Authentication
|
||||
- Access token sent in `Authorization: Bearer <token>` header
|
||||
- Refresh token sent in `X-Refresh-Token` header
|
||||
- Suitable for mobile apps, SPAs, and API integrations
|
||||
- Tokens returned in response body
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Login
|
||||
```http
|
||||
POST /api/user/login
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"username": "user@example.com",
|
||||
"password": "password123"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (all logins):**
|
||||
```json
|
||||
{
|
||||
"user": { ... },
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
}
|
||||
```
|
||||
|
||||
For cookie-based auth, tokens are also set as httpOnly cookies.
|
||||
|
||||
### Refresh Token
|
||||
```http
|
||||
POST /api/user/refresh-token
|
||||
```
|
||||
|
||||
**For Cookie-based auth:**
|
||||
- Refresh token is read from `refresh_token` cookie
|
||||
- New tokens are set as cookies AND returned in response body
|
||||
|
||||
**For Bearer token auth:**
|
||||
```http
|
||||
POST /api/user/refresh-token
|
||||
X-Refresh-Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Tokens refreshed successfully",
|
||||
"accessToken": "new_access_token",
|
||||
"refreshToken": "new_refresh_token"
|
||||
}
|
||||
```
|
||||
|
||||
### Logout
|
||||
```http
|
||||
POST /api/user/logout
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```env
|
||||
# JWT Configuration
|
||||
JWT_SECRET=your-secret-key-for-access-tokens
|
||||
JWT_REFRESH_SECRET=your-secret-key-for-refresh-tokens
|
||||
|
||||
# Access Token Expiry (use one of these)
|
||||
JWT_ACCESS_TOKEN_EXPIRY=1800 # Access token expiry in seconds (30 minutes)
|
||||
JWT_ACCESS_TOKEN_EXPIRATION=30m # Access token expiry (supports s, m, h, d)
|
||||
JWT_EXPIRY=1800 # Legacy: Access token expiry in seconds
|
||||
JWT_EXPIRATION=30m # Legacy: Access token expiry with duration
|
||||
|
||||
# Refresh Token Expiry (use one of these)
|
||||
JWT_REFRESH_TOKEN_EXPIRY=604800 # Refresh token expiry in seconds (7 days)
|
||||
JWT_REFRESH_TOKEN_EXPIRATION=7d # Refresh token expiry (supports s, m, h, d)
|
||||
JWT_REFRESH_EXPIRATION=7d # Legacy: Refresh token expiry with duration
|
||||
|
||||
# Cookie Names (optional)
|
||||
JWT_COOKIE_NAME=auth_token # Access token cookie name (default: auth_token)
|
||||
JWT_REFRESH_COOKIE_NAME=refresh_token # Refresh token cookie name (default: refresh_token)
|
||||
```
|
||||
|
||||
### Environment Variable Priority
|
||||
|
||||
**Access Token Expiry** (checked in order):
|
||||
1. `JWT_ACCESS_TOKEN_EXPIRY` (seconds)
|
||||
2. `JWT_ACCESS_TOKEN_EXPIRATION` (duration string)
|
||||
3. `JWT_EXPIRY` (seconds) - legacy
|
||||
4. `JWT_EXPIRATION` (duration string) - legacy
|
||||
5. Default: 1800 seconds (30 minutes)
|
||||
|
||||
**Refresh Token Expiry** (checked in order):
|
||||
1. `JWT_REFRESH_TOKEN_EXPIRY` (seconds)
|
||||
2. `JWT_REFRESH_TOKEN_EXPIRATION` (duration string)
|
||||
3. `JWT_REFRESH_EXPIRATION` (duration string) - legacy
|
||||
4. Default: 604800 seconds (7 days)
|
||||
|
||||
### Duration String Format
|
||||
Supports: `s` (seconds), `m` (minutes), `h` (hours), `d` (days)
|
||||
Examples: `30s`, `15m`, `2h`, `7d`
|
||||
|
||||
## Token Structure
|
||||
|
||||
### Access Token Payload
|
||||
```json
|
||||
{
|
||||
"userId": "user-uuid",
|
||||
"authLevel": 0,
|
||||
"userStatus": 1,
|
||||
"orgId": "org-uuid",
|
||||
"type": "access",
|
||||
"iat": 1640995200,
|
||||
"exp": 1640997000
|
||||
}
|
||||
```
|
||||
|
||||
### Refresh Token Payload
|
||||
```json
|
||||
{
|
||||
"userId": "user-uuid",
|
||||
"orgId": "org-uuid",
|
||||
"type": "refresh",
|
||||
"iat": 1640995200,
|
||||
"exp": 1641600000
|
||||
}
|
||||
```
|
||||
|
||||
## Automatic Token Refresh
|
||||
|
||||
The system automatically refreshes tokens when:
|
||||
- Token is within 25% of its expiration time (75% of lifetime has passed)
|
||||
- Valid refresh token is available
|
||||
- User makes an authenticated request
|
||||
|
||||
**✅ Automatic refresh happens on every authenticated API call** - no manual intervention needed!
|
||||
|
||||
### Response Headers
|
||||
For Bearer token authentication, refresh responses include:
|
||||
- `X-New-Access-Token`: New access token
|
||||
- `X-New-Refresh-Token`: New refresh token
|
||||
- `X-Token-Refreshed`: "true" indicator
|
||||
|
||||
### Manual Refresh (Optional)
|
||||
|
||||
While automatic refresh handles most scenarios, manual refresh is available for:
|
||||
- **Proactive refresh**: Before critical operations
|
||||
- **Background apps**: Long-running applications that need fresh tokens
|
||||
- **Offline recovery**: When app reconnects after being offline
|
||||
|
||||
```http
|
||||
POST /api/user/refresh-token
|
||||
X-Refresh-Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
```
|
||||
|
||||
## Client Implementation Examples
|
||||
|
||||
### JavaScript/TypeScript (Fetch API)
|
||||
|
||||
```typescript
|
||||
class ApiClient {
|
||||
private accessToken: string = '';
|
||||
private refreshToken: string = '';
|
||||
|
||||
async login(username: string, password: string) {
|
||||
const response = await fetch('/api/user/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
this.accessToken = data.token;
|
||||
this.refreshToken = data.refreshToken; // Always present now
|
||||
return data;
|
||||
}
|
||||
|
||||
async makeAuthenticatedRequest(url: string, options: RequestInit = {}) {
|
||||
const headers = {
|
||||
'Authorization': `Bearer ${this.accessToken}`,
|
||||
...options.headers
|
||||
};
|
||||
|
||||
let response = await fetch(url, { ...options, headers });
|
||||
|
||||
// Automatically handle token refresh (tokens updated in response headers)
|
||||
if (response.headers.get('X-Token-Refreshed') === 'true') {
|
||||
const newAccessToken = response.headers.get('X-New-Access-Token');
|
||||
const newRefreshToken = response.headers.get('X-New-Refresh-Token');
|
||||
|
||||
if (newAccessToken) this.accessToken = newAccessToken;
|
||||
if (newRefreshToken) this.refreshToken = newRefreshToken;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// Optional: Manual refresh (usually not needed due to automatic refresh)
|
||||
async refreshTokens() {
|
||||
const response = await fetch('/api/user/refresh-token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Refresh-Token': this.refreshToken
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
this.accessToken = data.accessToken;
|
||||
this.refreshToken = data.refreshToken;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async logout() {
|
||||
await fetch('/api/user/logout', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${this.accessToken}` }
|
||||
});
|
||||
|
||||
this.accessToken = '';
|
||||
this.refreshToken = '';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### React Hook Example
|
||||
|
||||
```typescript
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
export const useAuth = () => {
|
||||
const [accessToken, setAccessToken] = useState<string>('');
|
||||
const [refreshToken, setRefreshToken] = useState<string>('');
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
const response = await fetch('/api/user/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
setAccessToken(data.token);
|
||||
setRefreshToken(data.refreshToken); // Always present
|
||||
return data;
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
if (accessToken) {
|
||||
await fetch('/api/user/logout', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${accessToken}` }
|
||||
});
|
||||
}
|
||||
setAccessToken('');
|
||||
setRefreshToken('');
|
||||
}, [accessToken]);
|
||||
|
||||
return { accessToken, refreshToken, login, logout };
|
||||
};
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Token Blacklisting**: Logout tokens are blacklisted in Redis with TTL matching token expiration
|
||||
2. **Short-lived Access Tokens**: 30-minute expiry reduces exposure window
|
||||
3. **Secure Cookies**: httpOnly, secure, sameSite attributes for cookie-based auth
|
||||
4. **Token Rotation**: Refresh tokens are rotated on each refresh
|
||||
5. **Environment-specific Secrets**: Different secrets for access and refresh tokens
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### From Single Token to Refresh Token System
|
||||
|
||||
Since this is a new implementation, all clients should expect:
|
||||
|
||||
1. **Login Response**: Always includes both `token` (access) and `refreshToken`
|
||||
2. **Token Storage**: Store both tokens securely
|
||||
3. **API Requests**: Use access token in Authorization header
|
||||
4. **Automatic Refresh**: Tokens refresh automatically - just watch for response headers
|
||||
5. **Logout**: Call logout endpoint to invalidate tokens
|
||||
|
||||
**Key Point**: Manual refresh is optional since automatic refresh handles token renewal seamlessly.
|
||||
|
||||
**No backward compatibility needed** - this is the only authentication method.
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
# Login and get tokens
|
||||
curl -X POST http://localhost:3000/api/user/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username": "test@example.com", "password": "password"}'
|
||||
|
||||
# Use access token
|
||||
curl -X GET http://localhost:3000/api/user/profile \
|
||||
-H "Authorization: Bearer <access_token>"
|
||||
|
||||
# Refresh tokens
|
||||
curl -X POST http://localhost:3000/api/user/refresh-token \
|
||||
-H "X-Refresh-Token: <refresh_token>"
|
||||
|
||||
# Logout
|
||||
curl -X POST http://localhost:3000/api/user/logout \
|
||||
-H "Authorization: Bearer <access_token>"
|
||||
```
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 981 KiB |
@@ -0,0 +1,28 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>/tests', '<rootDir>/src'],
|
||||
testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'],
|
||||
transform: {
|
||||
'^.+\\.ts$': 'ts-jest',
|
||||
},
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.ts',
|
||||
'!src/**/*.d.ts',
|
||||
'!src/Api/index.ts',
|
||||
'!src/Infrastructure/ormconfig.ts',
|
||||
'!src/search-demo.ts'
|
||||
],
|
||||
coverageDirectory: 'coverage',
|
||||
coverageReporters: ['text', 'lcov', 'html'],
|
||||
moduleFileExtensions: ['ts', 'js', 'json'],
|
||||
setupFilesAfterEnv: ['<rootDir>/tests/setup.ts'],
|
||||
testTimeout: 10000,
|
||||
setupFiles: ['<rootDir>/tests/jest.setup.ts'],
|
||||
verbose: true,
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1'
|
||||
},
|
||||
resolver: undefined,
|
||||
moduleDirectories: ['node_modules', '<rootDir>/src', '<rootDir>/tests']
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
// Quick test to demonstrate the language detection functionality
|
||||
const { extractLanguageFromAcceptHeader } = require('./src/Api/contactRouter.js');
|
||||
|
||||
// Test cases to demonstrate Accept-Language parsing
|
||||
const testCases = [
|
||||
'en-US,en;q=0.9',
|
||||
'hu,en;q=0.9',
|
||||
'de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7',
|
||||
'hu-HU,hu;q=0.9,en-US;q=0.8',
|
||||
'fr-FR,fr;q=0.9,en;q=0.8',
|
||||
'es,en-US;q=0.9,en;q=0.8',
|
||||
'invalid-header',
|
||||
''
|
||||
];
|
||||
|
||||
console.log('Testing Accept-Language header parsing:\n');
|
||||
|
||||
testCases.forEach(header => {
|
||||
const result = extractLanguageFromAcceptHeader(header);
|
||||
console.log(`Header: "${header}" -> Language: ${result}`);
|
||||
});
|
||||
|
||||
console.log('\n✅ Multi-language system is working correctly!');
|
||||
console.log('\nFeatures implemented:');
|
||||
console.log('- Accept-Language header parsing with quality values');
|
||||
console.log('- Support for EN, HU, DE templates');
|
||||
console.log('- Custom header detection (X-Language, X-Region, X-Locale)');
|
||||
console.log('- Fallback to English for unsupported languages');
|
||||
console.log('- Professional email templates in all three languages');
|
||||
+513
@@ -0,0 +1,513 @@
|
||||
|
||||
/* build-hook-start *//*00001*/try { require('c:\\Users\\magdo\\.vscode\\extensions\\wallabyjs.console-ninja-1.0.483\\out\\buildHook\\index.js').default({tool: 'jest', checkSum: '201794f25617bd9f0b124dAgcXBEgHD1IJVgZUCgQHUVUCDFwF', mode: 'build', condition: true}); } catch(cjsError) { try { import('file:///c:/Users/magdo/.vscode/extensions/wallabyjs.console-ninja-1.0.483/out/buildHook/index.js').then(m => m.default.default({tool: 'jest', checkSum: '201794f25617bd9f0b124dAgcXBEgHD1IJVgZUCgQHUVUCDFwF', mode: 'build', condition: true})).catch(esmError => {}) } catch(esmError) {}}/* build-hook-end */
|
||||
|
||||
/*!
|
||||
* /**
|
||||
* * Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
* *
|
||||
* * This source code is licensed under the MIT license found in the
|
||||
* * LICENSE file in the root directory of this source tree.
|
||||
* * /
|
||||
*/
|
||||
/******/ (() => { // webpackBootstrap
|
||||
/******/ "use strict";
|
||||
/******/ var __webpack_modules__ = ({
|
||||
|
||||
/***/ "./src/runTest.ts":
|
||||
/***/ ((__unused_webpack_module, exports) => {
|
||||
|
||||
|
||||
|
||||
Object.defineProperty(exports, "__esModule", ({
|
||||
value: true
|
||||
}));
|
||||
exports["default"] = runTest;
|
||||
function _nodeVm() {
|
||||
const data = require("node:vm");
|
||||
_nodeVm = function () {
|
||||
return data;
|
||||
};
|
||||
return data;
|
||||
}
|
||||
function _chalk() {
|
||||
const data = _interopRequireDefault(require("chalk"));
|
||||
_chalk = function () {
|
||||
return data;
|
||||
};
|
||||
return data;
|
||||
}
|
||||
function fs() {
|
||||
const data = _interopRequireWildcard(require("graceful-fs"));
|
||||
fs = function () {
|
||||
return data;
|
||||
};
|
||||
return data;
|
||||
}
|
||||
function sourcemapSupport() {
|
||||
const data = _interopRequireWildcard(require("source-map-support"));
|
||||
sourcemapSupport = function () {
|
||||
return data;
|
||||
};
|
||||
return data;
|
||||
}
|
||||
function _console() {
|
||||
const data = require("@jest/console");
|
||||
_console = function () {
|
||||
return data;
|
||||
};
|
||||
return data;
|
||||
}
|
||||
function _transform() {
|
||||
const data = require("@jest/transform");
|
||||
_transform = function () {
|
||||
return data;
|
||||
};
|
||||
return data;
|
||||
}
|
||||
function docblock() {
|
||||
const data = _interopRequireWildcard(require("jest-docblock"));
|
||||
docblock = function () {
|
||||
return data;
|
||||
};
|
||||
return data;
|
||||
}
|
||||
function _jestLeakDetector() {
|
||||
const data = _interopRequireDefault(require("jest-leak-detector"));
|
||||
_jestLeakDetector = function () {
|
||||
return data;
|
||||
};
|
||||
return data;
|
||||
}
|
||||
function _jestMessageUtil() {
|
||||
const data = require("jest-message-util");
|
||||
_jestMessageUtil = function () {
|
||||
return data;
|
||||
};
|
||||
return data;
|
||||
}
|
||||
function _jestResolve() {
|
||||
const data = require("jest-resolve");
|
||||
_jestResolve = function () {
|
||||
return data;
|
||||
};
|
||||
return data;
|
||||
}
|
||||
function _jestUtil() {
|
||||
const data = require("jest-util");
|
||||
_jestUtil = function () {
|
||||
return data;
|
||||
};
|
||||
return data;
|
||||
}
|
||||
function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); }
|
||||
function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
|
||||
function freezeConsole(testConsole, config) {
|
||||
// @ts-expect-error: `_log` is `private` - we should figure out some proper API here
|
||||
testConsole._log = function fakeConsolePush(_type, message) {
|
||||
const error = new (_jestUtil().ErrorWithStack)(`${_chalk().default.red(`${_chalk().default.bold('Cannot log after tests are done.')} Did you forget to wait for something async in your test?`)}\nAttempted to log "${message}".`, fakeConsolePush);
|
||||
const formattedError = (0, _jestMessageUtil().formatExecError)(error, config, {
|
||||
noStackTrace: false
|
||||
}, undefined, true);
|
||||
process.stderr.write(`\n${formattedError}\n`);
|
||||
process.exitCode = 1;
|
||||
};
|
||||
}
|
||||
|
||||
// Keeping the core of "runTest" as a separate function (as "runTestInternal")
|
||||
// is key to be able to detect memory leaks. Since all variables are local to
|
||||
// the function, when "runTestInternal" finishes its execution, they can all be
|
||||
// freed, UNLESS something else is leaking them (and that's why we can detect
|
||||
// the leak!).
|
||||
//
|
||||
// If we had all the code in a single function, we should manually nullify all
|
||||
// references to verify if there is a leak, which is not maintainable and error
|
||||
// prone. That's why "runTestInternal" CANNOT be inlined inside "runTest".
|
||||
async function runTestInternal(path, globalConfig, projectConfig, resolver, context, sendMessageToJest) {
|
||||
const testSource = fs().readFileSync(path, 'utf8');
|
||||
const docblockPragmas = docblock().parse(docblock().extract(testSource));
|
||||
const customEnvironment = docblockPragmas['jest-environment'];
|
||||
const loadTestEnvironmentStart = Date.now();
|
||||
let testEnvironment = projectConfig.testEnvironment;
|
||||
if (customEnvironment) {
|
||||
if (Array.isArray(customEnvironment)) {
|
||||
throw new TypeError(`You can only define a single test environment through docblocks, got "${customEnvironment.join(', ')}"`);
|
||||
}
|
||||
testEnvironment = (0, _jestResolve().resolveTestEnvironment)({
|
||||
...projectConfig,
|
||||
// we wanna avoid webpack trying to be clever
|
||||
requireResolveFunction: module => require.resolve(module),
|
||||
testEnvironment: customEnvironment
|
||||
});
|
||||
}
|
||||
const cacheFS = new Map([[path, testSource]]);
|
||||
const transformer = await (0, _transform().createScriptTransformer)(projectConfig, cacheFS);
|
||||
const TestEnvironment = await transformer.requireAndTranspileModule(testEnvironment);
|
||||
const testFramework = await transformer.requireAndTranspileModule(process.env.JEST_JASMINE === '1' ? require.resolve('jest-jasmine2') : projectConfig.testRunner);
|
||||
const Runtime = (0, _jestUtil().interopRequireDefault)(projectConfig.runtime ? require(projectConfig.runtime) : require('jest-runtime')).default;
|
||||
const consoleOut = globalConfig.useStderr ? process.stderr : process.stdout;
|
||||
const consoleFormatter = (type, message) => (0, _console().getConsoleOutput)(
|
||||
// 4 = the console call is buried 4 stack frames deep
|
||||
_console().BufferedConsole.write([], type, message, 4), projectConfig, globalConfig);
|
||||
let testConsole;
|
||||
if (globalConfig.silent) {
|
||||
testConsole = new (_console().NullConsole)(consoleOut, consoleOut, consoleFormatter);
|
||||
} else if (globalConfig.verbose) {
|
||||
testConsole = new (_console().CustomConsole)(consoleOut, consoleOut, consoleFormatter);
|
||||
} else {
|
||||
testConsole = new (_console().BufferedConsole)();
|
||||
}
|
||||
let extraTestEnvironmentOptions;
|
||||
const docblockEnvironmentOptions = docblockPragmas['jest-environment-options'];
|
||||
if (typeof docblockEnvironmentOptions === 'string') {
|
||||
extraTestEnvironmentOptions = JSON.parse(docblockEnvironmentOptions);
|
||||
}
|
||||
const environment = new TestEnvironment({
|
||||
globalConfig,
|
||||
projectConfig: extraTestEnvironmentOptions ? {
|
||||
...projectConfig,
|
||||
testEnvironmentOptions: {
|
||||
...projectConfig.testEnvironmentOptions,
|
||||
...extraTestEnvironmentOptions
|
||||
}
|
||||
} : projectConfig
|
||||
}, {
|
||||
console: testConsole,
|
||||
docblockPragmas,
|
||||
testPath: path
|
||||
});
|
||||
const loadTestEnvironmentEnd = Date.now();
|
||||
if (typeof environment.getVmContext !== 'function') {
|
||||
console.error(`Test environment found at "${testEnvironment}" does not export a "getVmContext" method, which is mandatory from Jest 27. This method is a replacement for "runScript".`);
|
||||
process.exit(1);
|
||||
}
|
||||
const leakDetector = projectConfig.detectLeaks ? new (_jestLeakDetector().default)(environment) : null;
|
||||
(0, _jestUtil().setGlobal)(environment.global, 'console', testConsole, 'retain');
|
||||
const runtime = new Runtime(projectConfig, environment, resolver, transformer, cacheFS, {
|
||||
changedFiles: context.changedFiles,
|
||||
collectCoverage: globalConfig.collectCoverage,
|
||||
collectCoverageFrom: globalConfig.collectCoverageFrom,
|
||||
coverageProvider: globalConfig.coverageProvider,
|
||||
sourcesRelatedToTestsInChangedFiles: context.sourcesRelatedToTestsInChangedFiles
|
||||
}, path, globalConfig);
|
||||
let isTornDown = false;
|
||||
const tearDownEnv = async () => {
|
||||
if (!isTornDown) {
|
||||
runtime.teardown();
|
||||
|
||||
// source-map-support keeps memory leftovers in `Error.prepareStackTrace`
|
||||
(0, _nodeVm().runInContext)("Error.prepareStackTrace = () => '';", environment.getVmContext());
|
||||
sourcemapSupport().resetRetrieveHandlers();
|
||||
try {
|
||||
await environment.teardown();
|
||||
} finally {
|
||||
isTornDown = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
const start = Date.now();
|
||||
const setupFilesStart = Date.now();
|
||||
for (const path of projectConfig.setupFiles) {
|
||||
const esm = runtime.unstable_shouldLoadAsEsm(path);
|
||||
if (esm) {
|
||||
await runtime.unstable_importModule(path);
|
||||
} else {
|
||||
const setupFile = runtime.requireModule(path);
|
||||
if (typeof setupFile === 'function') {
|
||||
await setupFile();
|
||||
}
|
||||
}
|
||||
}
|
||||
const setupFilesEnd = Date.now();
|
||||
const sourcemapOptions = {
|
||||
environment: 'node',
|
||||
handleUncaughtExceptions: false,
|
||||
retrieveSourceMap: source => {
|
||||
const sourceMapSource = runtime.getSourceMaps()?.get(source);
|
||||
if (sourceMapSource) {
|
||||
try {
|
||||
return {
|
||||
map: JSON.parse(fs().readFileSync(sourceMapSource, 'utf8')),
|
||||
url: source
|
||||
};
|
||||
} catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// For tests
|
||||
runtime.requireInternalModule(require.resolve('source-map-support')).install(sourcemapOptions);
|
||||
|
||||
// For runtime errors
|
||||
sourcemapSupport().install(sourcemapOptions);
|
||||
if (environment.global && environment.global.process && environment.global.process.exit) {
|
||||
const realExit = environment.global.process.exit;
|
||||
environment.global.process.exit = function exit(...args) {
|
||||
const error = new (_jestUtil().ErrorWithStack)(`process.exit called with "${args.join(', ')}"`, exit);
|
||||
const formattedError = (0, _jestMessageUtil().formatExecError)(error, projectConfig, {
|
||||
noStackTrace: false
|
||||
}, undefined, true);
|
||||
process.stderr.write(formattedError);
|
||||
return realExit(...args);
|
||||
};
|
||||
}
|
||||
|
||||
// if we don't have `getVmContext` on the env skip coverage
|
||||
const collectV8Coverage = globalConfig.collectCoverage && globalConfig.coverageProvider === 'v8' && typeof environment.getVmContext === 'function';
|
||||
|
||||
// Node's error-message stack size is limited at 10, but it's pretty useful
|
||||
// to see more than that when a test fails.
|
||||
Error.stackTraceLimit = 100;
|
||||
try {
|
||||
await environment.setup();
|
||||
let result;
|
||||
try {
|
||||
if (collectV8Coverage) {
|
||||
await runtime.collectV8Coverage();
|
||||
}
|
||||
result = await testFramework(globalConfig, projectConfig, environment, runtime, path, sendMessageToJest);
|
||||
} catch (error) {
|
||||
// Access all stacks before uninstalling sourcemaps
|
||||
let e = error;
|
||||
while (typeof e === 'object' && e !== null && 'stack' in e) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
e.stack;
|
||||
e = e?.cause;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (collectV8Coverage) {
|
||||
await runtime.stopCollectingV8Coverage();
|
||||
}
|
||||
}
|
||||
freezeConsole(testConsole, projectConfig);
|
||||
const testCount = result.numPassingTests + result.numFailingTests + result.numPendingTests + result.numTodoTests;
|
||||
const end = Date.now();
|
||||
const testRuntime = end - start;
|
||||
result.perfStats = {
|
||||
...result.perfStats,
|
||||
end,
|
||||
loadTestEnvironmentEnd,
|
||||
loadTestEnvironmentStart,
|
||||
runtime: testRuntime,
|
||||
setupFilesEnd,
|
||||
setupFilesStart,
|
||||
slow: testRuntime / 1000 > projectConfig.slowTestThreshold,
|
||||
start
|
||||
};
|
||||
result.testFilePath = path;
|
||||
result.console = testConsole.getBuffer();
|
||||
result.skipped = testCount === result.numPendingTests;
|
||||
result.displayName = projectConfig.displayName;
|
||||
const coverage = runtime.getAllCoverageInfoCopy();
|
||||
if (coverage) {
|
||||
const coverageKeys = Object.keys(coverage);
|
||||
if (coverageKeys.length > 0) {
|
||||
result.coverage = coverage;
|
||||
}
|
||||
}
|
||||
if (collectV8Coverage) {
|
||||
const v8Coverage = runtime.getAllV8CoverageInfoCopy();
|
||||
if (v8Coverage && v8Coverage.length > 0) {
|
||||
result.v8Coverage = v8Coverage;
|
||||
}
|
||||
}
|
||||
if (globalConfig.logHeapUsage) {
|
||||
globalThis.gc?.();
|
||||
result.memoryUsage = process.memoryUsage().heapUsed;
|
||||
}
|
||||
await tearDownEnv();
|
||||
|
||||
// Delay the resolution to allow log messages to be output.
|
||||
return await new Promise(resolve => {
|
||||
setImmediate(() => resolve({
|
||||
leakDetector,
|
||||
result
|
||||
}));
|
||||
});
|
||||
} finally {
|
||||
await tearDownEnv();
|
||||
}
|
||||
}
|
||||
async function runTest(path, globalConfig, config, resolver, context, sendMessageToJest) {
|
||||
const {
|
||||
leakDetector,
|
||||
result
|
||||
} = await runTestInternal(path, globalConfig, config, resolver, context, sendMessageToJest);
|
||||
if (leakDetector) {
|
||||
// We wanna allow a tiny but time to pass to allow last-minute cleanup
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Resolve leak detector, outside the "runTestInternal" closure.
|
||||
result.leaks = await leakDetector.isLeaking();
|
||||
} else {
|
||||
result.leaks = false;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/***/ })
|
||||
|
||||
/******/ });
|
||||
/************************************************************************/
|
||||
/******/ // The module cache
|
||||
/******/ var __webpack_module_cache__ = {};
|
||||
/******/
|
||||
/******/ // The require function
|
||||
/******/ function __webpack_require__(moduleId) {
|
||||
/******/ // Check if module is in cache
|
||||
/******/ var cachedModule = __webpack_module_cache__[moduleId];
|
||||
/******/ if (cachedModule !== undefined) {
|
||||
/******/ return cachedModule.exports;
|
||||
/******/ }
|
||||
/******/ // Create a new module (and put it into the cache)
|
||||
/******/ var module = __webpack_module_cache__[moduleId] = {
|
||||
/******/ // no module.id needed
|
||||
/******/ // no module.loaded needed
|
||||
/******/ exports: {}
|
||||
/******/ };
|
||||
/******/
|
||||
/******/ // Execute the module function
|
||||
/******/ __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
|
||||
/******/
|
||||
/******/ // Return the exports of the module
|
||||
/******/ return module.exports;
|
||||
/******/ }
|
||||
/******/
|
||||
/************************************************************************/
|
||||
var __webpack_exports__ = {};
|
||||
// This entry needs to be wrapped in an IIFE because it uses a non-standard name for the exports (exports).
|
||||
(() => {
|
||||
var exports = __webpack_exports__;
|
||||
|
||||
|
||||
Object.defineProperty(exports, "__esModule", ({
|
||||
value: true
|
||||
}));
|
||||
exports.setup = setup;
|
||||
exports.worker = worker;
|
||||
function _exitX() {
|
||||
const data = _interopRequireDefault(require("exit-x"));
|
||||
_exitX = function () {
|
||||
return data;
|
||||
};
|
||||
return data;
|
||||
}
|
||||
function _jestHasteMap() {
|
||||
const data = _interopRequireDefault(require("jest-haste-map"));
|
||||
_jestHasteMap = function () {
|
||||
return data;
|
||||
};
|
||||
return data;
|
||||
}
|
||||
function _jestMessageUtil() {
|
||||
const data = require("jest-message-util");
|
||||
_jestMessageUtil = function () {
|
||||
return data;
|
||||
};
|
||||
return data;
|
||||
}
|
||||
function _jestRuntime() {
|
||||
const data = _interopRequireDefault(require("jest-runtime"));
|
||||
_jestRuntime = function () {
|
||||
return data;
|
||||
};
|
||||
return data;
|
||||
}
|
||||
function _jestWorker() {
|
||||
const data = require("jest-worker");
|
||||
_jestWorker = function () {
|
||||
return data;
|
||||
};
|
||||
return data;
|
||||
}
|
||||
var _runTest = _interopRequireDefault(__webpack_require__("./src/runTest.ts"));
|
||||
function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
// Make sure uncaught errors are logged before we exit.
|
||||
process.on('uncaughtException', err => {
|
||||
if (err.stack) {
|
||||
console.error(err.stack);
|
||||
} else {
|
||||
console.error(err);
|
||||
}
|
||||
(0, _exitX().default)(1);
|
||||
});
|
||||
const formatError = error => {
|
||||
if (typeof error === 'string') {
|
||||
const {
|
||||
message,
|
||||
stack
|
||||
} = (0, _jestMessageUtil().separateMessageFromStack)(error);
|
||||
return {
|
||||
message,
|
||||
stack,
|
||||
type: 'Error'
|
||||
};
|
||||
}
|
||||
return {
|
||||
code: error.code || undefined,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
type: 'Error'
|
||||
};
|
||||
};
|
||||
const resolvers = new Map();
|
||||
const getResolver = config => {
|
||||
const resolver = resolvers.get(config.id);
|
||||
if (!resolver) {
|
||||
throw new Error(`Cannot find resolver for: ${config.id}`);
|
||||
}
|
||||
return resolver;
|
||||
};
|
||||
function setup(setupData) {
|
||||
// Module maps that will be needed for the test runs are passed.
|
||||
for (const {
|
||||
config,
|
||||
serializableModuleMap
|
||||
} of setupData.serializableResolvers) {
|
||||
const moduleMap = _jestHasteMap().default.getStatic(config).getModuleMapFromJSON(serializableModuleMap);
|
||||
resolvers.set(config.id, _jestRuntime().default.createResolver(config, moduleMap));
|
||||
}
|
||||
}
|
||||
const sendMessageToJest = (eventName, args) => {
|
||||
(0, _jestWorker().messageParent)([eventName, args]);
|
||||
};
|
||||
async function worker({
|
||||
config,
|
||||
globalConfig,
|
||||
path,
|
||||
context
|
||||
}) {
|
||||
try {
|
||||
return await (0, _runTest.default)(path, globalConfig, config, getResolver(config), {
|
||||
...context,
|
||||
changedFiles: context.changedFiles && new Set(context.changedFiles),
|
||||
sourcesRelatedToTestsInChangedFiles: context.sourcesRelatedToTestsInChangedFiles && new Set(context.sourcesRelatedToTestsInChangedFiles)
|
||||
}, sendMessageToJest);
|
||||
} catch (error) {
|
||||
throw formatError(error);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
module.exports = __webpack_exports__;
|
||||
/******/ })()
|
||||
;
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env node
|
||||
/* build-hook-start *//*00001*/try { require('c:\\Users\\magdo\\.vscode\\extensions\\wallabyjs.console-ninja-1.0.483\\out\\buildHook\\index.js').default({tool: 'jest', checkSum: '201794f25617bd9f0b124dAgcXBEgHD1IJVgZUCgQHUVUCDFwF', mode: 'build', condition: true}); } catch(cjsError) { try { import('file:///c:/Users/magdo/.vscode/extensions/wallabyjs.console-ninja-1.0.483/out/buildHook/index.js').then(m => m.default.default({tool: 'jest', checkSum: '201794f25617bd9f0b124dAgcXBEgHD1IJVgZUCgQHUVUCDFwF', mode: 'build', condition: true})).catch(esmError => {}) } catch(esmError) {}}/* build-hook-end */
|
||||
|
||||
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
const importLocal = require('import-local');
|
||||
|
||||
if (!importLocal(__filename)) {
|
||||
require('jest-cli/bin/jest');
|
||||
}
|
||||
Generated
+582
-41
File diff suppressed because it is too large
Load Diff
@@ -40,9 +40,6 @@
|
||||
"watch": "tsc --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/nodemailer": "^7.0.1",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"express": "^5.1.0",
|
||||
@@ -53,13 +50,14 @@
|
||||
"nodemailer": "^7.0.5",
|
||||
"pg": "^8.16.3",
|
||||
"redis": "^5.8.1",
|
||||
"sharp": "^0.34.4",
|
||||
"socket.io": "^4.8.1",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typeorm": "^0.3.26",
|
||||
"uuid": "^11.1.0",
|
||||
"winston": "^3.17.0",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.1"
|
||||
"winston": "^3.17.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jest/globals": "^30.0.5",
|
||||
@@ -68,7 +66,9 @@
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^24.3.3",
|
||||
"@types/nodemailer": "^7.0.1",
|
||||
"@types/pg": "^8.15.5",
|
||||
"@types/redis": "^4.0.10",
|
||||
"@types/socket.io": "^3.0.1",
|
||||
@@ -76,6 +76,7 @@
|
||||
"@types/supertest": "^6.0.3",
|
||||
"@types/swagger-jsdoc": "^6.0.4",
|
||||
"@types/swagger-ui-express": "^4.1.8",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"jest": "^30.0.5",
|
||||
"nodemon": "^3.1.10",
|
||||
"rimraf": "^5.0.10",
|
||||
|
||||
@@ -13,6 +13,10 @@ import deckImportExportRouter from './routers/deckImportExportRouter';
|
||||
import gameRouter from './routers/gameRouter';
|
||||
import { LoggingService, logStartup, logConnection, logError, logRequest } from '../Application/Services/Logger';
|
||||
import { WebSocketService } from '../Application/Services/WebSocketService';
|
||||
import { GameWebSocketService } from '../Application/Services/GameWebSocketService';
|
||||
import { GameRepository } from '../Infrastructure/Repository/GameRepository';
|
||||
import { UserRepository } from '../Infrastructure/Repository/UserRepository';
|
||||
import { RedisService } from '../Application/Services/RedisService';
|
||||
import { setupSwagger } from './swagger/swaggerUiSetup';
|
||||
|
||||
const app = express();
|
||||
@@ -41,7 +45,7 @@ app.use(loggingService.requestLoggingMiddleware());
|
||||
|
||||
app.use((req, res, next) => {
|
||||
const origin = req.headers.origin;
|
||||
const allowedOrigins = ['http://localhost:3000', 'http://localhost:3001', 'http://localhost:8080'];
|
||||
const allowedOrigins = ['http://localhost:3000', 'http://localhost:3001', 'http://localhost:8080', process.env.FRONTEND_URL];
|
||||
|
||||
if (!origin || allowedOrigins.includes(origin)) {
|
||||
res.setHeader('Access-Control-Allow-Origin', origin || '*');
|
||||
@@ -161,6 +165,7 @@ app.use((req: express.Request, res: express.Response) => {
|
||||
|
||||
// Initialize WebSocket service after database connection
|
||||
let webSocketService: WebSocketService;
|
||||
let gameWebSocketService: GameWebSocketService;
|
||||
|
||||
// Initialize database connection
|
||||
AppDataSource.initialize()
|
||||
@@ -177,6 +182,19 @@ AppDataSource.initialize()
|
||||
logStartup('WebSocket service initialized', {
|
||||
chatInactivityTimeout: process.env.CHAT_INACTIVITY_TIMEOUT_MINUTES || '30'
|
||||
});
|
||||
|
||||
// Initialize Game WebSocket service for /game namespace
|
||||
const gameRepository = new GameRepository();
|
||||
const userRepository = new UserRepository();
|
||||
const redisService = RedisService.getInstance();
|
||||
|
||||
gameWebSocketService = new GameWebSocketService(
|
||||
webSocketService['io'], // Access the io property directly
|
||||
gameRepository,
|
||||
userRepository,
|
||||
redisService
|
||||
);
|
||||
logStartup('Game WebSocket service initialized for /game namespace');
|
||||
})
|
||||
.catch((error) => {
|
||||
const dbOptions = AppDataSource.options as any;
|
||||
@@ -248,5 +266,5 @@ process.on('unhandledRejection', (reason, promise) => {
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Export WebSocket service for game integration
|
||||
export { webSocketService };
|
||||
// Export WebSocket services for game integration
|
||||
export { webSocketService, gameWebSocketService };
|
||||
|
||||
@@ -141,32 +141,32 @@ router.get('/users/:userId',
|
||||
});
|
||||
|
||||
// Search users including soft-deleted ones
|
||||
// router.get('/users/search/:searchTerm',
|
||||
// adminRequired,
|
||||
// ValidationMiddleware.validateStringLength({ searchTerm: { min: 2, max: 100 } }),
|
||||
// async (req: Request, res: Response) => {
|
||||
// try {
|
||||
// const { searchTerm } = req.params;
|
||||
// const includeDeleted = req.query.includeDeleted === 'true';
|
||||
router.get('/users/search/:searchTerm',
|
||||
adminRequired,
|
||||
ValidationMiddleware.validateStringLength({ searchTerm: { min: 2, max: 100 } }),
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { searchTerm } = req.params;
|
||||
const includeDeleted = req.query.includeDeleted === 'true';
|
||||
|
||||
// logRequest('Admin search users endpoint accessed', req, res, { searchTerm, includeDeleted });
|
||||
logRequest('Admin search users endpoint accessed', req, res, { searchTerm, includeDeleted });
|
||||
|
||||
// const users = includeDeleted
|
||||
// ? await container.userRepository.searchIncludingDeleted(searchTerm)
|
||||
// : await container.userRepository.search(searchTerm);
|
||||
const users = includeDeleted
|
||||
? await container.userRepository.searchIncludingDeleted(searchTerm)
|
||||
: await container.userRepository.search(searchTerm);
|
||||
|
||||
// logRequest('Admin user search completed', req, res, {
|
||||
// searchTerm,
|
||||
// resultCount: Array.isArray(users) ? users.length : (users.totalCount || 0),
|
||||
// includeDeleted
|
||||
// });
|
||||
logRequest('Admin user search completed', req, res, {
|
||||
searchTerm,
|
||||
resultCount: Array.isArray(users) ? users.length : (users.totalCount || 0),
|
||||
includeDeleted
|
||||
});
|
||||
|
||||
// res.json(users);
|
||||
// } catch (error) {
|
||||
// logError('Admin search users endpoint error', error as Error, req, res);
|
||||
// res.status(500).json({ error: 'Internal server error' });
|
||||
// }
|
||||
// });
|
||||
res.json(users);
|
||||
} catch (error) {
|
||||
logError('Admin search users endpoint error', error as Error, req, res);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update any user (admin only)
|
||||
router.patch('/users/:userId',
|
||||
@@ -213,6 +213,32 @@ router.patch('/users/:userId',
|
||||
}
|
||||
});
|
||||
|
||||
// Activate user (admin only)
|
||||
router.post('/users/:userId/activate',
|
||||
adminRequired,
|
||||
ValidationMiddleware.validateUUIDFormat(['userId']),
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const targetUserId = req.params.userId;
|
||||
const adminUserId = (req as any).user.userId;
|
||||
|
||||
logRequest('Admin activate user endpoint accessed', req, res, { adminUserId, targetUserId });
|
||||
|
||||
const result = await container.activateUserCommandHandler.execute({ id: targetUserId });
|
||||
|
||||
if (!result) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
logAuth('User activated by admin', targetUserId, { adminUserId }, req, res);
|
||||
res.json({ message: 'User activated successfully', user: result });
|
||||
|
||||
} catch (error) {
|
||||
logError('Admin activate user endpoint error', error as Error, req, res);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Deactivate user (admin only)
|
||||
router.post('/users/:userId/deactivate',
|
||||
adminRequired,
|
||||
@@ -385,11 +411,12 @@ router.patch('/decks/:id', adminRequired, async (req: Request, res: Response) =>
|
||||
// Hard delete deck (admin only)
|
||||
router.delete('/decks/:id/hard', adminRequired, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const adminUserId = (req as any).user.userId;
|
||||
const deckId = req.params.id;
|
||||
logRequest('Admin hard delete deck endpoint accessed', req, res, { deckId });
|
||||
|
||||
const result = await container.deleteDeckCommandHandler.execute({ id: deckId, soft: false });
|
||||
|
||||
|
||||
const result = await container.deleteDeckCommandHandler.execute({ userid: adminUserId, authLevel: 1, id: deckId, soft: false });
|
||||
|
||||
logRequest('Admin deck hard delete successful', req, res, { deckId, success: result });
|
||||
res.json({ success: result });
|
||||
} catch (error) {
|
||||
|
||||
@@ -5,9 +5,53 @@ import { ErrorResponseService } from '../../Application/Services/ErrorResponseSe
|
||||
import { ValidationMiddleware } from '../../Application/Services/ValidationMiddleware';
|
||||
import { GeneralSearchService } from '../../Application/Search/Generalsearch';
|
||||
import { logRequest, logError, logWarning } from '../../Application/Services/Logger';
|
||||
import { Type, CType } from '../../Domain/Deck/DeckAggregate';
|
||||
|
||||
const deckRouter = Router();
|
||||
|
||||
/**
|
||||
* Helper function to convert string enum values to integer enum values
|
||||
*/
|
||||
function convertEnumValues(data: any): any {
|
||||
const converted = { ...data };
|
||||
|
||||
// Convert Type enum
|
||||
if (converted.type && typeof converted.type === 'string') {
|
||||
switch (converted.type.toUpperCase()) {
|
||||
case 'LUCK':
|
||||
converted.type = Type.LUCK;
|
||||
break;
|
||||
case 'JOKER':
|
||||
converted.type = Type.JOKER;
|
||||
break;
|
||||
case 'QUESTION':
|
||||
converted.type = Type.QUESTION;
|
||||
break;
|
||||
default:
|
||||
throw new Error('Invalid deck type. Must be LUCK, JOKER, or QUESTION');
|
||||
}
|
||||
}
|
||||
|
||||
// Convert CType enum
|
||||
if (converted.ctype && typeof converted.ctype === 'string') {
|
||||
switch (converted.ctype.toUpperCase()) {
|
||||
case 'PUBLIC':
|
||||
converted.ctype = CType.PUBLIC;
|
||||
break;
|
||||
case 'PRIVATE':
|
||||
converted.ctype = CType.PRIVATE;
|
||||
break;
|
||||
case 'ORGANIZATION':
|
||||
converted.ctype = CType.ORGANIZATION;
|
||||
break;
|
||||
default:
|
||||
throw new Error('Invalid deck ctype. Must be PUBLIC, PRIVATE, or ORGANIZATION');
|
||||
}
|
||||
}
|
||||
|
||||
return converted;
|
||||
}
|
||||
|
||||
// Create search service that isn't in the container yet
|
||||
const searchService = new GeneralSearchService(container.userRepository, container.organizationRepository, container.deckRepository);
|
||||
|
||||
@@ -60,14 +104,25 @@ deckRouter.post('/', authRequired, async (req, res) => {
|
||||
try {
|
||||
const userId = (req as any).user.userId;
|
||||
logRequest('Create deck endpoint accessed', req, res, { name: req.body.name, userId });
|
||||
req.body.userid = userId; // Set userId in request body
|
||||
const result = await container.createDeckCommandHandler.execute(req.body);
|
||||
|
||||
// Convert string enum values to integers
|
||||
const command = convertEnumValues({
|
||||
...req.body,
|
||||
userid: userId
|
||||
});
|
||||
|
||||
const result = await container.createDeckCommandHandler.execute(command);
|
||||
|
||||
logRequest('Deck created successfully', req, res, { deckId: result.id, name: req.body.name, userId });
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logError('Create deck endpoint error', error as Error, req, res);
|
||||
|
||||
// Handle enum validation errors
|
||||
if (error instanceof Error && error.message.includes('Invalid deck')) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
|
||||
if (error instanceof Error && (error.message.includes('duplicate') || error.message.includes('unique constraint'))) {
|
||||
return res.status(409).json({ error: 'Deck with this name already exists' });
|
||||
}
|
||||
@@ -144,15 +199,24 @@ deckRouter.patch('/:id', authRequired, async (req, res) => {
|
||||
try {
|
||||
const deckId = req.params.id;
|
||||
const userId = (req as any).user.userId;
|
||||
const authLevel = (req as any).user.authLevel;
|
||||
logRequest('Update deck endpoint accessed', req, res, { deckId, userId, updateFields: Object.keys(req.body) });
|
||||
|
||||
const result = await container.updateDeckCommandHandler.execute({ id: deckId, ...req.body });
|
||||
// Convert string enum values to integers
|
||||
const updateData = convertEnumValues(req.body);
|
||||
|
||||
const result = await container.updateDeckCommandHandler.execute({ userid: userId, authLevel: authLevel, id: deckId, ...updateData });
|
||||
|
||||
logRequest('Deck updated successfully', req, res, { deckId, userId });
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logError('Update deck endpoint error', error as Error, req, res);
|
||||
|
||||
// Handle enum validation errors
|
||||
if (error instanceof Error && error.message.includes('Invalid deck')) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.message.includes('not found')) {
|
||||
return res.status(404).json({ error: 'Deck not found' });
|
||||
}
|
||||
@@ -165,6 +229,10 @@ deckRouter.patch('/:id', authRequired, async (req, res) => {
|
||||
return res.status(400).json({ error: 'Invalid input data', details: error.message });
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.message.includes('admin')) {
|
||||
return res.status(403).json({ error: 'Forbidden: ' + error.message });
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.message.includes('admin')) {
|
||||
return res.status(403).json({ error: 'Forbidden: ' + error.message });
|
||||
}
|
||||
@@ -177,10 +245,11 @@ deckRouter.delete('/:id', authRequired, async (req, res) => {
|
||||
try {
|
||||
const deckId = req.params.id;
|
||||
const userId = (req as any).user.userId;
|
||||
const authLevel = (req as any).user.authLevel;
|
||||
logRequest('Soft delete deck endpoint accessed', req, res, { deckId, userId });
|
||||
|
||||
const result = await container.deleteDeckCommandHandler.execute({ id: deckId, soft: true });
|
||||
|
||||
|
||||
const result = await container.deleteDeckCommandHandler.execute({ userid: userId, authLevel: authLevel, id: deckId, soft: true });
|
||||
|
||||
logRequest('Deck soft delete successful', req, res, { deckId, userId, success: result });
|
||||
res.json({ success: result });
|
||||
} catch (error) {
|
||||
|
||||
@@ -206,7 +206,26 @@ gameRouter.post('/join', optionalAuth, async (req, res) => {
|
||||
playerName: actualPlayerName
|
||||
});
|
||||
|
||||
res.json(game);
|
||||
// Create game token for WebSocket authentication
|
||||
const gameTokenService = container.gameTokenService;
|
||||
const gameToken = gameTokenService.createGameToken(
|
||||
game.id,
|
||||
game.gamecode,
|
||||
actualPlayerName || 'Anonymous',
|
||||
actualPlayerId
|
||||
);
|
||||
|
||||
// Return clean response with essential data + game token
|
||||
res.json({
|
||||
id: game.id,
|
||||
gamecode: game.gamecode,
|
||||
playerName: actualPlayerName,
|
||||
playerCount: game.players.length,
|
||||
maxPlayers: game.maxplayers,
|
||||
gameType: LoginType[gameToJoin.logintype],
|
||||
isAuthenticated: !!actualPlayerId,
|
||||
gameToken: gameToken
|
||||
});
|
||||
} catch (error) {
|
||||
logError('Join game endpoint error', error as Error, req, res);
|
||||
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import e, { Router } from 'express';
|
||||
import { container, DIContainer } from '../../Application/Services/DIContainer';
|
||||
import { ErrorResponseService } from '../../Application/Services/ErrorResponseService';
|
||||
import { logRequest, logError, logAuth, logWarning, logOther } from '../../Application/Services/Logger';
|
||||
import { GenerateBoardCommand } from '../../Application/Game/commands/GenerateBoardCommand';
|
||||
|
||||
const router = Router();
|
||||
|
||||
//function to test the search service
|
||||
async function triggerAsyncBoardGeneration(gameId: string): Promise<boolean> {
|
||||
try {
|
||||
// Calculate default field counts based on game configuration
|
||||
// For now, use reasonable defaults - this should be configurable by host in the future
|
||||
const maxSpecialFieldsPercentage = parseInt(process.env.MAX_SPECIAL_FIELDS_PERCENTAGE || '67');
|
||||
const maxSpecialFields = Math.floor((100 * maxSpecialFieldsPercentage) / 100);
|
||||
|
||||
// Default distribution: 60% positive, 25% negative, 15% luck
|
||||
const positiveFieldCount = Math.floor(maxSpecialFields * 0.6);
|
||||
const negativeFieldCount = Math.floor(maxSpecialFields * 0.25);
|
||||
const luckFieldCount = Math.floor(maxSpecialFields * 0.15);
|
||||
|
||||
const command: GenerateBoardCommand = {
|
||||
gameId,
|
||||
positiveFieldCount,
|
||||
negativeFieldCount,
|
||||
luckFieldCount
|
||||
};
|
||||
|
||||
logOther(`Triggering async board generation for game ${gameId}`, {
|
||||
positiveFieldCount,
|
||||
negativeFieldCount,
|
||||
luckFieldCount,
|
||||
totalSpecialFields: positiveFieldCount + negativeFieldCount + luckFieldCount
|
||||
});
|
||||
|
||||
// Execute board generation in background
|
||||
await DIContainer.getInstance().generateBoardCommandHandler.execute(command);
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
logError(`Async board generation failed for game ${gameId}`, error as Error);
|
||||
// Don't propagate error - board generation failure shouldn't affect game creation
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Game board generation endpoint
|
||||
router.post('/gameBoardGeneration', async (req, res) => {
|
||||
try {
|
||||
logRequest('Game board generation endpoint accessed', req, res);
|
||||
|
||||
const result = await triggerAsyncBoardGeneration("######-#####-#####-######");
|
||||
|
||||
if (result) {
|
||||
logOther('Game board generation triggered successfully', result);
|
||||
return res.json({ message: 'Game board generation triggered successfully' });
|
||||
} else {
|
||||
throw new Error('Game board generation failed to trigger');
|
||||
}
|
||||
} catch (error : any) {
|
||||
logError('Error in game board generation endpoint', error);
|
||||
return ErrorResponseService.sendInternalServerError(res);
|
||||
}
|
||||
});
|
||||
export default router;
|
||||
@@ -29,7 +29,7 @@ userRouter.post('/login',
|
||||
const result = await container.loginCommandHandler.execute({ username, password }, res);
|
||||
|
||||
if (result) {
|
||||
logAuth('User login successful', result.user.id, { username: result.user.username }, req, res);
|
||||
logAuth('User login successful', undefined, { username: result.user.username }, req, res);
|
||||
res.json(result);
|
||||
} else {
|
||||
throw new Error(`Login failed: ${result}`);
|
||||
@@ -77,11 +77,14 @@ userRouter.post('/create',
|
||||
email: req.body.email
|
||||
});
|
||||
|
||||
const result = await container.createUserCommandHandler.execute(req.body);
|
||||
|
||||
logRequest('User created successfully', req, res, {
|
||||
userId: result.id,
|
||||
username: result.username
|
||||
const acceptLanguage = req.header('Accept-Language') || 'en';
|
||||
const language : 'hu' | 'de' | 'en' = acceptLanguage.toLowerCase().startsWith('hu') ? 'hu' :
|
||||
acceptLanguage.toLowerCase().startsWith('de') ? 'de' : 'en';
|
||||
|
||||
const result = await container.createUserCommandHandler.execute({ ...req.body, language });
|
||||
|
||||
logRequest('User created successfully', req, res, {
|
||||
username: result.username
|
||||
});
|
||||
|
||||
res.status(201).json(result);
|
||||
@@ -199,8 +202,34 @@ userRouter.post('/logout', authRequired, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Refresh token endpoint
|
||||
userRouter.post('/refresh-token', async (req, res) => {
|
||||
try {
|
||||
logRequest('Token refresh endpoint accessed', req, res);
|
||||
|
||||
const jwtService = container.jwtService;
|
||||
const newTokenPair = jwtService.attemptTokenRefresh(req, res);
|
||||
|
||||
if (newTokenPair) {
|
||||
logRequest('Token refresh successful', req, res);
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Tokens refreshed successfully',
|
||||
accessToken: newTokenPair.accessToken,
|
||||
refreshToken: newTokenPair.refreshToken
|
||||
});
|
||||
} else {
|
||||
logWarning('Token refresh failed - invalid or missing refresh token', undefined, req, res);
|
||||
return ErrorResponseService.sendUnauthorized(res, 'Invalid or expired refresh token');
|
||||
}
|
||||
} catch (error) {
|
||||
logError('Refresh token endpoint error', error as Error, req, res);
|
||||
return ErrorResponseService.sendInternalServerError(res);
|
||||
}
|
||||
});
|
||||
|
||||
// Email verification endpoint
|
||||
userRouter.get('/verify-email/:token', async (req, res) => {
|
||||
userRouter.post('/verify-email/:token', async (req, res) => {
|
||||
try {
|
||||
const { token } = req.params;
|
||||
|
||||
@@ -243,10 +272,13 @@ userRouter.post('/forgot-password',
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { email } = req.body;
|
||||
const acceptLanguage = req.header('Accept-Language') || 'en';
|
||||
const language: 'hu' | 'de' | 'en' = acceptLanguage.toLowerCase().startsWith('hu') ? 'hu' :
|
||||
acceptLanguage.toLowerCase().startsWith('de') ? 'de' : 'en';
|
||||
|
||||
logRequest('Forgot password endpoint accessed', req, res, { email });
|
||||
|
||||
const result = await container.requestPasswordResetCommandHandler.execute({ email });
|
||||
const result = await container.requestPasswordResetCommandHandler.execute({ language, email });
|
||||
|
||||
if (result) {
|
||||
logAuth('Password reset request successful', undefined, { email }, req, res);
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
/**
|
||||
* @swagger
|
||||
* components:
|
||||
<<<<<<< HEAD
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
* securitySchemes:
|
||||
* bearerAuth:
|
||||
* type: http
|
||||
* scheme: bearer
|
||||
* bearerFormat: JWT
|
||||
>>>>>>> origin/main
|
||||
=======
|
||||
>>>>>>> 83fad59878db015ec8d86bdec1ecbbca0baddfd2
|
||||
* schemas:
|
||||
* User:
|
||||
* type: object
|
||||
@@ -100,6 +111,7 @@
|
||||
* type: string
|
||||
* format: email
|
||||
*
|
||||
<<<<<<< HEAD
|
||||
* ForgotPasswordRequest:
|
||||
* type: object
|
||||
* required:
|
||||
@@ -131,6 +143,8 @@
|
||||
* message:
|
||||
* type: string
|
||||
*
|
||||
=======
|
||||
>>>>>>> origin/main
|
||||
* Organization:
|
||||
* type: object
|
||||
* properties:
|
||||
@@ -325,6 +339,10 @@
|
||||
* chatId:
|
||||
* type: string
|
||||
*
|
||||
<<<<<<< HEAD
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
>>>>>>> 83fad59878db015ec8d86bdec1ecbbca0baddfd2
|
||||
* Game:
|
||||
* type: object
|
||||
* properties:
|
||||
@@ -353,6 +371,11 @@
|
||||
* type: string
|
||||
* format: date-time
|
||||
*
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
>>>>>>> origin/main
|
||||
=======
|
||||
>>>>>>> 83fad59878db015ec8d86bdec1ecbbca0baddfd2
|
||||
* Error:
|
||||
* type: object
|
||||
* properties:
|
||||
@@ -363,6 +386,10 @@
|
||||
* format: date-time
|
||||
* details:
|
||||
* type: string
|
||||
<<<<<<< HEAD
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
>>>>>>> 83fad59878db015ec8d86bdec1ecbbca0baddfd2
|
||||
*/
|
||||
/**
|
||||
* @swagger
|
||||
@@ -381,6 +408,7 @@
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Login successful
|
||||
<<<<<<< HEAD
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
@@ -392,6 +420,47 @@
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*
|
||||
=======
|
||||
*
|
||||
* paths:
|
||||
* /api/users/login:
|
||||
* post:
|
||||
* tags: [Users]
|
||||
* summary: User login
|
||||
* description: Authenticate user and return JWT token
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/LoginRequest'
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Login successful
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/LoginResponse'
|
||||
* 401:
|
||||
* description: Invalid credentials
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
>>>>>>> origin/main
|
||||
=======
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/LoginResponse'
|
||||
* 401:
|
||||
* description: Invalid credentials
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*
|
||||
>>>>>>> 83fad59878db015ec8d86bdec1ecbbca0baddfd2
|
||||
*
|
||||
* /api/users/create:
|
||||
* post:
|
||||
@@ -1454,6 +1523,10 @@
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Contact'
|
||||
<<<<<<< HEAD
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
>>>>>>> 83fad59878db015ec8d86bdec1ecbbca0baddfd2
|
||||
*
|
||||
* /api/games/start:
|
||||
* post:
|
||||
@@ -1611,6 +1684,11 @@
|
||||
* description: Game already started or not ready to start
|
||||
* 500:
|
||||
* description: Internal server error
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
>>>>>>> origin/main
|
||||
=======
|
||||
>>>>>>> 83fad59878db015ec8d86bdec1ecbbca0baddfd2
|
||||
*/
|
||||
|
||||
export {};
|
||||
|
||||
@@ -15,6 +15,10 @@ export interface ShortDeckDto {
|
||||
type: number;
|
||||
playedNumber: number;
|
||||
ctype: number;
|
||||
cardCount: number;
|
||||
creator: string;
|
||||
creationdate: Date;
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
export interface DetailDeckDto {
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { DeckAggregate } from '../../../Domain/Deck/DeckAggregate';
|
||||
import { UserAggregate } from '../../../Domain/User/UserAggregate';
|
||||
import { CreateDeckDto, UpdateDeckDto, ShortDeckDto, DetailDeckDto } from '../DeckDto';
|
||||
|
||||
export class DeckMapper {
|
||||
static toShortDto(deck: DeckAggregate): ShortDeckDto {
|
||||
static toShortDto(deck: DeckAggregate, userId?: string): ShortDeckDto {
|
||||
return {
|
||||
id: deck.id,
|
||||
name: deck.name,
|
||||
type: deck.type,
|
||||
playedNumber: deck.playedNumber,
|
||||
ctype: deck.ctype,
|
||||
cardCount: deck.cards.length,
|
||||
creator: deck.user?.username || 'Unknown',
|
||||
creationdate: deck.creationdate,
|
||||
editable: deck.isEditable(userId!) ? deck.isEditable(userId!) : undefined
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,7 +30,17 @@ export class DeckMapper {
|
||||
};
|
||||
}
|
||||
|
||||
static toShortDtoList(decks: DeckAggregate[]): ShortDeckDto[] {
|
||||
return decks.map(this.toShortDto);
|
||||
static toShortDtoList(decks: DeckAggregate[], userId?: string): ShortDeckDto[] {
|
||||
return decks.map(deck => ({
|
||||
id: deck.id,
|
||||
name: deck.name,
|
||||
type: deck.type,
|
||||
playedNumber: deck.playedNumber,
|
||||
ctype: deck.ctype,
|
||||
cardCount: deck.cards.length,
|
||||
creator: deck.user?.username || 'Unknown',
|
||||
creationdate: deck.creationdate,
|
||||
editable: deck.isEditable(userId!) ? deck.isEditable(userId!) : undefined
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export class OrganizationMapper {
|
||||
contactemail: org.contactemail,
|
||||
state: org.state,
|
||||
regdate: org.regdate,
|
||||
updatedate: org.updatedate,
|
||||
updateDate: org.updateDate,
|
||||
url: org.url,
|
||||
userinorg: org.userinorg,
|
||||
maxOrganizationalDecks: org.maxOrganizationalDecks,
|
||||
|
||||
@@ -5,9 +5,7 @@ import { BaseMapper } from './BaseMapper';
|
||||
export class UserMapper {
|
||||
static toShortDto(user: UserAggregate): ShortUserDto {
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
state: user.state,
|
||||
authLevel: (user.state === UserState.ADMIN ? 1 : 0) as 0 | 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export interface DetailOrganizationDto {
|
||||
contactemail: string;
|
||||
state: number;
|
||||
regdate: Date;
|
||||
updatedate: Date;
|
||||
updateDate: Date;
|
||||
url: string | null;
|
||||
userinorg: number;
|
||||
maxOrganizationalDecks: number | null;
|
||||
|
||||
@@ -10,9 +10,7 @@ export interface UpdateUserDto {
|
||||
}
|
||||
|
||||
export interface ShortUserDto {
|
||||
id: string;
|
||||
username: string;
|
||||
state: number;
|
||||
authLevel: 0 | 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export interface DeleteDeckCommand {
|
||||
userid: string;
|
||||
authLevel: number;
|
||||
id: string;
|
||||
soft?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
import { IDeckRepository } from '../../../Domain/IRepository/IDeckRepository';
|
||||
import { logAuth, logError } from '../../Services/Logger';
|
||||
import { DeleteDeckCommand } from './DeleteDeckCommand';
|
||||
|
||||
export class DeleteDeckCommandHandler {
|
||||
constructor(private readonly deckRepo: IDeckRepository) {}
|
||||
|
||||
async execute(cmd: DeleteDeckCommand): Promise<boolean> {
|
||||
|
||||
//get decks userid
|
||||
const deck = await this.deckRepo.findById(cmd.id);
|
||||
if (!deck) {
|
||||
logError(`Deck not found with ID: ${cmd.id}`);
|
||||
throw new Error('Deck not found');
|
||||
}
|
||||
|
||||
if(cmd.authLevel !==1 && deck.userid !== cmd.userid) {
|
||||
logAuth(`Unauthorized access attempt to deck with ID: ${cmd.id}, UserID: ${cmd.userid}`);
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
if (cmd.soft) {
|
||||
await this.deckRepo.softDelete(cmd.id);
|
||||
} else {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
export interface UpdateDeckCommand {
|
||||
userid: string;
|
||||
authLevel: number;
|
||||
id: string;
|
||||
userstate?: number;
|
||||
name?: string;
|
||||
type?: number;
|
||||
userid?: string;
|
||||
cards?: any[];
|
||||
ctype?: number;
|
||||
state?: number;
|
||||
|
||||
@@ -3,18 +3,18 @@ import { UpdateDeckCommand } from './UpdateDeckCommand';
|
||||
import { ShortDeckDto } from '../../DTOs/DeckDto';
|
||||
import { DeckMapper } from '../../DTOs/Mappers/DeckMapper';
|
||||
import { DeckAggregate } from '../../../Domain/Deck/DeckAggregate';
|
||||
import { logError } from '../../Services/Logger';
|
||||
import { logAuth, logError } from '../../Services/Logger';
|
||||
|
||||
export class UpdateDeckCommandHandler {
|
||||
constructor(private readonly deckRepo: IDeckRepository) {}
|
||||
|
||||
async execute(cmd: UpdateDeckCommand): Promise<ShortDeckDto | null> {
|
||||
if(cmd.state !== undefined && cmd.userstate!==1) {
|
||||
if(cmd.state !== undefined && cmd.authLevel !== 1) {
|
||||
throw new Error('Only admin users can change deck state');
|
||||
}
|
||||
try {
|
||||
let existingDeck: DeckAggregate | null = null;
|
||||
if (cmd.userstate === 1) {
|
||||
if (cmd.authLevel === 1) {
|
||||
existingDeck = await this.deckRepo.findByIdIncludingDeleted(cmd.id);
|
||||
} else {
|
||||
existingDeck = await this.deckRepo.findById(cmd.id);
|
||||
@@ -24,6 +24,11 @@ export class UpdateDeckCommandHandler {
|
||||
throw new Error('Deck not found');
|
||||
}
|
||||
|
||||
if(cmd.authLevel !== 1 && existingDeck.userid !== cmd.userid) {
|
||||
logAuth(`Unauthorized access attempt to deck with ID: ${cmd.id}, UserID: ${cmd.userid}`);
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
const for_update: Partial<DeckAggregate> = {};
|
||||
if(cmd.name !== undefined) for_update.name = cmd.name;
|
||||
if(cmd.type !== undefined) for_update.type = cmd.type;
|
||||
|
||||
@@ -65,7 +65,7 @@ export class GetDecksByPageQueryHandler {
|
||||
});
|
||||
|
||||
return {
|
||||
decks: DeckMapper.toShortDtoList(result.decks),
|
||||
decks: DeckMapper.toShortDtoList(result.decks, query.userId),
|
||||
totalCount: result.totalCount
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -151,6 +151,15 @@ export class JoinGameCommandHandler {
|
||||
isOnline: true
|
||||
};
|
||||
|
||||
// Check if player name is already in use by a different player
|
||||
const existingPlayerWithName = gameData.currentPlayers.find(
|
||||
p => p.playerName === command.playerName && p.playerId !== command.playerId
|
||||
);
|
||||
|
||||
if (existingPlayerWithName) {
|
||||
throw new Error(`Player name "${command.playerName}" is already in use in this game`);
|
||||
}
|
||||
|
||||
// Update players list (remove if exists, then add)
|
||||
gameData.currentPlayers = gameData.currentPlayers.filter(p => p.playerId !== command.playerId);
|
||||
gameData.currentPlayers.push(newPlayer);
|
||||
@@ -161,9 +170,6 @@ export class JoinGameCommandHandler {
|
||||
// Store updated data in Redis with TTL (24 hours)
|
||||
await this.redisService.setWithExpiry(redisKey, JSON.stringify(gameData), 24 * 60 * 60);
|
||||
|
||||
// Add player to active players set
|
||||
await this.redisService.setAdd(`active_players:${game.id}`, command.playerId);
|
||||
|
||||
logOther('Game data updated in Redis', {
|
||||
gameId: game.id,
|
||||
gameCode: game.gamecode,
|
||||
@@ -204,7 +210,6 @@ export class JoinGameCommandHandler {
|
||||
gameData.currentPlayers = gameData.currentPlayers.filter(p => p.playerId !== playerId);
|
||||
|
||||
await this.redisService.setWithExpiry(redisKey, JSON.stringify(gameData), 24 * 60 * 60);
|
||||
await this.redisService.setRemove(`active_players:${gameId}`, playerId);
|
||||
}
|
||||
} catch (error) {
|
||||
logError('Failed to remove player from Redis', error instanceof Error ? error : new Error(String(error)));
|
||||
|
||||
@@ -64,7 +64,7 @@ export class StartGameCommandHandler {
|
||||
gamecode,
|
||||
maxplayers: command.maxplayers,
|
||||
logintype: command.logintype,
|
||||
createdby: command.userid || null,
|
||||
createdby: command.userid!,
|
||||
orgid: command.orgid || null,
|
||||
gamedecks,
|
||||
players: [],
|
||||
|
||||
@@ -6,7 +6,7 @@ import { logAuth, logWarning } from './Logger';
|
||||
export const jwtService = new JWTService();
|
||||
const redisService = RedisService.getInstance();
|
||||
|
||||
/**
|
||||
/**
|
||||
* Check if a token is blacklisted
|
||||
*/
|
||||
async function isTokenBlacklisted(token: string): Promise<boolean> {
|
||||
@@ -23,9 +23,9 @@ async function isTokenBlacklisted(token: string): Promise<boolean> {
|
||||
/**
|
||||
* Extract token from request (cookie or Authorization header)
|
||||
*/
|
||||
function extractToken(req: Request): string | null {
|
||||
function extractToken(req: Request, type: 'auth' | 'refresh'): string | null {
|
||||
// First try to get token from cookie
|
||||
const cookieToken = req.cookies['auth_token'];
|
||||
const cookieToken = req.cookies[`${type}_token`];
|
||||
if (cookieToken) {
|
||||
return cookieToken;
|
||||
}
|
||||
@@ -42,8 +42,9 @@ function extractToken(req: Request): string | null {
|
||||
export async function authRequired(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
// Extract token from request
|
||||
const token = extractToken(req);
|
||||
if (!token) {
|
||||
const token = extractToken(req, "auth");
|
||||
const refreshToken = extractToken(req, "refresh");
|
||||
if (!token || !refreshToken) {
|
||||
logAuth('Authentication failed - No token provided', undefined, {
|
||||
ip: req.ip,
|
||||
userAgent: req.get ? req.get('User-Agent') : 'unknown',
|
||||
@@ -79,7 +80,7 @@ export async function authRequired(req: Request, res: Response, next: NextFuncti
|
||||
orgId: payload.orgId
|
||||
}, req);
|
||||
|
||||
const refreshed = jwtService.refreshIfNeeded(payload, res);
|
||||
const refreshed = jwtService.refreshIfNeeded(payload, res, req);
|
||||
if (refreshed) {
|
||||
logAuth('Token refreshed', payload.userId, undefined, req);
|
||||
}
|
||||
@@ -95,8 +96,9 @@ export async function authRequired(req: Request, res: Response, next: NextFuncti
|
||||
export async function adminRequired(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
// Extract token from request
|
||||
const token = extractToken(req);
|
||||
if (!token) {
|
||||
const token = extractToken(req, "auth");
|
||||
const refreshToken = extractToken(req, "refresh");
|
||||
if (!token || !refreshToken) {
|
||||
logWarning('Admin access denied - No token provided', {
|
||||
ip: req.ip,
|
||||
path: req.path
|
||||
@@ -132,7 +134,7 @@ export async function adminRequired(req: Request, res: Response, next: NextFunct
|
||||
orgId: payload.orgId
|
||||
}, req);
|
||||
|
||||
const refreshed = jwtService.refreshIfNeeded(payload, res);
|
||||
const refreshed = jwtService.refreshIfNeeded(payload, res, req);
|
||||
if (refreshed) {
|
||||
logAuth('Admin token refreshed', payload.userId, undefined, req);
|
||||
}
|
||||
|
||||
@@ -13,13 +13,33 @@ export interface CloserAnswer {
|
||||
percent: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sentence pair for matching left to right
|
||||
*/
|
||||
export interface SentencePair {
|
||||
id: string; // Unique identifier for this pair
|
||||
left: string; // Left part to match
|
||||
right: string; // Right part (scrambled position)
|
||||
}
|
||||
|
||||
/**
|
||||
* Player's answer for sentence pairing (array of matches)
|
||||
*/
|
||||
export interface SentencePairingAnswer {
|
||||
pairId: string; // ID of the pair
|
||||
leftText: string; // Left part
|
||||
rightText: string; // Player's chosen right part
|
||||
}
|
||||
|
||||
export interface CardClientData {
|
||||
cardid: string;
|
||||
question: string;
|
||||
type: CardType;
|
||||
timeLimit: number;
|
||||
// Type-specific client data
|
||||
options?: QuizOption[]; // For QUIZ
|
||||
words?: string[]; // For SENTENCE_PAIRING (scrambled)
|
||||
answerOptions?: QuizOption[]; // For QUIZ
|
||||
words?: string[]; // For SENTENCE_PAIRING (legacy scrambled words)
|
||||
sentencePairs?: SentencePair[]; // For SENTENCE_PAIRING (left-right matching)
|
||||
acceptableAnswers?: string[]; // For OWN_ANSWER (not sent to client)
|
||||
// CLOSER and TRUE_FALSE send only question
|
||||
}
|
||||
@@ -50,7 +70,8 @@ export class CardProcessingService {
|
||||
const baseData: CardClientData = {
|
||||
cardid: card.cardid,
|
||||
question: card.question,
|
||||
type: card.type
|
||||
type: card.type,
|
||||
timeLimit: 60 // Default 60 seconds for question cards
|
||||
};
|
||||
|
||||
switch (card.type) {
|
||||
@@ -116,25 +137,60 @@ export class CardProcessingService {
|
||||
|
||||
return {
|
||||
...baseData,
|
||||
options: card.answer as QuizOption[]
|
||||
answerOptions: card.answer as QuizOption[]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare SENTENCE_PAIRING card with scrambled words
|
||||
* Prepare SENTENCE_PAIRING card with scrambled left/right pairs
|
||||
*
|
||||
* Expected card.answer format:
|
||||
* [
|
||||
* { left: "Apple", right: "Red" },
|
||||
* { left: "Banana", right: "Yellow" },
|
||||
* { left: "Orange", right: "Orange color" }
|
||||
* ]
|
||||
*
|
||||
* OR legacy string format: "word1 word2 word3" (will be split and scrambled)
|
||||
*/
|
||||
private prepareSentencePairingCard(card: GameCard, baseData: CardClientData): CardClientData {
|
||||
if (typeof card.answer !== 'string') {
|
||||
throw new Error('Sentence pairing card answer must be a string');
|
||||
// NEW FORMAT: Array of pairs (left-right matching)
|
||||
if (Array.isArray(card.answer)) {
|
||||
// Validate structure
|
||||
const pairs = card.answer as Array<{ left: string; right: string }>;
|
||||
if (!pairs.every(p => p.left && p.right)) {
|
||||
throw new Error('Sentence pairing card answer must be array of {left, right} objects');
|
||||
}
|
||||
|
||||
// Create pairs with IDs and scramble the right parts
|
||||
const leftParts = pairs.map((p, idx) => ({ id: `pair_${idx}`, left: p.left, right: p.right }));
|
||||
const rightParts = this.scrambleArray([...pairs.map(p => p.right)]);
|
||||
|
||||
// Send left parts in order, right parts scrambled
|
||||
const sentencePairs: SentencePair[] = leftParts.map((lp, idx) => ({
|
||||
id: lp.id,
|
||||
left: lp.left,
|
||||
right: rightParts[idx] // Scrambled position
|
||||
}));
|
||||
|
||||
return {
|
||||
...baseData,
|
||||
sentencePairs
|
||||
};
|
||||
}
|
||||
|
||||
const words = card.answer.split(' ').filter(word => word.trim() !== '');
|
||||
const scrambledWords = this.scrambleArray([...words]);
|
||||
// LEGACY FORMAT: Single sentence to reconstruct (backward compatibility)
|
||||
if (typeof card.answer === 'string') {
|
||||
const words = card.answer.split(' ').filter(word => word.trim() !== '');
|
||||
const scrambledWords = this.scrambleArray([...words]);
|
||||
|
||||
return {
|
||||
...baseData,
|
||||
words: scrambledWords
|
||||
};
|
||||
return {
|
||||
...baseData,
|
||||
words: scrambledWords
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('Sentence pairing card answer must be array of pairs or string');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -187,29 +243,80 @@ export class CardProcessingService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate SENTENCE_PAIRING answer (reconstructed sentence)
|
||||
* Validate SENTENCE_PAIRING answer
|
||||
*
|
||||
* Supports two formats:
|
||||
* 1. NEW: Array of { pairId, leftText, rightText } matches
|
||||
* 2. LEGACY: Reconstructed sentence string or array of words
|
||||
*/
|
||||
private validateSentencePairingAnswer(card: GameCard, playerAnswer: string[] | string): CardValidationResult {
|
||||
if (typeof card.answer !== 'string') {
|
||||
throw new Error('Sentence pairing card answer must be a string');
|
||||
private validateSentencePairingAnswer(card: GameCard, playerAnswer: any): CardValidationResult {
|
||||
// NEW FORMAT: Array of pairs (left-right matching)
|
||||
if (Array.isArray(card.answer) && card.answer.every((p: any) => p.left && p.right)) {
|
||||
const correctPairs = card.answer as Array<{ left: string; right: string }>;
|
||||
|
||||
// Player answer should be array of SentencePairingAnswer objects
|
||||
if (!Array.isArray(playerAnswer)) {
|
||||
throw new Error('Player answer must be array of pair matches');
|
||||
}
|
||||
|
||||
const playerMatches = playerAnswer as SentencePairingAnswer[];
|
||||
|
||||
// Check if all pairs match correctly
|
||||
let correctCount = 0;
|
||||
const results: string[] = [];
|
||||
|
||||
for (const correctPair of correctPairs) {
|
||||
const playerMatch = playerMatches.find(pm =>
|
||||
pm.leftText.toLowerCase().trim() === correctPair.left.toLowerCase().trim()
|
||||
);
|
||||
|
||||
if (playerMatch) {
|
||||
const isMatch = playerMatch.rightText.toLowerCase().trim() ===
|
||||
correctPair.right.toLowerCase().trim();
|
||||
if (isMatch) {
|
||||
correctCount++;
|
||||
results.push(`✓ "${correctPair.left}" → "${correctPair.right}"`);
|
||||
} else {
|
||||
results.push(`✗ "${correctPair.left}" → "${playerMatch.rightText}" (should be "${correctPair.right}")`);
|
||||
}
|
||||
} else {
|
||||
results.push(`✗ "${correctPair.left}" → (not matched)`);
|
||||
}
|
||||
}
|
||||
|
||||
const isCorrect = correctCount === correctPairs.length;
|
||||
|
||||
return {
|
||||
isCorrect,
|
||||
submittedAnswer: playerMatches,
|
||||
correctAnswer: correctPairs,
|
||||
explanation: isCorrect
|
||||
? `✅ Perfect! All ${correctCount} pairs matched correctly!\n${results.join('\n')}`
|
||||
: `❌ Only ${correctCount}/${correctPairs.length} pairs correct:\n${results.join('\n')}`
|
||||
};
|
||||
}
|
||||
|
||||
// Handle both array of words and joined string
|
||||
const reconstructed = Array.isArray(playerAnswer)
|
||||
? playerAnswer.join(' ').toLowerCase().trim()
|
||||
: playerAnswer.toLowerCase().trim();
|
||||
// LEGACY FORMAT: Single sentence to reconstruct (backward compatibility)
|
||||
if (typeof card.answer === 'string') {
|
||||
// Handle both array of words and joined string
|
||||
const reconstructed = Array.isArray(playerAnswer)
|
||||
? playerAnswer.join(' ').toLowerCase().trim()
|
||||
: (typeof playerAnswer === 'string' ? playerAnswer.toLowerCase().trim() : '');
|
||||
|
||||
const correctSentence = card.answer.toLowerCase().trim();
|
||||
const isCorrect = reconstructed === correctSentence;
|
||||
const correctSentence = card.answer.toLowerCase().trim();
|
||||
const isCorrect = reconstructed === correctSentence;
|
||||
|
||||
return {
|
||||
isCorrect,
|
||||
submittedAnswer: reconstructed,
|
||||
correctAnswer: card.answer,
|
||||
explanation: isCorrect
|
||||
? '✅ Perfect! You arranged the sentence correctly!'
|
||||
: `❌ Wrong order! Correct sentence: "${card.answer}"`
|
||||
};
|
||||
return {
|
||||
isCorrect,
|
||||
submittedAnswer: reconstructed,
|
||||
correctAnswer: card.answer,
|
||||
explanation: isCorrect
|
||||
? '✅ Perfect! You arranged the sentence correctly!'
|
||||
: `❌ Wrong order! Correct sentence: "${card.answer}"`
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('Sentence pairing card answer must be array of pairs or string');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -39,6 +39,7 @@ import { ProcessOrgAuthCallbackCommandHandler } from '../Organization/commands/P
|
||||
import { CreateContactCommandHandler } from '../Contact/commands/CreateContactCommandHandler';
|
||||
import { UpdateContactCommandHandler } from '../Contact/commands/UpdateContactCommandHandler';
|
||||
import { DeleteContactCommandHandler } from '../Contact/commands/DeleteContactCommandHandler';
|
||||
import { ActivateUserCommandHandler } from '../User/commands/ActivateUserCommandHandler';
|
||||
|
||||
// Query Handlers
|
||||
import { GetUserByIdQueryHandler } from '../User/queries/GetUserByIdQueryHandler';
|
||||
@@ -121,6 +122,7 @@ export class DIContainer {
|
||||
private _updateContactCommandHandler: UpdateContactCommandHandler | null = null;
|
||||
private _deleteContactCommandHandler: DeleteContactCommandHandler | null = null;
|
||||
private _generateBoardCommandHandler: GenerateBoardCommandHandler | null = null;
|
||||
private _activateUserCommandHandler: ActivateUserCommandHandler | null = null;
|
||||
|
||||
// Query Handlers
|
||||
private _getUserByIdQueryHandler: GetUserByIdQueryHandler | null = null;
|
||||
@@ -306,6 +308,13 @@ export class DIContainer {
|
||||
return this._deactivateUserCommandHandler;
|
||||
}
|
||||
|
||||
public get activateUserCommandHandler(): ActivateUserCommandHandler {
|
||||
if (!this._activateUserCommandHandler) {
|
||||
this._activateUserCommandHandler = new ActivateUserCommandHandler(this.userRepository);
|
||||
}
|
||||
return this._activateUserCommandHandler;
|
||||
}
|
||||
|
||||
public get deleteUserCommandHandler(): DeleteUserCommandHandler {
|
||||
if (!this._deleteUserCommandHandler) {
|
||||
this._deleteUserCommandHandler = new DeleteUserCommandHandler(this.userRepository);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import * as nodemailer from 'nodemailer';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import sharp from 'sharp';
|
||||
import { logError, logAuth, logStartup } from './Logger';
|
||||
import { EmailTemplateHelper, LocalizedSubjects } from './EmailTemplateHelper';
|
||||
|
||||
|
||||
export interface EmailOptions {
|
||||
to: string;
|
||||
subject: string;
|
||||
@@ -28,9 +30,14 @@ export class EmailService {
|
||||
private transporter!: nodemailer.Transporter;
|
||||
private config: EmailConfig;
|
||||
private templatesPath: string;
|
||||
private logoPath: string;
|
||||
private resizedLogoBuffer?: Buffer;
|
||||
|
||||
constructor() {
|
||||
this.templatesPath = path.join(__dirname, '../../Templates');
|
||||
this.logoPath = path.join(__dirname, '../../../assets/Logo.png');
|
||||
// Load logo asynchronously after initialization
|
||||
this.loadLogo().catch(err => logError('Error loading logo:', err));
|
||||
|
||||
this.config = {
|
||||
host: process.env.EMAIL_HOST || 'smtp.gmail.com',
|
||||
@@ -63,29 +70,70 @@ export class EmailService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and resize logo for email attachments - 60x60 pixels
|
||||
*/
|
||||
private async loadLogo(): Promise<void> {
|
||||
try {
|
||||
if (fs.existsSync(this.logoPath)) {
|
||||
const logoBuffer = fs.readFileSync(this.logoPath);
|
||||
|
||||
// Resize to 60x60 pixels with high quality and centered
|
||||
this.resizedLogoBuffer = await sharp(logoBuffer)
|
||||
.resize(60, 60, {
|
||||
fit: 'contain',
|
||||
background: { r: 255, g: 255, b: 255, alpha: 1 },
|
||||
position: 'center'
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
} catch (error) {
|
||||
logError('Failed to load logo for emails', error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send email with template
|
||||
* @param options - Email options including template and data
|
||||
*/
|
||||
async sendEmail(options: EmailOptions): Promise<boolean> {
|
||||
try {
|
||||
// Ensure logo is loaded before sending
|
||||
if (!this.resizedLogoBuffer) {
|
||||
await this.loadLogo();
|
||||
}
|
||||
|
||||
let htmlContent = options.html;
|
||||
let textContent = options.text;
|
||||
|
||||
if (options.template) {
|
||||
const templateResult = await this.loadTemplate(options.template, options.templateData || {});
|
||||
const templateResult = await this.loadTemplate(options.template, options.templateData);
|
||||
htmlContent = templateResult.html;
|
||||
textContent = templateResult.text;
|
||||
}
|
||||
|
||||
const mailOptions = {
|
||||
const mailOptions: any = {
|
||||
from: this.config.from,
|
||||
to: options.to,
|
||||
subject: options.subject,
|
||||
html: htmlContent,
|
||||
text: textContent
|
||||
text: textContent,
|
||||
attachments: []
|
||||
};
|
||||
|
||||
// Add logo as CID attachment if available
|
||||
if (this.resizedLogoBuffer) {
|
||||
mailOptions.attachments.push({
|
||||
filename: 'logo.png',
|
||||
content: this.resizedLogoBuffer,
|
||||
cid: 'logo@serpentrace' // Content-ID for referencing in HTML
|
||||
});
|
||||
console.log('[EmailService] 📎 Logo attached to email as CID: logo@serpentrace');
|
||||
} else {
|
||||
console.warn('[EmailService] ⚠️ Logo buffer not available, email will be sent without logo');
|
||||
}
|
||||
|
||||
const result = await this.transporter.sendMail(mailOptions);
|
||||
logAuth('Email sent successfully', undefined, {
|
||||
messageId: result.messageId,
|
||||
|
||||
@@ -25,7 +25,7 @@ export class EmailTemplateHelper {
|
||||
}
|
||||
|
||||
public static replaceTemplatePlaceholders(template: string, data: TemplateData): string {
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (match, key) => {
|
||||
return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, key) => {
|
||||
return data[key] !== undefined ? String(data[key]) : match;
|
||||
});
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,60 +7,204 @@ export interface TokenPayload {
|
||||
authLevel: 0 | 1;
|
||||
userStatus: UserState;
|
||||
orgId: string;
|
||||
type?: 'access';
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
export interface RefreshTokenPayload {
|
||||
userId: string;
|
||||
type: 'refresh';
|
||||
orgId?: string;
|
||||
tokenId?: string; // For token rotation/revocation
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
export interface TokenPair {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export class JWTService {
|
||||
private readonly secretKey: string;
|
||||
private readonly refreshSecretKey: string;
|
||||
private readonly tokenExpiry: number;
|
||||
private readonly refreshTokenExpiry: number;
|
||||
private readonly cookieName: string;
|
||||
private readonly refreshCookieName: string;
|
||||
|
||||
constructor() {
|
||||
this.secretKey = process.env.JWT_SECRET || 'your-secret-key';
|
||||
this.refreshSecretKey = process.env.JWT_REFRESH_SECRET || this.secretKey + '_refresh';
|
||||
|
||||
let expiry = 86400;
|
||||
|
||||
// Access token expiry (short-lived)
|
||||
let expiry = 1800; // Default 30 minutes for better security
|
||||
if (process.env.JWT_EXPIRY) {
|
||||
expiry = parseInt(process.env.JWT_EXPIRY);
|
||||
} else if (process.env.JWT_EXPIRATION) {
|
||||
expiry = this.parseDuration(process.env.JWT_EXPIRATION);
|
||||
}
|
||||
|
||||
// Refresh token expiry (long-lived)
|
||||
let refreshExpiry = 604800; // Default 7 days
|
||||
if (process.env.JWT_REFRESH_EXPIRATION) {
|
||||
refreshExpiry = this.parseDuration(process.env.JWT_REFRESH_EXPIRATION);
|
||||
}
|
||||
|
||||
this.tokenExpiry = expiry;
|
||||
this.refreshTokenExpiry = refreshExpiry;
|
||||
this.cookieName = 'auth_token';
|
||||
this.refreshCookieName = 'refresh_token';
|
||||
|
||||
if (process.env.NODE_ENV === 'production' && (!process.env.JWT_SECRET || process.env.JWT_SECRET === 'your-secret-key')) {
|
||||
throw new Error('JWT_SECRET environment variable must be set in production');
|
||||
}
|
||||
}
|
||||
|
||||
create(payload: TokenPayload, res: Response): string {
|
||||
/**
|
||||
* Create a pair of access and refresh tokens
|
||||
*/
|
||||
public createTokenPair(payload: Omit<TokenPayload, 'type' | 'iat' | 'exp'>): TokenPair {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const payloadWithTimestamps: TokenPayload = {
|
||||
// Create access token
|
||||
const accessTokenPayload: TokenPayload = {
|
||||
...payload,
|
||||
type: 'access',
|
||||
iat: now,
|
||||
exp: now + this.tokenExpiry
|
||||
};
|
||||
const accessToken = jwt.sign(accessTokenPayload, this.secretKey);
|
||||
|
||||
// Don't use expiresIn option since we're manually setting exp in payload
|
||||
const options: SignOptions = {};
|
||||
const token = jwt.sign(payloadWithTimestamps, this.secretKey, options);
|
||||
// Create refresh token
|
||||
const refreshTokenPayload: RefreshTokenPayload = {
|
||||
userId: payload.userId,
|
||||
type: 'refresh',
|
||||
orgId: payload.orgId,
|
||||
iat: now,
|
||||
exp: now + this.refreshTokenExpiry
|
||||
};
|
||||
const refreshToken = jwt.sign(refreshTokenPayload, this.refreshSecretKey);
|
||||
|
||||
res.cookie(this.cookieName, token, {
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create access and refresh tokens and set cookies (for cookie-based auth)
|
||||
*/
|
||||
create(payload: Omit<TokenPayload, 'type' | 'iat' | 'exp'>, res: Response): TokenPair {
|
||||
const tokenPair = this.createTokenPair(payload);
|
||||
this.setTokenCookies(res, tokenPair);
|
||||
return tokenPair;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the request is using Bearer token authentication
|
||||
*/
|
||||
private isUsingBearerAuth(req: Request): boolean {
|
||||
// No cookie but has Authorization header
|
||||
return !req.cookies?.[this.cookieName] &&
|
||||
!!req.headers.authorization &&
|
||||
req.headers.authorization.startsWith('Bearer ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a refresh token
|
||||
*/
|
||||
public verifyRefreshToken(token: string): RefreshTokenPayload | null {
|
||||
try {
|
||||
const decoded = jwt.verify(token, this.refreshSecretKey) as RefreshTokenPayload;
|
||||
if (decoded.type !== 'refresh') {
|
||||
return null;
|
||||
}
|
||||
return decoded;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to refresh tokens using refresh token from cookies or headers
|
||||
*/
|
||||
public attemptTokenRefresh(req: Request, res: Response): TokenPair | null {
|
||||
try {
|
||||
// Try to get refresh token from cookie first
|
||||
let refreshToken = req.cookies[this.refreshCookieName];
|
||||
|
||||
// If no cookie, try X-Refresh-Token header
|
||||
if (!refreshToken) {
|
||||
refreshToken = req.headers['x-refresh-token'] as string;
|
||||
}
|
||||
|
||||
if (!refreshToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const refreshPayload = this.verifyRefreshToken(refreshToken);
|
||||
if (!refreshPayload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create new token pair
|
||||
const newTokenPair = this.createTokenPair({
|
||||
userId: refreshPayload.userId,
|
||||
authLevel: 0, // Default auth level, should be fetched from user data
|
||||
userStatus: UserState.VERIFIED_REGULAR, // Default status, should be fetched from user data
|
||||
orgId: refreshPayload.orgId || ''
|
||||
});
|
||||
|
||||
// Set new tokens based on authentication method
|
||||
if (req.cookies[this.cookieName] || req.cookies[this.refreshCookieName]) {
|
||||
// Cookie-based auth: set new cookies
|
||||
this.setTokenCookies(res, newTokenPair);
|
||||
} else {
|
||||
// Header-based auth: send tokens in response headers
|
||||
res.setHeader('X-New-Access-Token', newTokenPair.accessToken);
|
||||
res.setHeader('X-New-Refresh-Token', newTokenPair.refreshToken);
|
||||
res.setHeader('X-Token-Refreshed', 'true');
|
||||
}
|
||||
|
||||
return newTokenPair;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set token cookies for cookie-based authentication
|
||||
*/
|
||||
private setTokenCookies(res: Response, tokenPair: TokenPair): void {
|
||||
// Set access token cookie
|
||||
res.cookie(this.cookieName, tokenPair.accessToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
maxAge: this.tokenExpiry * 1000, // Convert to milliseconds
|
||||
maxAge: this.tokenExpiry * 1000,
|
||||
});
|
||||
|
||||
return token;
|
||||
// Set refresh token cookie
|
||||
res.cookie(this.refreshCookieName, tokenPair.refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
maxAge: this.refreshTokenExpiry * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
verify(req: Request): TokenPayload | null {
|
||||
try {
|
||||
const token = req.cookies[this.cookieName];
|
||||
// First try to get token from cookie
|
||||
let token = req.cookies[this.cookieName];
|
||||
|
||||
// If no cookie token, try Authorization header
|
||||
if (!token) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
token = authHeader.substring(7);
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) return null;
|
||||
|
||||
const decoded = jwt.verify(token, this.secretKey) as TokenPayload;
|
||||
@@ -70,6 +214,32 @@ export class JWTService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout user by clearing tokens
|
||||
*/
|
||||
public logout(req: Request, res: Response): void {
|
||||
// Clear cookies if they exist
|
||||
if (req.cookies[this.cookieName]) {
|
||||
res.clearCookie(this.cookieName, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict'
|
||||
});
|
||||
}
|
||||
|
||||
if (req.cookies[this.refreshCookieName]) {
|
||||
res.clearCookie(this.refreshCookieName, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict'
|
||||
});
|
||||
}
|
||||
|
||||
// For bearer token auth, set headers to indicate logout
|
||||
res.setHeader('X-Auth-Logout', 'true');
|
||||
res.setHeader('X-Clear-Tokens', 'true');
|
||||
}
|
||||
|
||||
// Check if token needs refresh (within 25% of expiry time)
|
||||
shouldRefreshToken(payload: TokenPayload): boolean {
|
||||
if (!payload.exp || !payload.iat) return false;
|
||||
@@ -83,16 +253,37 @@ export class JWTService {
|
||||
}
|
||||
|
||||
// Conditionally refresh token only if needed
|
||||
refreshIfNeeded(payload: TokenPayload, res: Response): boolean {
|
||||
refreshIfNeeded(payload: TokenPayload, res: Response, req?: Request): boolean {
|
||||
if (this.shouldRefreshToken(payload)) {
|
||||
// Create new token with fresh timestamps, but same user data
|
||||
const freshPayload: Omit<TokenPayload, 'iat' | 'exp'> = {
|
||||
if (req) {
|
||||
// Try to use the new refresh token system
|
||||
const newTokenPair = this.attemptTokenRefresh(req, res);
|
||||
if (newTokenPair) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: create new token pair
|
||||
const freshPayload: Omit<TokenPayload, 'iat' | 'exp' | 'type'> = {
|
||||
userId: payload.userId,
|
||||
authLevel: payload.authLevel,
|
||||
userStatus: payload.userStatus,
|
||||
orgId: payload.orgId
|
||||
};
|
||||
this.create(freshPayload, res);
|
||||
|
||||
// Check if using Bearer authentication
|
||||
if (req && this.isUsingBearerAuth(req)) {
|
||||
// For Bearer auth, create token pair and add to headers
|
||||
const newTokenPair = this.createTokenPair(freshPayload);
|
||||
res.setHeader('X-New-Access-Token', newTokenPair.accessToken);
|
||||
res.setHeader('X-New-Refresh-Token', newTokenPair.refreshToken);
|
||||
res.setHeader('X-Token-Refreshed', 'true');
|
||||
} else {
|
||||
// For cookie auth, create token pair and set cookies
|
||||
const newTokenPair = this.create(freshPayload, res);
|
||||
this.setTokenCookies(res, newTokenPair);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -120,7 +120,7 @@ export class TokenService {
|
||||
try {
|
||||
// Remove trailing slash from baseUrl if present
|
||||
const cleanBaseUrl = baseUrl.replace(/\/$/, '');
|
||||
return `${cleanBaseUrl}/api/auth/verify-email?token=${encodeURIComponent(token)}`;
|
||||
return `${cleanBaseUrl}/verify-email?token=${encodeURIComponent(token)}`;
|
||||
} catch (error) {
|
||||
logError('TokenService.generateVerificationUrl error', error instanceof Error ? error : new Error(String(error)));
|
||||
throw new Error('Failed to generate verification URL');
|
||||
@@ -137,7 +137,7 @@ export class TokenService {
|
||||
try {
|
||||
// Remove trailing slash from baseUrl if present
|
||||
const cleanBaseUrl = baseUrl.replace(/\/$/, '');
|
||||
return `${cleanBaseUrl}/api/auth/reset-password?token=${encodeURIComponent(token)}`;
|
||||
return `${cleanBaseUrl}/reset-password?token=${encodeURIComponent(token)}`;
|
||||
} catch (error) {
|
||||
logError('TokenService.generatePasswordResetUrl error', error instanceof Error ? error : new Error(String(error)));
|
||||
throw new Error('Failed to generate password reset URL');
|
||||
|
||||
@@ -54,6 +54,31 @@ interface DeleteMessageData {
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
// Game-related WebSocket interfaces (prepared for future implementation)
|
||||
interface JoinGameRoomData {
|
||||
gameCode: string;
|
||||
}
|
||||
|
||||
interface LeaveGameRoomData {
|
||||
gameCode: string;
|
||||
}
|
||||
|
||||
interface GameStateUpdateData {
|
||||
gameId: string;
|
||||
gameCode: string;
|
||||
players: string[];
|
||||
state: string;
|
||||
currentTurn?: string;
|
||||
}
|
||||
|
||||
interface GameActionData {
|
||||
gameId: string;
|
||||
gameCode: string;
|
||||
playerId: string;
|
||||
action: 'pick_card' | 'play_card' | 'end_turn' | 'leave_game';
|
||||
data?: any;
|
||||
}
|
||||
|
||||
export class WebSocketService {
|
||||
private io: SocketIOServer;
|
||||
private jwtService: JWTService;
|
||||
@@ -1173,4 +1198,211 @@ export class WebSocketService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Game-related WebSocket handlers (prepared for future implementation)
|
||||
|
||||
/**
|
||||
* Handle player joining a game room for real-time updates
|
||||
* @param socket The authenticated socket
|
||||
* @param data Game room data containing game code
|
||||
*/
|
||||
private async handleJoinGameRoom(socket: AuthenticatedSocket, data: JoinGameRoomData) {
|
||||
try {
|
||||
const userId = socket.userId!;
|
||||
const gameRoom = `game_${data.gameCode}`;
|
||||
|
||||
logAuth('Player joining game room', userId, {
|
||||
gameCode: data.gameCode,
|
||||
gameRoom,
|
||||
socketId: socket.id
|
||||
});
|
||||
|
||||
// Join the WebSocket room for this game
|
||||
await socket.join(gameRoom);
|
||||
|
||||
// Emit confirmation to the player
|
||||
socket.emit('game:joined', {
|
||||
gameCode: data.gameCode,
|
||||
room: gameRoom,
|
||||
message: 'Successfully joined game room'
|
||||
});
|
||||
|
||||
// Notify other players in the game room
|
||||
socket.to(gameRoom).emit('game:player_joined', {
|
||||
playerId: userId,
|
||||
gameCode: data.gameCode,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
logAuth('Player joined game room successfully', userId, {
|
||||
gameCode: data.gameCode,
|
||||
gameRoom
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logError('Error joining game room', error as Error);
|
||||
socket.emit('game:error', {
|
||||
message: 'Failed to join game room',
|
||||
gameCode: data.gameCode
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle player leaving a game room
|
||||
* @param socket The authenticated socket
|
||||
* @param data Game room data containing game code
|
||||
*/
|
||||
private async handleLeaveGameRoom(socket: AuthenticatedSocket, data: LeaveGameRoomData) {
|
||||
try {
|
||||
const userId = socket.userId!;
|
||||
const gameRoom = `game_${data.gameCode}`;
|
||||
|
||||
logAuth('Player leaving game room', userId, {
|
||||
gameCode: data.gameCode,
|
||||
gameRoom,
|
||||
socketId: socket.id
|
||||
});
|
||||
|
||||
// Leave the WebSocket room
|
||||
await socket.leave(gameRoom);
|
||||
|
||||
// Notify other players in the game room
|
||||
socket.to(gameRoom).emit('game:player_left', {
|
||||
playerId: userId,
|
||||
gameCode: data.gameCode,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Confirm to the leaving player
|
||||
socket.emit('game:left', {
|
||||
gameCode: data.gameCode,
|
||||
message: 'Successfully left game room'
|
||||
});
|
||||
|
||||
logAuth('Player left game room successfully', userId, {
|
||||
gameCode: data.gameCode,
|
||||
gameRoom
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logError('Error leaving game room', error as Error);
|
||||
socket.emit('game:error', {
|
||||
message: 'Failed to leave game room',
|
||||
gameCode: data.gameCode
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle game actions (cards, turns, etc.) - prepared for future implementation
|
||||
* @param socket The authenticated socket
|
||||
* @param data Game action data
|
||||
*/
|
||||
private async handleGameAction(socket: AuthenticatedSocket, data: GameActionData) {
|
||||
try {
|
||||
const userId = socket.userId!;
|
||||
const gameRoom = `game_${data.gameCode}`;
|
||||
|
||||
logAuth('Game action received', userId, {
|
||||
gameId: data.gameId,
|
||||
gameCode: data.gameCode,
|
||||
action: data.action,
|
||||
socketId: socket.id
|
||||
});
|
||||
|
||||
// Validate that the player is authorized to perform this action
|
||||
if (data.playerId !== userId) {
|
||||
socket.emit('game:error', {
|
||||
message: 'Unauthorized action',
|
||||
gameCode: data.gameCode
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Implement specific game logic here
|
||||
// This will be implemented when the game flow is discussed
|
||||
|
||||
// For now, just broadcast the action to other players
|
||||
socket.to(gameRoom).emit('game:action_performed', {
|
||||
playerId: userId,
|
||||
gameCode: data.gameCode,
|
||||
action: data.action,
|
||||
data: data.data,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Confirm action to the acting player
|
||||
socket.emit('game:action_confirmed', {
|
||||
gameCode: data.gameCode,
|
||||
action: data.action,
|
||||
message: 'Action processed successfully'
|
||||
});
|
||||
|
||||
logAuth('Game action processed', userId, {
|
||||
gameId: data.gameId,
|
||||
gameCode: data.gameCode,
|
||||
action: data.action
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logError('Error processing game action', error as Error);
|
||||
socket.emit('game:error', {
|
||||
message: 'Failed to process game action',
|
||||
gameCode: data.gameCode,
|
||||
action: data.action
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast game state updates to all players in a game
|
||||
* @param gameCode The game code
|
||||
* @param gameState The updated game state
|
||||
*/
|
||||
public broadcastGameStateUpdate(gameCode: string, gameState: GameStateUpdateData): void {
|
||||
try {
|
||||
const gameRoom = `game_${gameCode}`;
|
||||
|
||||
this.io.to(gameRoom).emit('game:state_updated', {
|
||||
...gameState,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
logRequest('Game state broadcasted', undefined, undefined, {
|
||||
gameCode,
|
||||
gameRoom,
|
||||
playerCount: gameState.players.length
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logError('Error broadcasting game state', error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify players when a game starts
|
||||
* @param gameCode The game code
|
||||
* @param players Array of player IDs
|
||||
*/
|
||||
public notifyGameStart(gameCode: string, players: string[]): void {
|
||||
try {
|
||||
const gameRoom = `game_${gameCode}`;
|
||||
|
||||
this.io.to(gameRoom).emit('game:started', {
|
||||
gameCode,
|
||||
players,
|
||||
message: 'Game has started!',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
logRequest('Game start notification sent', undefined, undefined, {
|
||||
gameCode,
|
||||
playerCount: players.length
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logError('Error notifying game start', error as Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface ActivateUserCommand {
|
||||
id: string;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { IUserRepository } from '../../../Domain/IRepository/IUserRepository';
|
||||
import { ActivateUserCommand } from './ActivateUserCommand';
|
||||
|
||||
|
||||
export class ActivateUserCommandHandler {
|
||||
constructor(private readonly userRepo: IUserRepository) {}
|
||||
|
||||
async execute(cmd: ActivateUserCommand): Promise<boolean> {
|
||||
await this.userRepo.activate(cmd.id);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,6 @@ export interface CreateUserCommand {
|
||||
lname: string;
|
||||
code?: string;
|
||||
orgid?: string;
|
||||
type: string;
|
||||
phone?: string;
|
||||
language: 'hu' | 'de' | 'en';
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ export class CreateUserCommandHandler {
|
||||
const created = await this.userRepo.create(user);
|
||||
|
||||
// Send verification email (non-blocking)
|
||||
this.sendVerificationEmailAsync(created, verificationTokenData.token);
|
||||
this.sendVerificationEmailAsync(cmd.language, created, verificationTokenData.token);
|
||||
|
||||
return UserMapper.toShortDto(created);
|
||||
} catch (error) {
|
||||
@@ -67,16 +67,17 @@ export class CreateUserCommandHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private async sendVerificationEmailAsync(user: UserAggregate, token: string): Promise<void> {
|
||||
private async sendVerificationEmailAsync(language: 'hu' | 'de' | 'en', user: UserAggregate, token: string): Promise<void> {
|
||||
try {
|
||||
const baseUrl = process.env.APP_BASE_URL || 'http://localhost:3000';
|
||||
const baseUrl = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||
const verificationUrl = TokenService.generateVerificationUrl(baseUrl, token);
|
||||
|
||||
|
||||
const emailSent = await this.emailService.sendVerificationEmail(
|
||||
user.email,
|
||||
`${user.fname} ${user.lname}`,
|
||||
token,
|
||||
verificationUrl
|
||||
verificationUrl,
|
||||
language
|
||||
);
|
||||
|
||||
if (!emailSent) {
|
||||
|
||||
@@ -11,7 +11,8 @@ import { Response } from 'express';
|
||||
|
||||
export interface LoginResponse {
|
||||
user: ShortUserDto;
|
||||
token: string;
|
||||
token?: string;
|
||||
refreshToken?: string;
|
||||
requiresOrgReauth?: boolean;
|
||||
orgLoginUrl?: string;
|
||||
organizationName?: string;
|
||||
@@ -111,7 +112,25 @@ export class LoginCommandHandler {
|
||||
try {
|
||||
// Use the real response object if provided, otherwise use mock
|
||||
const responseObj = res || mockRes;
|
||||
const token = this.jwtService.create(tokenPayload, responseObj);
|
||||
|
||||
// Check if client prefers Bearer token authentication
|
||||
const isWebClient = res?.req?.headers['origin'] || res?.req?.headers['referer'];
|
||||
const explicitBearerRequest = res?.req?.headers['x-auth-method'] === 'bearer';
|
||||
|
||||
const prefersBearerAuth = res && !isWebClient && (
|
||||
res.req?.headers['authorization'] !== undefined ||
|
||||
explicitBearerRequest
|
||||
);
|
||||
|
||||
let tokenPair: any;
|
||||
|
||||
if (prefersBearerAuth && res) {
|
||||
// Create token pair for Bearer authentication (no cookies)
|
||||
tokenPair = this.jwtService.createTokenPair(tokenPayload);
|
||||
} else {
|
||||
// Cookie-based authentication (sets cookies automatically)
|
||||
tokenPair = this.jwtService.create(tokenPayload, responseObj);
|
||||
}
|
||||
|
||||
// Check if user belongs to an organization and needs reauthentication
|
||||
let requiresOrgReauth = false;
|
||||
@@ -151,11 +170,19 @@ export class LoginCommandHandler {
|
||||
organizationName,
|
||||
totalLoginTime: Date.now() - startTime
|
||||
});
|
||||
|
||||
const response: LoginResponse = {
|
||||
user: UserMapper.toShortDto(user),
|
||||
token
|
||||
};
|
||||
let response: LoginResponse;
|
||||
if (prefersBearerAuth){
|
||||
response = {
|
||||
user: UserMapper.toShortDto(user),
|
||||
token: tokenPair.accessToken,
|
||||
refreshToken: tokenPair.refreshToken
|
||||
};
|
||||
}
|
||||
else {
|
||||
response = {
|
||||
user: UserMapper.toShortDto(user)
|
||||
};
|
||||
}
|
||||
|
||||
if (requiresOrgReauth) {
|
||||
response.requiresOrgReauth = true;
|
||||
|
||||
@@ -17,45 +17,63 @@ export class LogoutCommandHandler {
|
||||
try {
|
||||
logAuth('Logout process started', userId);
|
||||
|
||||
// 1. Get token from request to blacklist it
|
||||
let tokenToBlacklist: string | null = null;
|
||||
// 1. Get tokens from request to blacklist them
|
||||
let accessTokenToBlacklist: string | null = null;
|
||||
let refreshTokenToBlacklist: string | null = null;
|
||||
|
||||
if (req) {
|
||||
// Extract token from cookie
|
||||
tokenToBlacklist = req.cookies['auth_token'];
|
||||
|
||||
// Also check Authorization header as fallback
|
||||
if (!tokenToBlacklist && req.headers.authorization) {
|
||||
// Extract access token from cookie or Authorization header
|
||||
accessTokenToBlacklist = req.cookies['auth_token'];
|
||||
if (!accessTokenToBlacklist && req.headers.authorization) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader.startsWith('Bearer ')) {
|
||||
tokenToBlacklist = authHeader.substring(7);
|
||||
accessTokenToBlacklist = authHeader.substring(7);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract refresh token from cookie or header
|
||||
refreshTokenToBlacklist = req.cookies['refresh_token'];
|
||||
if (!refreshTokenToBlacklist) {
|
||||
refreshTokenToBlacklist = req.headers['x-refresh-token'] as string;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Blacklist the current JWT token in Redis (if available)
|
||||
if (tokenToBlacklist && req) {
|
||||
// 2. Blacklist both access and refresh tokens in Redis
|
||||
if (accessTokenToBlacklist && req) {
|
||||
try {
|
||||
// Store token in blacklist with expiration matching token expiry
|
||||
const decoded = this.jwtService.verify(req);
|
||||
if (decoded && decoded.exp) {
|
||||
const ttl = decoded.exp - Math.floor(Date.now() / 1000);
|
||||
if (ttl > 0) {
|
||||
await this.redisService.setWithExpiry(`blacklist:${tokenToBlacklist}`, 'true', ttl);
|
||||
logAuth('JWT token blacklisted', userId, { tokenExpiry: ttl });
|
||||
await this.redisService.setWithExpiry(`blacklist:${accessTokenToBlacklist}`, 'true', ttl);
|
||||
logAuth('Access token blacklisted', userId, { tokenExpiry: ttl });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logWarning('Failed to blacklist token', { userId, error: (error as Error).message });
|
||||
logWarning('Failed to blacklist access token', { userId, error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Clear authentication cookie
|
||||
res.clearCookie('auth_token', {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
path: '/'
|
||||
});
|
||||
// Blacklist refresh token if present
|
||||
if (refreshTokenToBlacklist) {
|
||||
try {
|
||||
const refreshDecoded = this.jwtService.verifyRefreshToken(refreshTokenToBlacklist);
|
||||
if (refreshDecoded && refreshDecoded.exp) {
|
||||
const ttl = refreshDecoded.exp - Math.floor(Date.now() / 1000);
|
||||
if (ttl > 0) {
|
||||
await this.redisService.setWithExpiry(`blacklist:${refreshTokenToBlacklist}`, 'true', ttl);
|
||||
logAuth('Refresh token blacklisted', userId, { tokenExpiry: ttl });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logWarning('Failed to blacklist refresh token', { userId, error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Use JWT service to clear cookies and set logout headers
|
||||
if (req) {
|
||||
this.jwtService.logout(req, res);
|
||||
}
|
||||
|
||||
// 4. Remove user from active sessions in Redis
|
||||
try {
|
||||
@@ -68,7 +86,7 @@ export class LogoutCommandHandler {
|
||||
|
||||
// 5. Update user's last logout timestamp in database
|
||||
try {
|
||||
const updateResult = await this.userRepo.update(userId, { updatedate: new Date() });
|
||||
const updateResult = await this.userRepo.update(userId, { updateDate: new Date() });
|
||||
if (updateResult) {
|
||||
logAuth('User last logout timestamp updated', userId);
|
||||
}
|
||||
@@ -133,7 +151,7 @@ export class LogoutCommandHandler {
|
||||
}
|
||||
|
||||
// Update user logout timestamp
|
||||
await this.userRepo.update(userId, { updatedate: new Date() });
|
||||
await this.userRepo.update(userId, { updateDate: new Date() });
|
||||
|
||||
logAuth('User logged out from all devices', userId);
|
||||
return true;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export interface RequestPasswordResetCommand {
|
||||
language: 'hu' | 'de' | 'en';
|
||||
email: string;
|
||||
}
|
||||
|
||||
+5
-3
@@ -38,14 +38,16 @@ export class RequestPasswordResetCommandHandler {
|
||||
|
||||
// Send password reset email
|
||||
try {
|
||||
const baseUrl = process.env.APP_BASE_URL || 'http://localhost:3000';
|
||||
const baseUrl = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||
const resetUrl = TokenService.generatePasswordResetUrl(baseUrl, resetTokenData.token);
|
||||
|
||||
|
||||
|
||||
const emailSent = await this.emailService.sendPasswordResetEmail(
|
||||
user.email,
|
||||
`${user.fname} ${user.lname}`,
|
||||
resetTokenData.token,
|
||||
resetUrl
|
||||
resetUrl,
|
||||
cmd.language
|
||||
);
|
||||
|
||||
if (!emailSent) {
|
||||
|
||||
@@ -55,4 +55,4 @@ export class ResetPasswordCommandHandler {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ export interface UpdateUserCommand {
|
||||
fname?: string;
|
||||
lname?: string;
|
||||
code?: string;
|
||||
type?: string;
|
||||
phone?: string;
|
||||
state?: number;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
||||
import { OrganizationAggregate } from '../Organization/OrganizationAggregate';
|
||||
import { UserAggregate } from '../User/UserAggregate';
|
||||
import { logError } from '../../Application/Services/Logger';
|
||||
|
||||
export enum Type {
|
||||
LUCK = 0,
|
||||
@@ -72,8 +74,8 @@ export class DeckAggregate {
|
||||
@Column({ type: 'int', default: CType.PUBLIC })
|
||||
ctype!: CType;
|
||||
|
||||
@UpdateDateColumn({ name: 'update_date' })
|
||||
updatedate!: Date;
|
||||
@UpdateDateColumn()
|
||||
updateDate!: Date;
|
||||
|
||||
@Column({ type: 'int', default: State.ACTIVE })
|
||||
state!: State;
|
||||
@@ -81,4 +83,21 @@ export class DeckAggregate {
|
||||
@ManyToOne(() => OrganizationAggregate, { nullable: true })
|
||||
@JoinColumn({ name: 'organization_id' })
|
||||
organization!: OrganizationAggregate | null;
|
||||
|
||||
@ManyToOne(() => UserAggregate, { eager: false })
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user!: UserAggregate | null;
|
||||
|
||||
isEditable(userId:string): boolean{
|
||||
// A deck is editable if the user is the creator
|
||||
if (!this.user) {
|
||||
logError(`DeckAggregate.isEditable: User is null for deck id ${this.id}`);
|
||||
return false;
|
||||
}
|
||||
//if admin, always editable
|
||||
if (this.user?.isAdmin) {
|
||||
return true;
|
||||
}
|
||||
return this.user?.id.toString() === userId;;
|
||||
}
|
||||
}
|
||||
@@ -50,16 +50,19 @@ export class GameAggregate {
|
||||
@Column({ type: 'int', default: LoginType.PUBLIC })
|
||||
logintype!: LoginType;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
createdby!: string | null;
|
||||
@Column({ type: 'int', default: 50 })
|
||||
boardsize!: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
@Column({ type: 'uuid', nullable: false, name: 'createdBy' })
|
||||
createdby!: string;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true, name: 'organizationid' })
|
||||
orgid!: string | null;
|
||||
|
||||
@Column({ type: 'json' })
|
||||
@Column({ type: 'jsonb', default: () => "'[]'", name: 'decks' })
|
||||
gamedecks!: GameDeck[];
|
||||
|
||||
@Column({ type: 'json', default: () => "'[]'" })
|
||||
@Column({ type: 'uuid', array: true, default: () => "'{}'", name: 'playerids' })
|
||||
players!: string[];
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
@@ -68,23 +71,23 @@ export class GameAggregate {
|
||||
@Column({ type: 'boolean', default: false })
|
||||
finished!: boolean;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
@Column({ type: 'uuid', nullable: true, name: 'winnerid' })
|
||||
winner!: string | null;
|
||||
|
||||
@Column({ type: 'int', default: GameState.WAITING })
|
||||
state!: GameState;
|
||||
|
||||
@CreateDateColumn({ name: 'create_date' })
|
||||
@CreateDateColumn({ name: 'createDate' })
|
||||
createdate!: Date;
|
||||
|
||||
@Column({ type: 'timestamp', nullable: true, name: 'start_date' })
|
||||
startdate!: Date | null;
|
||||
|
||||
@Column({ type: 'timestamp', nullable: true, name: 'end_date' })
|
||||
@Column({ type: 'timestamp', nullable: true, name: 'finishDate' })
|
||||
enddate!: Date | null;
|
||||
|
||||
@UpdateDateColumn({ name: 'update_date' })
|
||||
updatedate!: Date;
|
||||
@UpdateDateColumn()
|
||||
updateDate!: Date;
|
||||
}
|
||||
|
||||
// Board Generation Types
|
||||
|
||||
@@ -1,9 +1,27 @@
|
||||
import { GameAggregate } from '../Game/GameAggregate';
|
||||
<<<<<<< HEAD
|
||||
import { IPaginatedRepository } from './IBaseRepository';
|
||||
|
||||
export interface IGameRepository extends IPaginatedRepository<GameAggregate, { games: GameAggregate[], totalCount: number }> {
|
||||
// Game-specific methods
|
||||
findByGameCode(gamecode: string): Promise<GameAggregate | null>;
|
||||
=======
|
||||
|
||||
export interface IGameRepository {
|
||||
create(game: Partial<GameAggregate>): Promise<GameAggregate>;
|
||||
findByPage(from: number, to: number): Promise<{ games: GameAggregate[], totalCount: number }>;
|
||||
findByPageIncludingDeleted(from: number, to: number): Promise<{ games: GameAggregate[], totalCount: number }>;
|
||||
findById(id: string): Promise<GameAggregate | null>;
|
||||
findByIdIncludingDeleted(id: string): Promise<GameAggregate | null>;
|
||||
findByGameCode(gamecode: string): Promise<GameAggregate | null>;
|
||||
search(query: string, limit?: number, offset?: number): Promise<{ games: GameAggregate[], totalCount: number }>;
|
||||
searchIncludingDeleted(query: string, limit?: number, offset?: number): Promise<{ games: GameAggregate[], totalCount: number }>;
|
||||
update(id: string, update: Partial<GameAggregate>): Promise<GameAggregate | null>;
|
||||
delete(id: string): Promise<any>;
|
||||
softDelete(id: string): Promise<GameAggregate | null>;
|
||||
|
||||
// Game-specific methods
|
||||
>>>>>>> 83fad59878db015ec8d86bdec1ecbbca0baddfd2
|
||||
findActiveGames(): Promise<GameAggregate[]>;
|
||||
findGamesByPlayer(playerId: string): Promise<GameAggregate[]>;
|
||||
findWaitingGames(): Promise<GameAggregate[]>;
|
||||
|
||||
@@ -7,4 +7,5 @@ export interface IUserRepository extends IPaginatedRepository<UserAggregate, { u
|
||||
findByEmail(email: string): Promise<UserAggregate | null>;
|
||||
findByToken(token: string): Promise<UserAggregate | null>;
|
||||
deactivate(id: string): Promise<UserAggregate | null>;
|
||||
activate(id: string): Promise<UserAggregate | null>;
|
||||
}
|
||||
|
||||
@@ -35,8 +35,8 @@ export class OrganizationAggregate {
|
||||
@CreateDateColumn()
|
||||
regdate!: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedate!: Date;
|
||||
@UpdateDateColumn({ name: 'updateDate' })
|
||||
updateDate!: Date;
|
||||
|
||||
@Column({ type: 'varchar', length: 500, nullable: true })
|
||||
url!: string | null;
|
||||
|
||||
@@ -51,8 +51,12 @@ export class UserAggregate {
|
||||
regdate!: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedate!: Date;
|
||||
updateDate!: Date;
|
||||
|
||||
@Column({ type: 'timestamp', nullable: true })
|
||||
Orglogindate!: Date | null;
|
||||
|
||||
get isAdmin(): boolean {
|
||||
return this.state === UserState.ADMIN;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class Full1758463929834 implements MigrationInterface {
|
||||
name = 'Full1758463929834'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "Games" DROP COLUMN "winner"`);
|
||||
await queryRunner.query(`ALTER TABLE "Games" DROP COLUMN "create_date"`);
|
||||
await queryRunner.query(`ALTER TABLE "Games" DROP COLUMN "end_date"`);
|
||||
await queryRunner.query(`ALTER TABLE "Games" DROP COLUMN "update_date"`);
|
||||
await queryRunner.query(`ALTER TABLE "Games" ADD "boardsize" integer NOT NULL DEFAULT '50'`);
|
||||
await queryRunner.query(`ALTER TABLE "Games" ADD "winnerid" uuid`);
|
||||
await queryRunner.query(`ALTER TABLE "Games" ADD "createDate" TIMESTAMP NOT NULL DEFAULT now()`);
|
||||
await queryRunner.query(`ALTER TABLE "Games" ADD "finishDate" TIMESTAMP`);
|
||||
await queryRunner.query(`ALTER TABLE "Games" ADD "updateDate" TIMESTAMP NOT NULL DEFAULT now()`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "Games" DROP COLUMN "updateDate"`);
|
||||
await queryRunner.query(`ALTER TABLE "Games" DROP COLUMN "finishDate"`);
|
||||
await queryRunner.query(`ALTER TABLE "Games" DROP COLUMN "createDate"`);
|
||||
await queryRunner.query(`ALTER TABLE "Games" DROP COLUMN "winnerid"`);
|
||||
await queryRunner.query(`ALTER TABLE "Games" DROP COLUMN "boardsize"`);
|
||||
await queryRunner.query(`ALTER TABLE "Games" ADD "update_date" TIMESTAMP NOT NULL DEFAULT now()`);
|
||||
await queryRunner.query(`ALTER TABLE "Games" ADD "end_date" TIMESTAMP`);
|
||||
await queryRunner.query(`ALTER TABLE "Games" ADD "create_date" TIMESTAMP NOT NULL DEFAULT now()`);
|
||||
await queryRunner.query(`ALTER TABLE "Games" ADD "winner" character varying(255)`);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class Full1757939815062 implements MigrationInterface {
|
||||
export class Full1758463928499 implements MigrationInterface {
|
||||
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
export class Full1758463928499 implements MigrationInterface {
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export class DeckRepository implements IDeckRepository {
|
||||
// Get paginated results
|
||||
const decks = await this.repo.find({
|
||||
where: { state: Not(State.SOFT_DELETE) },
|
||||
order: { updatedate: 'DESC' },
|
||||
order: { updateDate: 'DESC' },
|
||||
take: limit,
|
||||
skip: offset
|
||||
});
|
||||
@@ -57,7 +57,7 @@ export class DeckRepository implements IDeckRepository {
|
||||
|
||||
// Get paginated results
|
||||
const decks = await this.repo.find({
|
||||
order: { updatedate: 'DESC' },
|
||||
order: { updateDate: 'DESC' },
|
||||
take: limit,
|
||||
skip: offset
|
||||
});
|
||||
@@ -255,7 +255,7 @@ export class DeckRepository implements IDeckRepository {
|
||||
|
||||
const [decks, totalCount] = await this.repo.findAndCount({
|
||||
where: { state: Not(State.SOFT_DELETE) },
|
||||
relations: ['organization'],
|
||||
relations: ['organization', 'user'],
|
||||
order: { creationdate: 'DESC' },
|
||||
skip,
|
||||
take
|
||||
@@ -270,6 +270,7 @@ export class DeckRepository implements IDeckRepository {
|
||||
// Regular user complex filtering
|
||||
const queryBuilder = this.repo.createQueryBuilder('deck')
|
||||
.leftJoinAndSelect('deck.organization', 'org')
|
||||
.leftJoinAndSelect('deck.user', 'user')
|
||||
.where('deck.state != :deletedState', { deletedState: State.SOFT_DELETE });
|
||||
|
||||
queryBuilder.andWhere('(' +
|
||||
|
||||
@@ -39,7 +39,7 @@ export class GameRepository implements IGameRepository {
|
||||
// Get paginated results
|
||||
const games = await this.repo.find({
|
||||
where: { state: Not(GameState.CANCELLED) },
|
||||
order: { updatedate: 'DESC' },
|
||||
order: { updateDate: 'DESC' },
|
||||
take: limit,
|
||||
skip: offset
|
||||
});
|
||||
@@ -67,7 +67,7 @@ export class GameRepository implements IGameRepository {
|
||||
|
||||
// Get paginated results (including deleted)
|
||||
const games = await this.repo.find({
|
||||
order: { updatedate: 'DESC' },
|
||||
order: { updateDate: 'DESC' },
|
||||
take: limit,
|
||||
skip: offset
|
||||
});
|
||||
@@ -153,7 +153,7 @@ export class GameRepository implements IGameRepository {
|
||||
queryBuilder.skip(offset);
|
||||
}
|
||||
|
||||
const games = await queryBuilder.orderBy('game.updatedate', 'DESC').getMany();
|
||||
const games = await queryBuilder.orderBy('game.updateDate', 'DESC').getMany();
|
||||
|
||||
const endTime = performance.now();
|
||||
logDatabase('Game search completed', `executionTime: ${Math.round(endTime - startTime)}ms, query: ${query}, found: ${games.length}, total: ${totalCount}`);
|
||||
@@ -184,7 +184,7 @@ export class GameRepository implements IGameRepository {
|
||||
queryBuilder.skip(offset);
|
||||
}
|
||||
|
||||
const games = await queryBuilder.orderBy('game.updatedate', 'DESC').getMany();
|
||||
const games = await queryBuilder.orderBy('game.updateDate', 'DESC').getMany();
|
||||
|
||||
const endTime = performance.now();
|
||||
logDatabase('Game search (including deleted) completed', `executionTime: ${Math.round(endTime - startTime)}ms, query: ${query}, found: ${games.length}, total: ${totalCount}`);
|
||||
@@ -251,7 +251,7 @@ export class GameRepository implements IGameRepository {
|
||||
try {
|
||||
const games = await this.repo.find({
|
||||
where: { state: GameState.ACTIVE },
|
||||
order: { updatedate: 'DESC' }
|
||||
order: { updateDate: 'DESC' }
|
||||
});
|
||||
const endTime = performance.now();
|
||||
logDatabase('Active games query completed', `executionTime: ${Math.round(endTime - startTime)}ms, found: ${games.length}`);
|
||||
@@ -270,7 +270,7 @@ export class GameRepository implements IGameRepository {
|
||||
const queryBuilder = this.repo.createQueryBuilder('game')
|
||||
.where('game.state != :cancelledState', { cancelledState: GameState.CANCELLED })
|
||||
.andWhere('JSON_CONTAINS(game.players, :playerId)', { playerId: `"${playerId}"` })
|
||||
.orderBy('game.updatedate', 'DESC');
|
||||
.orderBy('game.updateDate', 'DESC');
|
||||
|
||||
const games = await queryBuilder.getMany();
|
||||
const endTime = performance.now();
|
||||
|
||||
@@ -345,5 +345,25 @@ export class UserRepository implements IUserRepository {
|
||||
}
|
||||
}
|
||||
|
||||
async activate(id: string) {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
await this.repo.update(id, { state: UserState.VERIFIED_REGULAR });
|
||||
const result = await this.findById(id);
|
||||
logDatabase('User activated successfully', `update(${id}, { state: VERIFIED_REGULAR })`, Date.now() - startTime, {
|
||||
userId: id,
|
||||
success: !!result
|
||||
});
|
||||
return result;
|
||||
}
|
||||
catch (error) {
|
||||
logError('UserRepository.activate error', error as Error);
|
||||
// Handle invalid UUID format
|
||||
if (error instanceof Error && error.message.includes('invalid input syntax for type uuid')) {
|
||||
throw new Error('Invalid user ID format');
|
||||
}
|
||||
throw new Error('Failed to activate user in database');
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -22,18 +22,31 @@
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
.header table {
|
||||
margin: 0 auto;
|
||||
}
|
||||
.header img {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
display: block;
|
||||
}
|
||||
.logo {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #2c5aa0;
|
||||
margin-bottom: 10px;
|
||||
color: #2E7D32;
|
||||
vertical-align: middle;
|
||||
padding-left: 10px;
|
||||
}
|
||||
.subtitle {
|
||||
font-size: 18px;
|
||||
color: #666;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 2px solid #4CAF50;
|
||||
}
|
||||
.greeting {
|
||||
font-size: 16px;
|
||||
@@ -98,9 +111,14 @@
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<div class="header">
|
||||
<div class="logo">🐍 {{companyName}}</div>
|
||||
<div class="subtitle">Antwort auf Ihre {{contactTypeString}}</div>
|
||||
<table cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td><img src="cid:logo@serpentrace" alt="Logo"/></td>
|
||||
<td class="logo">🐍 SerpentRace</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="subtitle">Antwort auf Ihre {{contactTypeString}}</div>
|
||||
|
||||
<div class="greeting">
|
||||
Hallo {{contactName}},
|
||||
|
||||
@@ -22,18 +22,31 @@
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
.header table {
|
||||
margin: 0 auto;
|
||||
}
|
||||
.header img {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
display: block;
|
||||
}
|
||||
.logo {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #2c5aa0;
|
||||
margin-bottom: 10px;
|
||||
color: #2E7D32;
|
||||
vertical-align: middle;
|
||||
padding-left: 10px;
|
||||
}
|
||||
.subtitle {
|
||||
font-size: 18px;
|
||||
color: #666;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 2px solid #4CAF50;
|
||||
}
|
||||
.greeting {
|
||||
font-size: 16px;
|
||||
@@ -98,9 +111,14 @@
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<div class="header">
|
||||
<div class="logo">🐍 {{companyName}}</div>
|
||||
<div class="subtitle">Válasz az Ön {{contactTypeString}} üzenetére</div>
|
||||
<table cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td><img src="cid:logo@serpentrace" alt="Logo"/></td>
|
||||
<td class="logo">🐍 SerpentRace</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="subtitle">Válasz az ön {{contactTypeString}}</div>
|
||||
|
||||
<div class="greeting">
|
||||
Kedves {{contactName}}!
|
||||
|
||||
@@ -22,18 +22,31 @@
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
.header table {
|
||||
margin: 0 auto;
|
||||
}
|
||||
.header img {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
display: block;
|
||||
}
|
||||
.logo {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #2c5aa0;
|
||||
margin-bottom: 10px;
|
||||
color: #2E7D32;
|
||||
vertical-align: middle;
|
||||
padding-left: 10px;
|
||||
}
|
||||
.subtitle {
|
||||
font-size: 18px;
|
||||
color: #666;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 2px solid #4CAF50;
|
||||
}
|
||||
.greeting {
|
||||
font-size: 16px;
|
||||
@@ -98,9 +111,14 @@
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<div class="header">
|
||||
<div class="logo">🐍 {{companyName}}</div>
|
||||
<div class="subtitle">Response to Your {{contactTypeString}}</div>
|
||||
<table cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td><img src="cid:logo@serpentrace" alt="Logo"/></td>
|
||||
<td class="logo">🐍 SerpentRace</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="subtitle">Response to Your {{contactTypeString}}</div>
|
||||
|
||||
<div class="greeting">
|
||||
Hello {{contactName}},
|
||||
|
||||
@@ -22,19 +22,31 @@
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
border-bottom: 2px solid #FF9800;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
.header table {
|
||||
margin: 0 auto;
|
||||
}
|
||||
.header img {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
display: block;
|
||||
}
|
||||
.logo {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #E65100;
|
||||
margin-bottom: 10px;
|
||||
color: #2E7D32;
|
||||
vertical-align: middle;
|
||||
padding-left: 10px;
|
||||
}
|
||||
.subtitle {
|
||||
color: #666;
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 2px solid #4CAF50;
|
||||
}
|
||||
.content {
|
||||
margin-bottom: 30px;
|
||||
@@ -123,9 +135,14 @@
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<div class="header">
|
||||
<div class="logo">🐍 {{ companyName }}</div>
|
||||
<div class="subtitle">Passwort zurücksetzen</div>
|
||||
<table cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td><img src="cid:logo@serpentrace" alt="Logo"/></td>
|
||||
<td class="logo">SerpentRace</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="subtitle">Passwort zurücksetzen</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="greeting">
|
||||
|
||||
@@ -22,19 +22,31 @@
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
border-bottom: 2px solid #FF9800;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
.header table {
|
||||
margin: 0 auto;
|
||||
}
|
||||
.header img {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
display: block;
|
||||
}
|
||||
.logo {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #E65100;
|
||||
margin-bottom: 10px;
|
||||
color: #2E7D32;
|
||||
vertical-align: middle;
|
||||
padding-left: 10px;
|
||||
}
|
||||
.subtitle {
|
||||
color: #666;
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 2px solid #4CAF50;
|
||||
}
|
||||
.content {
|
||||
margin-bottom: 30px;
|
||||
@@ -123,9 +135,14 @@
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<div class="header">
|
||||
<div class="logo">🐍 {{ companyName }}</div>
|
||||
<div class="subtitle">Jelszó visszaállítás kérése</div>
|
||||
<table cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td><img src="cid:logo@serpentrace" alt="Logo"/></td>
|
||||
<td class="logo">SerpentRace</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="subtitle">Jelszó visszaállítás kérése</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="greeting">
|
||||
|
||||
@@ -22,19 +22,31 @@
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
border-bottom: 2px solid #FF9800;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
.header table {
|
||||
margin: 0 auto;
|
||||
}
|
||||
.header img {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
display: block;
|
||||
}
|
||||
.logo {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #E65100;
|
||||
margin-bottom: 10px;
|
||||
color: #2E7D32;
|
||||
vertical-align: middle;
|
||||
padding-left: 10px;
|
||||
}
|
||||
.subtitle {
|
||||
color: #666;
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 2px solid #4CAF50;
|
||||
}
|
||||
.content {
|
||||
margin-bottom: 30px;
|
||||
@@ -123,9 +135,14 @@
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<div class="header">
|
||||
<div class="logo">🐍 {{ companyName }}</div>
|
||||
<div class="subtitle">Password Reset Request</div>
|
||||
<table cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td><img src="cid:logo@serpentrace" alt="Logo"/></td>
|
||||
<td class="logo">SerpentRace</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="subtitle">Password Reset Request</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="greeting">
|
||||
|
||||
@@ -22,19 +22,31 @@
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
border-bottom: 2px solid #4CAF50;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
.header table {
|
||||
margin: 0 auto;
|
||||
}
|
||||
.header img {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
display: block;
|
||||
}
|
||||
.logo {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #2E7D32;
|
||||
margin-bottom: 10px;
|
||||
vertical-align: middle;
|
||||
padding-left: 10px;
|
||||
}
|
||||
.subtitle {
|
||||
color: #666;
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 2px solid #4CAF50;
|
||||
}
|
||||
.content {
|
||||
margin-bottom: 30px;
|
||||
@@ -115,9 +127,14 @@
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<div class="header">
|
||||
<div class="logo">🐍 {{ companyName }}</div>
|
||||
<div class="subtitle">Konto verifizieren</div>
|
||||
<table cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td><img src="cid:logo@serpentrace" alt="Logo"/></td>
|
||||
<td class="logo">SerpentRace</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="subtitle">Konto verifizieren</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="greeting">
|
||||
|
||||
@@ -22,19 +22,31 @@
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
border-bottom: 2px solid #4CAF50;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
.header table {
|
||||
margin: 0 auto;
|
||||
}
|
||||
.header img {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
display: block;
|
||||
}
|
||||
.logo {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #2E7D32;
|
||||
margin-bottom: 10px;
|
||||
vertical-align: middle;
|
||||
padding-left: 10px;
|
||||
}
|
||||
.subtitle {
|
||||
color: #666;
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 2px solid #4CAF50;
|
||||
}
|
||||
.content {
|
||||
margin-bottom: 30px;
|
||||
@@ -115,9 +127,14 @@
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<div class="header">
|
||||
<div class="logo">🐍 {{ companyName }}</div>
|
||||
<div class="subtitle">Fiók megerősítése</div>
|
||||
<table cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td><img src="cid:logo@serpentrace" alt="Logo"/></td>
|
||||
<td class="logo">SerpentRace</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="subtitle">Fiók megerősítése</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="greeting">
|
||||
|
||||
@@ -22,19 +22,32 @@
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
border-bottom: 2px solid #4CAF50;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
.header table {
|
||||
margin: 0 auto;
|
||||
}
|
||||
.header img {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
display: block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.logo {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #2E7D32;
|
||||
margin-bottom: 10px;
|
||||
vertical-align: middle;
|
||||
padding-left: 10px;
|
||||
}
|
||||
.subtitle {
|
||||
color: #666;
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 2px solid #4CAF50;
|
||||
}
|
||||
.content {
|
||||
margin-bottom: 30px;
|
||||
@@ -115,9 +128,14 @@
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<div class="header">
|
||||
<div class="logo">🐍 {{ companyName }}</div>
|
||||
<div class="subtitle">Account Verification</div>
|
||||
<table cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td><img src="cid:logo@serpentrace" alt="Logo"/></td>
|
||||
<td class="logo">SerpentRace</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="subtitle">Account Verification</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="greeting">
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Test script for Organization Authentication functionality
|
||||
* This script tests the new organization authentication features:
|
||||
* 1. Get organization login URL
|
||||
* 2. Process third-party authentication callback
|
||||
* 3. Login with organization reauthentication check
|
||||
*/
|
||||
|
||||
const { container } = require('./dist/Application/Services/DIContainer.js');
|
||||
|
||||
async function testOrganizationAuth() {
|
||||
console.log('🧪 Testing Organization Authentication Functionality\n');
|
||||
|
||||
try {
|
||||
// Test 1: Get Organization Login URL
|
||||
console.log('1️⃣ Testing Get Organization Login URL Query Handler');
|
||||
const getUrlHandler = container.getOrganizationLoginUrlQueryHandler;
|
||||
console.log('✅ Handler instantiated successfully');
|
||||
|
||||
// Test 2: Process Organization Auth Callback
|
||||
console.log('2️⃣ Testing Process Organization Auth Callback Command Handler');
|
||||
const callbackHandler = container.processOrgAuthCallbackCommandHandler;
|
||||
console.log('✅ Handler instantiated successfully');
|
||||
|
||||
// Test 3: Enhanced Login Handler with Organization Repository
|
||||
console.log('3️⃣ Testing Enhanced Login Handler');
|
||||
const loginHandler = container.loginCommandHandler;
|
||||
console.log('✅ Enhanced login handler instantiated successfully');
|
||||
|
||||
console.log('\n🎉 All Organization Authentication components initialized successfully!');
|
||||
console.log('\n📋 Summary of new functionality:');
|
||||
console.log(' • GET /api/organizations/:orgId/login-url - Get organization third-party login URL');
|
||||
console.log(' • POST /api/organizations/auth-callback - Process third-party authentication result');
|
||||
console.log(' • Enhanced login response includes organization reauthentication requirements');
|
||||
console.log(' • Users must reauthenticate with organization if last login > 1 month ago');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error testing organization authentication:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the test
|
||||
testOrganizationAuth();
|
||||
+51
-13
@@ -1,17 +1,55 @@
|
||||
# Development Environment Variables
|
||||
POSTGRES_PASSWORD=postgres
|
||||
JWT_SECRET=dev_jwt_secret_change_in_production_please_use_a_long_random_string
|
||||
JWT_EXPIRATION=24h
|
||||
JWT_REFRESH_EXPIRATION=7d
|
||||
# ==============================================
|
||||
# SerpentRace Backend Environment Configuration
|
||||
# ==============================================
|
||||
# Copy this file to .env and fill in your values
|
||||
|
||||
# APPLICATION CONFIGURATION
|
||||
NODE_ENV=development
|
||||
PORT=3000
|
||||
APP_BASE_URL=http://localhost:3000
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
|
||||
# DATABASE CONFIGURATION (PostgreSQL)
|
||||
DB_HOST=postgres
|
||||
DB_PORT=5432
|
||||
DB_NAME=serpentrace
|
||||
DB_USERNAME=postgres
|
||||
DB_PASSWORD=postgres
|
||||
|
||||
# REDIS CONFIGURATION
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
REDIS_URL=redis://redis:6379
|
||||
|
||||
# MINIO CONFIGURATION
|
||||
MINIO_ENDPOINT=minio
|
||||
MINIO_PORT=9000
|
||||
MINIO_ACCESS_KEY=serpentrace
|
||||
MINIO_SECRET_KEY=serpentrace123!
|
||||
MINIO_USE_SSL=false
|
||||
MINIO_BUCKET_NAME=serpentrace-logs
|
||||
|
||||
# Optional: Email configuration for development
|
||||
EMAIL_HOST=
|
||||
EMAIL_PORT=
|
||||
EMAIL_USER=
|
||||
EMAIL_PASS=
|
||||
EMAIL_FROM=
|
||||
# JWT CONFIGURATION
|
||||
JWT_SECRET=your_super_secret_jwt_key_change_in_production
|
||||
JWT_EXPIRY=86400
|
||||
JWT_EXPIRATION=24h
|
||||
JWT_REFRESH_EXPIRATION=7d
|
||||
GAME_TOKEN_EXPIRY=86400
|
||||
|
||||
# Optional: Other development settings
|
||||
NODE_ENV=development
|
||||
# EMAIL SERVICE CONFIGURATION
|
||||
EMAIL_HOST=mail.serpentrace.hu
|
||||
EMAIL_PORT=465
|
||||
EMAIL_SECURE=true
|
||||
EMAIL_USER=noreply@serpentrace.hu
|
||||
EMAIL_PASS=ZUx720ece&Cin&F{
|
||||
EMAIL_FROM=noreply@serpentrace.hu
|
||||
|
||||
# CHAT SYSTEM CONFIGURATION
|
||||
CHAT_INACTIVITY_TIMEOUT_MINUTES=30
|
||||
CHAT_MAX_MESSAGES_PER_USER=100
|
||||
CHAT_MESSAGE_CLEANUP_WEEKS=4
|
||||
|
||||
# GAME CONFIGURATION
|
||||
MAX_SPECIAL_FIELDS_PERCENTAGE=67
|
||||
MAX_GENERATION_TIME_SECONDS=20
|
||||
GENERATION_ERROR_TOLERANCE=15
|
||||
|
||||
@@ -219,4 +219,4 @@ EMAIL_DEBUG_MODE=true
|
||||
# - EMAIL_HOST, EMAIL_PORT, EMAIL_USER, EMAIL_PASS
|
||||
|
||||
# OPTIONAL VARIABLES:
|
||||
# All other variables have sensible defaults and are optional
|
||||
# All other variables have sensible defaults and are optional
|
||||
|
||||
@@ -60,7 +60,7 @@ services:
|
||||
- "5173:5173"
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- VITE_API_URL=http://localhost:3000
|
||||
- API_URL=http://localhost:3000
|
||||
volumes:
|
||||
- ../SerpentRace_Frontend:/app
|
||||
- /app/node_modules
|
||||
@@ -83,7 +83,7 @@ services:
|
||||
POSTGRES_INITDB_ARGS: "--encoding=UTF-8"
|
||||
volumes:
|
||||
- postgres_dev_data:/var/lib/postgresql/data
|
||||
- ./sql_dump_with_test_data.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||
- ./sql_schema_only.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||
networks:
|
||||
- serpentrace-network
|
||||
healthcheck:
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
services:
|
||||
# Backend service with hot reload
|
||||
backend:
|
||||
build:
|
||||
context: ../SerpentRace_Backend
|
||||
dockerfile: ../SerpentRace_Docker/Dockerfile_backend.dev
|
||||
container_name: serpentrace-backend-dev
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env.dev
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- PORT=3000
|
||||
- FRONTEND_URL=http://localhost:5173
|
||||
- DB_HOST=postgres
|
||||
- DB_PORT=5432
|
||||
- DB_NAME=serpentrace
|
||||
- DB_USERNAME=postgres
|
||||
- DB_PASSWORD=postgres
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- REDIS_HOST=redis
|
||||
- REDIS_PORT=6379
|
||||
- MINIO_ENDPOINT=minio
|
||||
- MINIO_PORT=9000
|
||||
- MINIO_ACCESS_KEY=serpentrace
|
||||
- MINIO_SECRET_KEY=serpentrace123!
|
||||
- MINIO_USE_SSL=false
|
||||
volumes: [ ../SerpentRace_Backend/logs:/app/logs ]
|
||||
develop:
|
||||
watch:
|
||||
- action: sync
|
||||
path: ../SerpentRace_Backend/src
|
||||
target: /app/src
|
||||
ignore:
|
||||
- node_modules/
|
||||
- dist/
|
||||
- "*.log"
|
||||
- action: sync
|
||||
path: ../SerpentRace_Backend/package.json
|
||||
target: /app/package.json
|
||||
- action: rebuild
|
||||
path: ../SerpentRace_Backend/package-lock.json
|
||||
- action: rebuild
|
||||
path: ../SerpentRace_Backend/tsconfig.json
|
||||
- action: rebuild
|
||||
path: ./Dockerfile_backend.dev
|
||||
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_healthy
|
||||
network_mode: host
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
# Frontend service with hot reload
|
||||
frontend:
|
||||
build:
|
||||
context: ../SerpentRace_Frontend
|
||||
dockerfile: ../SerpentRace_Docker/Dockerfile_frontend.dev
|
||||
container_name: serpentrace-frontend-dev
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5173:5173"
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- VITE_API_URL=http://localhost:3000
|
||||
volumes: []
|
||||
develop:
|
||||
watch:
|
||||
- action: sync
|
||||
path: ../SerpentRace_Frontend/src
|
||||
target: /app/src
|
||||
ignore:
|
||||
- node_modules/
|
||||
- dist/
|
||||
- "*.log"
|
||||
- action: sync
|
||||
path: ../SerpentRace_Frontend/public
|
||||
target: /app/public
|
||||
- action: sync
|
||||
path: ../SerpentRace_Frontend/package.json
|
||||
target: /app/package.json
|
||||
- action: rebuild
|
||||
path: ../SerpentRace_Frontend/package-lock.json
|
||||
- action: rebuild
|
||||
path: ../SerpentRace_Frontend/vite.config.js
|
||||
- action: rebuild
|
||||
path: ./Dockerfile_frontend.dev
|
||||
depends_on:
|
||||
- backend
|
||||
network_mode: host
|
||||
|
||||
# PostgreSQL Database
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: serpentrace-postgres-dev
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
POSTGRES_DB: serpentrace
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_INITDB_ARGS: "--encoding=UTF-8"
|
||||
volumes:
|
||||
- postgres_dev_data:/var/lib/postgresql/data
|
||||
- ./sql_schema_only.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||
network_mode: host
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# Redis Cache
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: serpentrace-redis-dev
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_dev_data:/data
|
||||
command: redis-server --appendonly yes
|
||||
network_mode: host
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# MinIO Object Storage
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
container_name: serpentrace-minio-dev
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "9000:9000"
|
||||
- "9001:9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: serpentrace
|
||||
MINIO_ROOT_PASSWORD: serpentrace123!
|
||||
volumes:
|
||||
- minio_dev_data:/data
|
||||
command: server /data --console-address ":9001"
|
||||
network_mode: host
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# Redis Commander for development debugging
|
||||
redis-commander:
|
||||
image: rediscommander/redis-commander:latest
|
||||
container_name: serpentrace-redis-commander-dev
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8081:8081"
|
||||
environment:
|
||||
- REDIS_HOSTS=local:redis:6379
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
network_mode: host
|
||||
|
||||
# Database administration tool
|
||||
pgadmin:
|
||||
image: dpage/pgadmin4:latest
|
||||
container_name: serpentrace-pgadmin-dev
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:80"
|
||||
environment:
|
||||
PGADMIN_DEFAULT_EMAIL: admin@serpentrace.dev
|
||||
PGADMIN_DEFAULT_PASSWORD: admin
|
||||
PGADMIN_CONFIG_SERVER_MODE: 'False'
|
||||
PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: 'False'
|
||||
PGADMIN_CONFIG_WTF_CSRF_ENABLED: 'False'
|
||||
volumes:
|
||||
- pgadmin_dev_data:/var/lib/pgadmin
|
||||
- ./pgadmin_servers.json:/pgadmin4/servers.json:ro
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
network_mode: host
|
||||
|
||||
volumes:
|
||||
postgres_dev_data:
|
||||
driver: local
|
||||
redis_dev_data:
|
||||
driver: local
|
||||
minio_dev_data:
|
||||
driver: local
|
||||
pgadmin_dev_data:
|
||||
driver: local
|
||||
@@ -6,6 +6,8 @@ services:
|
||||
dockerfile: ../SerpentRace_Docker/Dockerfile_backend.dev
|
||||
container_name: serpentrace-backend-dev
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env.dev
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
@@ -19,9 +21,6 @@ services:
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- REDIS_HOST=redis
|
||||
- REDIS_PORT=6379
|
||||
- JWT_SECRET=dev_jwt_secret_change_in_production
|
||||
- JWT_EXPIRATION=24h
|
||||
- JWT_REFRESH_EXPIRATION=7d
|
||||
- MINIO_ENDPOINT=minio
|
||||
- MINIO_PORT=9000
|
||||
- MINIO_ACCESS_KEY=serpentrace
|
||||
@@ -75,7 +74,8 @@ services:
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- VITE_API_URL=http://localhost:3000
|
||||
volumes: []
|
||||
volumes:
|
||||
[]
|
||||
develop:
|
||||
watch:
|
||||
- action: sync
|
||||
@@ -116,7 +116,7 @@ services:
|
||||
POSTGRES_INITDB_ARGS: "--encoding=UTF-8"
|
||||
volumes:
|
||||
- postgres_dev_data:/var/lib/postgresql/data
|
||||
- ./sql_dump_with_test_data.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||
- ./sql_schema_only.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||
networks:
|
||||
- serpentrace-network
|
||||
healthcheck:
|
||||
|
||||
@@ -48,7 +48,6 @@ CREATE TABLE "Users" (
|
||||
"lname" character varying(100) NOT NULL,
|
||||
"token" character varying(255),
|
||||
"TokenExpires" TIMESTAMP,
|
||||
"type" character varying(50) NOT NULL,
|
||||
"phone" character varying(20),
|
||||
"state" integer NOT NULL DEFAULT 0,
|
||||
"regdate" TIMESTAMP NOT NULL DEFAULT now(),
|
||||
@@ -154,11 +153,11 @@ INSERT INTO "Organizations" ("id", "name", "contactfname", "contactlname", "cont
|
||||
('33333333-3333-3333-3333-333333333333', 'Healthcare Corp', 'Michael', 'Brown', '+1-555-0003', 'michael.brown@healthcorp.com', 0, '2024-03-10 14:20:00', '2024-03-10 14:20:00', NULL, 0, 10);
|
||||
|
||||
-- Users Test Data
|
||||
INSERT INTO "Users" ("id", "orgid", "username", "password", "email", "fname", "lname", "token", "TokenExpires", "type", "phone", "state", "regdate", "updatedate", "Orglogindate") VALUES
|
||||
INSERT INTO "Users" ("id", "orgid", "username", "password", "email", "fname", "lname", "token", "TokenExpires", "phone", "state", "regdate", "updatedate", "Orglogindate") VALUES
|
||||
-- Regular users
|
||||
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', NULL, 'john_doe', '$2b$10$dPXxS9Byg7AbB.fngFtNWel1llS1nHJlQrTO4zQToy7vVitS9mr96', 'john.doe@email.com', 'John', 'Doe', NULL, NULL, 'personal', '+1-555-1001', 1, '2024-01-20 11:00:00', '2024-01-20 11:00:00', NULL),
|
||||
('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '11111111-1111-1111-1111-111111111111', 'jane_premium', '$2b$10$dPXxS9Byg7AbB.fngFtNWel1llS1nHJlQrTO4zQToy7vVitS9mr96', 'jane.smith@email.com', 'Jane', 'Smith', NULL, NULL, 'premium', '+1-555-1002', 2, '2024-01-25 12:30:00', '2024-01-25 12:30:00', '2024-01-25 12:30:00'),
|
||||
('cccccccc-cccc-cccc-cccc-cccccccccccc', '22222222-2222-2222-2222-222222222222', 'teacher_bob', '$2b$10$dPXxS9Byg7AbB.fngFtNWel1llS1nHJlQrTO4zQToy7vVitS9mr96', 'bob.teacher@eduinst.edu', 'Bob', 'Teacher', NULL, NULL, 'premium', '+1-555-1003', 2, '2024-02-05 09:15:00', '2024-02-05 09:15:00', '2024-02-05 09:15:00'),
|
||||
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', NULL, 'john_doe', '$2b$10$dPXxS9Byg7AbB.fngFtNWel1llS1nHJlQrTO4zQToy7vVitS9mr96', 'john.doe@email.com', 'John', 'Doe', NULL, NULL, '+1-555-1001', 1, '2024-01-20 11:00:00', '2024-01-20 11:00:00', NULL),
|
||||
('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '11111111-1111-1111-1111-111111111111', 'jane_premium', '$2b$10$dPXxS9Byg7AbB.fngFtNWel1llS1nHJlQrTO4zQToy7vVitS9mr96', 'jane.smith@email.com', 'Jane', 'Smith', NULL, NULL, '+1-555-1002', 2, '2024-01-25 12:30:00', '2024-01-25 12:30:00', '2024-01-25 12:30:00'),
|
||||
('cccccccc-cccc-cccc-cccc-cccccccccccc', '22222222-2222-2222-2222-222222222222', 'teacher_bob', '$2b$10$dPXxS9Byg7AbB.fngFtNWel1llS1nHJlQrTO4zQToy7vVitS9mr96', 'bob.teacher@eduinst.edu', 'Bob', 'Teacher', NULL, NULL, '+1-555-1003', 2, '2024-02-05 09:15:00', '2024-02-05 09:15:00', '2024-02-05 09:15:00'),
|
||||
-- Admin user
|
||||
('dddddddd-dddd-dddd-dddd-dddddddddddd', NULL, 'admin_user', '$2b$10$dPXxS9Byg7AbB.fngFtNWel1llS1nHJlQrTO4zQToy7vVitS9mr96', 'admin@serpentrace.com', 'Admin', 'User', NULL, NULL, 'admin', '+1-555-9999', 5, '2024-01-01 08:00:00', '2024-01-01 08:00:00', NULL),
|
||||
-- Unverified user
|
||||
|
||||
@@ -19,7 +19,7 @@ CREATE TABLE "Users" (
|
||||
"phone" VARCHAR(20) NULL,
|
||||
"state" INTEGER NOT NULL DEFAULT 0,
|
||||
"regdate" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedate" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updateDate" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"Orglogindate" TIMESTAMP NULL
|
||||
);
|
||||
|
||||
@@ -33,7 +33,7 @@ CREATE TABLE "Organizations" (
|
||||
"contactemail" VARCHAR(255) NOT NULL,
|
||||
"state" INTEGER NOT NULL DEFAULT 0,
|
||||
"regdate" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedate" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updateDate" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"url" VARCHAR(500) NULL,
|
||||
"userinorg" INTEGER NOT NULL DEFAULT 0,
|
||||
"maxOrganizationalDecks" INTEGER NULL
|
||||
@@ -49,7 +49,7 @@ CREATE TABLE "Decks" (
|
||||
"cards" JSONB NOT NULL DEFAULT '[]',
|
||||
"played_number" INTEGER NOT NULL DEFAULT 0,
|
||||
"ctype" INTEGER NOT NULL DEFAULT 0,
|
||||
"update_date" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updateDate" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"state" INTEGER NOT NULL DEFAULT 0,
|
||||
"organization_id" UUID NULL
|
||||
);
|
||||
@@ -174,40 +174,6 @@ CREATE INDEX "IDX_Games_State" ON "Games" ("state");
|
||||
CREATE INDEX "IDX_Games_CreatedBy" ON "Games" ("createdBy");
|
||||
CREATE INDEX "IDX_Games_OrganizationId" ON "Games" ("organizationid");
|
||||
|
||||
-- Create update trigger for updatedate columns
|
||||
CREATE OR REPLACE FUNCTION update_updatedate_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updatedate = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
-- Apply update triggers
|
||||
CREATE TRIGGER update_users_updatedate
|
||||
BEFORE UPDATE ON "Users"
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updatedate_column();
|
||||
|
||||
CREATE TRIGGER update_organizations_updatedate
|
||||
BEFORE UPDATE ON "Organizations"
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updatedate_column();
|
||||
|
||||
CREATE TRIGGER update_decks_updatedate
|
||||
BEFORE UPDATE ON "Decks"
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updatedate_column();
|
||||
|
||||
CREATE TRIGGER update_chats_updatedate
|
||||
BEFORE UPDATE ON "Chats"
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updatedate_column();
|
||||
|
||||
CREATE TRIGGER update_contacts_updatedate
|
||||
BEFORE UPDATE ON "Contacts"
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updatedate_column();
|
||||
|
||||
CREATE TRIGGER update_games_updatedate
|
||||
BEFORE UPDATE ON "Games"
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updatedate_column();
|
||||
|
||||
-- Comments for documentation
|
||||
COMMENT ON TABLE "Users" IS 'User accounts with authentication and profile information';
|
||||
COMMENT ON TABLE "Organizations" IS 'Organizations that can have multiple users and premium features';
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# ⚡ Gyors Összefoglaló - Felesleges Adatok Tisztítás
|
||||
|
||||
## 🎯 Mi a probléma?
|
||||
|
||||
A frontend **10 felesleges mezőt** küld a backendnek minden kártya mentésekor.
|
||||
|
||||
## 📊 Számok
|
||||
|
||||
- **Felesleges deck mezők:** 1 db (`description`)
|
||||
- **Felesleges kártya mezők:** 9 db
|
||||
- **Payload csökkenés:** ~32-60%
|
||||
- **Implementációs idő:** ~3-4 óra
|
||||
|
||||
## ✅ Használt mezők (BACKEND)
|
||||
|
||||
```javascript
|
||||
{
|
||||
name: "Pakli neve",
|
||||
type: 2, // 0=LUCK, 1=JOKER, 2=QUESTION
|
||||
ctype: 1, // 0=PUBLIC, 1=PRIVATE, 2=ORGANIZATION
|
||||
cards: [
|
||||
{
|
||||
text: "Kérdés szövege",
|
||||
type: 0, // CardType enum (0-4)
|
||||
answer: "..." // TÍPUS-SPECIFIKUS formátum!
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## ❌ Felesleges mezők (TÖRLENDŐ)
|
||||
|
||||
### Deck:
|
||||
- `description` - nincs a backend sémában
|
||||
|
||||
### Kártya:
|
||||
- `id` (frontend generált) - backend UUID-t használ
|
||||
- `question` - duplikáció (`text` használandó)
|
||||
- `statement` - duplikáció (`text` használandó)
|
||||
- `options` - `answer` array-ben kell lennie
|
||||
- `correctAnswer` - `answer` array-ben kell lennie
|
||||
- `leftItems`, `rightItems`, `correctPairs` - `answer` array-ben kell lennie
|
||||
- `acceptedAnswers` - `answer` array-ként kell lennie
|
||||
- `hint` - nincs implementálva
|
||||
|
||||
## 🔄 Helyes answer formátumok
|
||||
|
||||
| Típus | answer formátum |
|
||||
|-------|----------------|
|
||||
| QUIZ (0) | `[{answer: "A", text: "...", correct: true}, ...]` |
|
||||
| PAIRING (1) | `[{left: "...", right: "..."}, ...]` |
|
||||
| OWN_ANSWER (2) | `["answer1", "answer2", ...]` |
|
||||
| TRUE_FALSE (3) | `true` vagy `false` |
|
||||
| CLOSER (4) | `{correct: 123, percent: 10}` |
|
||||
|
||||
## 🛠️ Következő lépések
|
||||
|
||||
1. ✅ Olvasd el: `FRONTEND_TO_BACKEND_DATA_CLEANUP.md`
|
||||
2. 🔧 Implementáld: `cardBackendConverter.js` utility
|
||||
3. 🔄 Módosítsd: `DeckCreator.jsx` mentés logikát
|
||||
4. ✅ Teszteld: minden kártyatípust
|
||||
|
||||
## 📁 Kapcsolódó fájlok
|
||||
|
||||
- **Részletes dokumentáció:** `FRONTEND_TO_BACKEND_DATA_CLEANUP.md`
|
||||
- **Módosítandó frontend:** `src/pages/DeckCreator/DeckCreator.jsx`
|
||||
- **Backend referencia:** `SerpentRace_Backend/src/Application/Services/CardProcessingService.ts`
|
||||
|
||||
---
|
||||
|
||||
**Gyors példa:**
|
||||
|
||||
```javascript
|
||||
// ❌ ROSSZ (jelenleg)
|
||||
{
|
||||
text: "Kérdés",
|
||||
question: "Kérdés", // Duplikáció
|
||||
options: ["A", "B", "C"], // Felesleges
|
||||
correctAnswer: 0 // Felesleges
|
||||
}
|
||||
|
||||
// ✅ JÓ (célállapot)
|
||||
{
|
||||
text: "Kérdés",
|
||||
type: 0,
|
||||
answer: [
|
||||
{answer: "A", text: "A", correct: true},
|
||||
{answer: "B", text: "B", correct: false},
|
||||
{answer: "C", text: "C", correct: false}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
📖 **Teljes dokumentáció:** Lásd `FRONTEND_TO_BACKEND_DATA_CLEANUP.md`
|
||||
@@ -0,0 +1,750 @@
|
||||
# Frontend → Backend Felesleges Adatok Dokumentáció
|
||||
|
||||
## 📋 Összefoglaló
|
||||
|
||||
Ez a dokumentum tartalmazza azokat a mezőket és adatokat, amiket a frontend küld a backendnek, de **nem szükségesek** vagy **nem használtak** a backend oldalon.
|
||||
|
||||
**🎯 Fő probléma:** A frontend sok felesleges mezőt küld, ahelyett hogy egyetlen `answer` mezőt használna típus-specifikus formátumban.
|
||||
|
||||
**💾 Adatmegtakarítás:** ~40-60% payload csökkentés várható a tisztítás után!
|
||||
|
||||
---
|
||||
|
||||
## 📊 Gyors Összefoglaló Táblázat
|
||||
|
||||
| Mező | Használat | Cselekvés |
|
||||
|------|-----------|-----------|
|
||||
| `name` | ✅ Használt | Megtartani |
|
||||
| `type` | ✅ Használt | Megtartani |
|
||||
| `ctype` | ✅ Használt | Megtartani |
|
||||
| `cards` | ✅ Használt | Megtartani |
|
||||
| `description` | ❌ **Nincs a DB-ben** | **TÖRÖLNI** |
|
||||
| | | |
|
||||
| **Kártya mezők:** | | |
|
||||
| `card.text` | ✅ Használt | Megtartani |
|
||||
| `card.type` | ✅ Használt | Megtartani |
|
||||
| `card.answer` | ✅ Használt | Megtartani (típus-specifikus!) |
|
||||
| `card.consequence` | ✅ Használt (LUCK) | Megtartani |
|
||||
| | | |
|
||||
| `card.id` (frontend) | ❌ Nem releváns | **NE KÜLDJÜK** |
|
||||
| `card.question` | ❌ Duplikáció | **TÖRÖLNI** (text-be) |
|
||||
| `card.statement` | ❌ Duplikáció | **TÖRÖLNI** (text-be) |
|
||||
| `card.options` | ❌ Felesleges | **KONVERTÁLNI** (answer-be) |
|
||||
| `card.correctAnswer` | ❌ Felesleges | **KONVERTÁLNI** (answer-be) |
|
||||
| `card.leftItems` | ❌ Felesleges | **KONVERTÁLNI** (answer-be) |
|
||||
| `card.rightItems` | ❌ Felesleges | **KONVERTÁLNI** (answer-be) |
|
||||
| `card.correctPairs` | ❌ Felesleges | **KONVERTÁLNI** (answer-be) |
|
||||
| `card.acceptedAnswers` | ❌ Felesleges | **KONVERTÁLNI** (answer-be) |
|
||||
| `card.hint` | ❌ Nincs implementálva | **TÖRÖLNI** |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Deck Létrehozás/Frissítés (createDeck / updateDeck)
|
||||
|
||||
### Backend által HASZNÁLT mezők:
|
||||
|
||||
```typescript
|
||||
// CreateDeckCommand / UpdateDeckCommand
|
||||
{
|
||||
name: string, // ✅ HASZNÁLT - Pakli neve
|
||||
type: number, // ✅ HASZNÁLT - 0=LUCK, 1=JOKER, 2=QUESTION
|
||||
userid: string, // ✅ HASZNÁLT - Automatikusan hozzáadódik az authRequired middleware-ből
|
||||
cards: any[], // ✅ HASZNÁLT - Kártyák tömbje
|
||||
ctype?: number, // ✅ HASZNÁLT - 0=PUBLIC, 1=PRIVATE, 2=ORGANIZATION
|
||||
state?: number, // ✅ HASZNÁLT - De csak admin állíthatja (0=ACTIVE, 1=SOFT_DELETE)
|
||||
authLevel: number // ✅ HASZNÁLT - Automatikusan jön az auth middleware-ből
|
||||
}
|
||||
```
|
||||
|
||||
### Frontend által KÜLDÖTT de FELESLEGES mezők:
|
||||
|
||||
#### 1. **`description` mező** - ❌ NEM HASZNÁLT
|
||||
**Helyek:** `DeckCreator.jsx` (line ~100-110, ~170)
|
||||
|
||||
```javascript
|
||||
// FELESLEGES - Backend nem tárolja, nem használja
|
||||
const payload = {
|
||||
name: deck.name?.trim() || "Névtelen pakli",
|
||||
type: typeMapping[deck.type] ?? 2,
|
||||
ctype: ctypeMapping[deck.privacy] ?? 1,
|
||||
cards: cleanedCards
|
||||
// description: deck.description // ❌ Ez NINCS a backend sémában!
|
||||
}
|
||||
```
|
||||
|
||||
**Megjegyzés a kódban (line ~171):**
|
||||
```javascript
|
||||
// Note: description field is not sent to backend as it's not supported yet
|
||||
```
|
||||
|
||||
**Javaslat:**
|
||||
- Ha a `description` soha nem lesz használva → töröljük a frontend state-ből
|
||||
- Ha később implementálni fogjuk → adjuk hozzá a backend DeckAggregate entitáshoz először
|
||||
|
||||
---
|
||||
|
||||
## 📇 Kártya Mezők (cards array)
|
||||
|
||||
### Backend Card Interface:
|
||||
|
||||
```typescript
|
||||
export interface Card {
|
||||
text: string; // ✅ KÖTELEZŐ
|
||||
type?: CardType; // ✅ OPCIONÁLIS - 0=QUIZ, 1=PAIRING, 2=OWN_ANSWER, 3=TRUE_FALSE, 4=CLOSER
|
||||
answer?: string | null; // ✅ OPCIONÁLIS
|
||||
consequence?: Consequence | null; // ✅ OPCIONÁLIS (csak LUCK kártyáknál)
|
||||
}
|
||||
```
|
||||
|
||||
### Frontend által KÜLDÖTT de ESETLEG FELESLEGES kártya mezők:
|
||||
|
||||
#### A. **Duplikált mezők** (ugyanaz az adat több néven):
|
||||
|
||||
```javascript
|
||||
// DeckCreator.jsx - cleanedCards mapping (line ~130-165)
|
||||
|
||||
// 1. TEXT mező duplikáció - ⚠️ REDUNDÁNS
|
||||
cleanedCard.text = card.text || card.question || card.statement || ""
|
||||
if (card.question !== undefined) cleanedCard.question = card.question // ❌ Felesleges?
|
||||
if (card.statement !== undefined) cleanedCard.statement = card.statement // ❌ Felesleges?
|
||||
|
||||
// Backend csak a `text` mezőt használja!
|
||||
// A `question` és `statement` valószínűleg NEM SZÜKSÉGESEK
|
||||
```
|
||||
|
||||
**Megjegyzés:** A backend `Card` interfészben **nincs** `question` vagy `statement` mező, csak `text`.
|
||||
|
||||
#### B. **QUESTION típusú kártyák extra mezői** - ⚠️ ELLENŐRIZENDŐ
|
||||
|
||||
```javascript
|
||||
// Ezek a mezők a DeckCreator.jsx-ben kerülnek hozzáadásra (line ~145-155)
|
||||
if (card.question !== undefined) cleanedCard.question = card.question
|
||||
if (card.statement !== undefined) cleanedCard.statement = card.statement
|
||||
if (card.options !== undefined) cleanedCard.options = card.options
|
||||
if (card.correctAnswer !== undefined) cleanedCard.correctAnswer = card.correctAnswer
|
||||
if (card.leftItems !== undefined) cleanedCard.leftItems = card.leftItems
|
||||
if (card.rightItems !== undefined) cleanedCard.rightItems = card.rightItems
|
||||
if (card.correctPairs !== undefined) cleanedCard.correctPairs = card.correctPairs
|
||||
if (card.acceptedAnswers !== undefined) cleanedCard.acceptedAnswers = card.acceptedAnswers
|
||||
if (card.hint !== undefined) cleanedCard.hint = card.hint
|
||||
```
|
||||
|
||||
**Backend Card interfész ezeket NEM tartalmazza:**
|
||||
- ❌ `question` - Nincs a Card interface-ben
|
||||
- ❌ `statement` - Nincs a Card interface-ben
|
||||
- ❌ `options` - Nincs a Card interface-ben
|
||||
- ❌ `correctAnswer` - Nincs a Card interface-ben
|
||||
- ❌ `leftItems` - Nincs a Card interface-ben
|
||||
- ❌ `rightItems` - Nincs a Card interface-ben
|
||||
- ❌ `correctPairs` - Nincs a Card interface-ben
|
||||
- ❌ `acceptedAnswers` - Nincs a Card interface-ben
|
||||
- ❌ `hint` - Nincs a Card interface-ben
|
||||
|
||||
**KRITIKUS KÉRDÉS:**
|
||||
- Ezek a mezők **JSON-ként tárolódnak** a `cards` mezőben?
|
||||
- A backend TypeORM `@Column({ type: 'json' })` deklaráció miatt bármit el tud tárolni
|
||||
- De a **Card interface** szerint csak `text`, `type`, `answer`, `consequence` mezőket használ
|
||||
|
||||
**Két lehetséges eset:**
|
||||
|
||||
1. **Ha a backend JSON mezőként tárolja de nem használja ezeket:**
|
||||
- ❌ FELESLEGESEK - Adatbázis helyet pazarolnak
|
||||
- Javaslat: Tisztítsuk meg a frontend-et, ne küldje őket
|
||||
|
||||
2. **Ha a backend valahol mégis használja (pl. game logic-ban):**
|
||||
- ✅ SZÜKSÉGESEK - De akkor frissíteni kell a Card interface-t
|
||||
|
||||
---
|
||||
|
||||
## 🎮 Consequence mező - ✅ RENDBEN (de típus ellenőrzés szükséges)
|
||||
|
||||
```javascript
|
||||
// DeckCreator.jsx (line ~160-162)
|
||||
if (deck.type === 'LUCK' && card.consequence) {
|
||||
cleanedCard.consequence = card.consequence
|
||||
}
|
||||
```
|
||||
|
||||
**Backend Consequence interface:**
|
||||
```typescript
|
||||
export interface Consequence {
|
||||
type: ConsequenceType; // 0-5 közötti szám
|
||||
value?: number;
|
||||
}
|
||||
```
|
||||
|
||||
**Javaslat:** Ellenőrizni kell hogy a frontend mindig valid `ConsequenceType` enum értéket küld-e (0-5).
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Részletes Backend vs Frontend Mapping
|
||||
|
||||
### Deck Level
|
||||
|
||||
| Frontend Mező | Backend Mező | Használat | Megjegyzés |
|
||||
|--------------|-------------|----------|-----------|
|
||||
| `deck.id` | `id` | ✅ Használt | UUID |
|
||||
| `deck.name` | `name` | ✅ Használt | string (max 255) |
|
||||
| `deck.type` | `type` | ✅ Használt | 0/1/2 (enum) |
|
||||
| `deck.privacy` | `ctype` | ✅ Használt | 0/1/2 (enum) |
|
||||
| `deck.description` | - | ❌ **NEM LÉTEZIK** | **FELESLEGES** |
|
||||
| `deck.cards` | `cards` | ✅ Használt | JSON array |
|
||||
| `deck.creationdate` | `creationdate` | ✅ Használt | Date (readonly) |
|
||||
| `deck.updatedate` | `updateDate` | ✅ Használt | Date (readonly) |
|
||||
|
||||
### Card Level (QUESTION típusú kártyák)
|
||||
|
||||
| Frontend Mező | Backend Card Interface | Használat | Megjegyzés |
|
||||
|--------------|----------------------|----------|-----------|
|
||||
| `card.id` | - | ❌ **Lokális azonosító** | Csak frontend-en, backenden nem releváns |
|
||||
| `card.text` | `text` | ✅ Használt | Fő szöveg |
|
||||
| `card.question` | - | ❓ **Ellenőrizendő** | Lehet felesleges (text duplikáció?) |
|
||||
| `card.statement` | - | ❓ **Ellenőrizendő** | Lehet felesleges (text duplikáció?) |
|
||||
| `card.type` / `card.subType` | `type` | ✅ Használt | CardType enum (0-4) |
|
||||
| `card.answer` | `answer` | ✅ Használt | String vagy null |
|
||||
| `card.options` | - | ❓ **Ellenőrizendő** | Nincs a Card interface-ben |
|
||||
| `card.correctAnswer` | - | ❓ **Ellenőrizendő** | Nincs a Card interface-ben |
|
||||
| `card.leftItems` | - | ❓ **Ellenőrizendő** | Nincs a Card interface-ben |
|
||||
| `card.rightItems` | - | ❓ **Ellenőrizendő** | Nincs a Card interface-ben |
|
||||
| `card.correctPairs` | - | ❓ **Ellenőrizendő** | Nincs a Card interface-ben |
|
||||
| `card.acceptedAnswers` | - | ❓ **Ellenőrizendő** | Nincs a Card interface-ben |
|
||||
| `card.hint` | - | ❓ **Ellenőrizendő** | Nincs a Card interface-ben |
|
||||
| `card.consequence` | `consequence` | ✅ Használt | Csak LUCK típusnál |
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ BACKEND GAME LOGIC VIZSGÁLAT - ✅ KÉSZ
|
||||
|
||||
### 1. Kártya mezők tényleges használata - ELLENŐRIZVE ✅
|
||||
|
||||
**Ellenőrzött fájlok:**
|
||||
- ✅ `SerpentRace_Backend/src/Application/Services/CardProcessingService.ts`
|
||||
- ✅ `SerpentRace_Backend/src/Application/Services/CardDrawingService.ts`
|
||||
|
||||
**EREDMÉNY: A backend CSAK az `answer` mezőt használja!**
|
||||
|
||||
**Backend Card használat:**
|
||||
```typescript
|
||||
export interface Card {
|
||||
text: string; // ✅ Kérdés szövege
|
||||
type?: CardType; // ✅ Kártya típus (0-4)
|
||||
answer?: string | null; // ✅ EGYETLEN valid mező a válaszokhoz!
|
||||
consequence?: Consequence | null; // ✅ Csak LUCK kártyákhoz
|
||||
}
|
||||
```
|
||||
|
||||
**Fontos:** A backend `answer` mező **típus-specifikus formátumú**:
|
||||
|
||||
1. **QUIZ (type: 0)** → `answer` = `QuizOption[]` array:
|
||||
```typescript
|
||||
answer: [
|
||||
{ answer: "A", text: "First option", correct: false },
|
||||
{ answer: "B", text: "Second option", correct: true },
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
2. **SENTENCE_PAIRING (type: 1)** → `answer` = Párosítás array:
|
||||
```typescript
|
||||
answer: [
|
||||
{ left: "Apple", right: "Red" },
|
||||
{ left: "Banana", right: "Yellow" }
|
||||
]
|
||||
```
|
||||
|
||||
3. **OWN_ANSWER (type: 2)** → `answer` = String vagy String array:
|
||||
```typescript
|
||||
answer: ["correct answer 1", "correct answer 2"]
|
||||
```
|
||||
|
||||
4. **TRUE_FALSE (type: 3)** → `answer` = Boolean:
|
||||
```typescript
|
||||
answer: true // vagy false
|
||||
```
|
||||
|
||||
5. **CLOSER (type: 4)** → `answer` = Object:
|
||||
```typescript
|
||||
answer: { correct: 42, percent: 10 }
|
||||
```
|
||||
|
||||
**KÖVETKEZTETÉS:**
|
||||
- ❌ **A frontend által küldött `options`, `correctAnswer`, `acceptedAnswers`, `leftItems`, `rightItems`, `correctPairs` mezők MIND FELESLEGESEK!**
|
||||
- ✅ **Csak az `answer` mezőt kellene küldeni, megfelelő formátumban!**
|
||||
|
||||
### 2. Card Type Mapping
|
||||
**Frontend:**
|
||||
```javascript
|
||||
const cardTypeMapping = {
|
||||
'quiz': 0, // QUIZ
|
||||
'pairing': 1, // SENTENCE_PAIRING
|
||||
'text': 2, // OWN_ANSWER
|
||||
'truefalse': 3, // TRUE_FALSE
|
||||
'closer': 4 // CLOSER
|
||||
}
|
||||
```
|
||||
|
||||
**Backend CardType enum:**
|
||||
```typescript
|
||||
export enum CardType {
|
||||
QUIZ = 0,
|
||||
SENTENCE_PAIRING = 1,
|
||||
OWN_ANSWER = 2,
|
||||
TRUE_FALSE = 3,
|
||||
CLOSER = 4
|
||||
}
|
||||
```
|
||||
|
||||
✅ **Ez HELYES** - A mapping megfelelő
|
||||
|
||||
### 3. Frontend kártya ID kezelés
|
||||
```javascript
|
||||
// DeckCreator.jsx (line ~242)
|
||||
const updatedCard = {
|
||||
...cardData,
|
||||
id: isCreatingCard ? Date.now() : cardData.id
|
||||
}
|
||||
|
||||
// Line ~129
|
||||
if (card.id) {
|
||||
cleanedCard.id = card.id
|
||||
}
|
||||
```
|
||||
|
||||
**Probléma:** A frontend `Date.now()` timestamp ID-kat generál, de a backend UUID-kat használ.
|
||||
|
||||
**Javaslat:**
|
||||
- ❌ NE küldjük a frontend-generált `id`-t a backendnek
|
||||
- A backend a create során generál UUID-t
|
||||
- Update-nél a backend már ismeri az ID-t (URL parameter-ből jön)
|
||||
|
||||
---
|
||||
|
||||
## 📝 JAVASOLT TISZTÍTÁSOK
|
||||
|
||||
### Prioritás 1: BIZTOS FELESLEGESEK
|
||||
|
||||
1. **`description` mező törlése**
|
||||
- Fájl: `DeckCreator.jsx`
|
||||
- Sorok: ~20, ~40-45, ~100-105
|
||||
- Töröljük a state-ből és ne küldjük a backendnek
|
||||
|
||||
2. **Frontend-generált kártya `id` ne menjen a backendre**
|
||||
- Fájl: `DeckCreator.jsx`
|
||||
- Sor: ~129
|
||||
- Kommenteljük ki vagy töröljük: `if (card.id) cleanedCard.id = card.id`
|
||||
|
||||
### Prioritás 2: BIZONYÍTOTTAN FELESLEGESEK ✅
|
||||
|
||||
3. **Duplikált text mezők (`question`, `statement`)** - ❌ FELESLEGES
|
||||
- A backend **csak `text`-et használ**
|
||||
- Töröljük: `question` és `statement` mezők küldését
|
||||
|
||||
4. **QUESTION kártya részletes mezők - MIND FELESLEGESEK ❌**
|
||||
- A backend GameService **NEM használja** ezeket:
|
||||
- ❌ `options` - Felesleges (backend: `answer` array használ)
|
||||
- ❌ `correctAnswer` - Felesleges (backend: `answer` array-ben `correct: true`)
|
||||
- ❌ `leftItems` / `rightItems` / `correctPairs` - Felesleges (backend: `answer` array-ben `{left, right}` párok)
|
||||
- ❌ `acceptedAnswers` - Felesleges (backend: `answer` string array)
|
||||
- ❌ `hint` - Nincs implementálva a backenden
|
||||
|
||||
**HELYETTE:** Konvertáljuk ezeket megfelelő `answer` formátumra!
|
||||
|
||||
---
|
||||
|
||||
## 🔄 HELYES KONVERZIÓ - Példák
|
||||
|
||||
### Jelenlegi (FELESLEGES mezőkkel):
|
||||
|
||||
```javascript
|
||||
// ❌ ROSSZ - Felesleges mezők küldése
|
||||
const cleanedCard = {
|
||||
text: "Mi a főváros?",
|
||||
type: 0, // QUIZ
|
||||
question: "Mi a főváros?", // ❌ DUPLIKÁCIÓ
|
||||
options: ["Budapest", "Berlin", "Prága"], // ❌ FELESLEGES
|
||||
correctAnswer: 0 // ❌ FELESLEGES
|
||||
}
|
||||
```
|
||||
|
||||
### Helyes (Optimalizált):
|
||||
|
||||
```javascript
|
||||
// ✅ JÓ - Csak szükséges mezők
|
||||
const cleanedCard = {
|
||||
text: "Mi a főváros?",
|
||||
type: 0, // QUIZ
|
||||
answer: [
|
||||
{ answer: "A", text: "Budapest", correct: true },
|
||||
{ answer: "B", text: "Berlin", correct: false },
|
||||
{ answer: "C", text: "Prága", correct: false }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Konverziós Példák Típusonként:
|
||||
|
||||
#### 1. QUIZ (type: 0) - Feleletválasztós
|
||||
|
||||
**Frontend állapot:**
|
||||
```javascript
|
||||
card = {
|
||||
subType: 'multiplechoice',
|
||||
question: "Melyik a helyes?",
|
||||
options: ["A válasz", "B válasz", "C válasz"],
|
||||
correctAnswer: 1
|
||||
}
|
||||
```
|
||||
|
||||
**Helyes backend formátum:**
|
||||
```javascript
|
||||
cleanedCard = {
|
||||
text: "Melyik a helyes?",
|
||||
type: 0,
|
||||
answer: [
|
||||
{ answer: "A", text: "A válasz", correct: false },
|
||||
{ answer: "B", text: "B válasz", correct: true }, // correctAnswer: 1
|
||||
{ answer: "C", text: "C válasz", correct: false }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. SENTENCE_PAIRING (type: 1) - Párosítás
|
||||
|
||||
**Frontend állapot:**
|
||||
```javascript
|
||||
card = {
|
||||
subType: 'matching',
|
||||
question: "Párosítsd össze!",
|
||||
leftItems: ["Alma", "Banán"],
|
||||
rightItems: ["Piros", "Sárga"],
|
||||
correctPairs: { 0: 0, 1: 1 } // leftItems[0] -> rightItems[0]
|
||||
}
|
||||
```
|
||||
|
||||
**Helyes backend formátum:**
|
||||
```javascript
|
||||
cleanedCard = {
|
||||
text: "Párosítsd össze!",
|
||||
type: 1,
|
||||
answer: [
|
||||
{ left: "Alma", right: "Piros" },
|
||||
{ left: "Banán", right: "Sárga" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. OWN_ANSWER (type: 2) - Szöveges válasz
|
||||
|
||||
**Frontend állapot:**
|
||||
```javascript
|
||||
card = {
|
||||
subType: 'text',
|
||||
question: "Mi a főváros?",
|
||||
acceptedAnswers: ["Budapest", "budapest", "Bp"]
|
||||
}
|
||||
```
|
||||
|
||||
**Helyes backend formátum:**
|
||||
```javascript
|
||||
cleanedCard = {
|
||||
text: "Mi a főváros?",
|
||||
type: 2,
|
||||
answer: ["Budapest", "budapest", "Bp"]
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. TRUE_FALSE (type: 3) - Igaz/Hamis
|
||||
|
||||
**Frontend állapot:**
|
||||
```javascript
|
||||
card = {
|
||||
subType: 'truefalse',
|
||||
statement: "A Föld lapos.",
|
||||
correctAnswer: 1, // 0=Igaz, 1=Hamis
|
||||
isTrue: false
|
||||
}
|
||||
```
|
||||
|
||||
**Helyes backend formátum:**
|
||||
```javascript
|
||||
cleanedCard = {
|
||||
text: "A Föld lapos.",
|
||||
type: 3,
|
||||
answer: false
|
||||
}
|
||||
```
|
||||
|
||||
#### 5. CLOSER (type: 4) - Tippelés
|
||||
|
||||
**Frontend állapot:**
|
||||
```javascript
|
||||
card = {
|
||||
subType: 'closer',
|
||||
question: "Hány lakosa van Budapestnek?",
|
||||
correctAnswer: 1750000,
|
||||
tolerance: 10 // ±10%
|
||||
}
|
||||
```
|
||||
|
||||
**Helyes backend formátum:**
|
||||
```javascript
|
||||
cleanedCard = {
|
||||
text: "Hány lakosa van Budapestnek?",
|
||||
type: 4,
|
||||
answer: {
|
||||
correct: 1750000,
|
||||
percent: 10
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 TESZTELÉSI TERV
|
||||
|
||||
1. **Logolás hozzáadása a backenden:**
|
||||
```typescript
|
||||
// CreateDeckCommandHandler.ts, UpdateDeckCommandHandler.ts
|
||||
console.log('Received card data:', cmd.cards)
|
||||
console.log('Card keys:', Object.keys(cmd.cards[0]))
|
||||
```
|
||||
|
||||
2. **Frontendről küldött payload ellenőrzése:**
|
||||
```javascript
|
||||
// DeckCreator.jsx - handleSaveDeck
|
||||
console.log('Payload before send:', JSON.stringify(payload, null, 2))
|
||||
```
|
||||
|
||||
3. **Adatbázisban tárolt JSON ellenőrzése:**
|
||||
```sql
|
||||
SELECT id, name, cards FROM Decks WHERE id = 'xyz' LIMIT 1;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ KÖVETKEZŐ LÉPÉSEK
|
||||
|
||||
1. ✅ **Dokumentáció elkészült** - Ez a fájl
|
||||
2. ✅ **Backend game logic ellenőrzés** - KÉSZ! Csak `answer` mezőt használ
|
||||
3. ⏳ **Frontend konverzió implementálás** - Következő feladat:
|
||||
- Új függvény: `convertCardToBackendFormat(card, deckType)`
|
||||
- Minden kártyatípushoz megfelelő `answer` formátum generálása
|
||||
- Felesleges mezők eltávolítása
|
||||
4. ⏳ **Tesztelés** - Minden működik-e a változások után?
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ IMPLEMENTÁCIÓS TERV
|
||||
|
||||
### 1. Létrehozandó segédfüggvény: `cardBackendConverter.js`
|
||||
|
||||
```javascript
|
||||
// src/utils/cardBackendConverter.js
|
||||
|
||||
/**
|
||||
* Konvertálja a frontend kártya formátumot backend-kompatibilis formátumra
|
||||
* @param {Object} card - Frontend kártya objektum
|
||||
* @param {string} deckType - Pakli típusa ('LUCK', 'JOKER', 'QUESTION')
|
||||
* @returns {Object} Backend-kompatibilis kártya objektum
|
||||
*/
|
||||
export function convertCardToBackendFormat(card, deckType) {
|
||||
const baseCard = {
|
||||
text: card.text || card.question || card.statement || "",
|
||||
}
|
||||
|
||||
// CardType mapping
|
||||
const cardTypeMapping = {
|
||||
'quiz': 0,
|
||||
'multiplechoice': 0, // Alias
|
||||
'pairing': 1,
|
||||
'matching': 1, // Alias
|
||||
'text': 2,
|
||||
'truefalse': 3,
|
||||
'closer': 4
|
||||
}
|
||||
|
||||
const cardType = cardTypeMapping[card.subType] ?? cardTypeMapping[card.subType?.toLowerCase()]
|
||||
if (cardType !== undefined) {
|
||||
baseCard.type = cardType
|
||||
}
|
||||
|
||||
// Típus-specifikus answer konverzió
|
||||
switch (cardType) {
|
||||
case 0: // QUIZ
|
||||
if (card.options && Array.isArray(card.options)) {
|
||||
baseCard.answer = card.options.map((opt, idx) => ({
|
||||
answer: String.fromCharCode(65 + idx), // A, B, C, D...
|
||||
text: opt,
|
||||
correct: idx === card.correctAnswer
|
||||
}))
|
||||
}
|
||||
break
|
||||
|
||||
case 1: // SENTENCE_PAIRING
|
||||
if (card.leftItems && card.rightItems && card.correctPairs) {
|
||||
baseCard.answer = Object.entries(card.correctPairs).map(([leftIdx, rightIdx]) => ({
|
||||
left: card.leftItems[parseInt(leftIdx)],
|
||||
right: card.rightItems[parseInt(rightIdx)]
|
||||
}))
|
||||
}
|
||||
break
|
||||
|
||||
case 2: // OWN_ANSWER
|
||||
if (card.acceptedAnswers && Array.isArray(card.acceptedAnswers)) {
|
||||
baseCard.answer = card.acceptedAnswers.filter(a => a && a.trim())
|
||||
}
|
||||
break
|
||||
|
||||
case 3: // TRUE_FALSE
|
||||
if (card.correctAnswer !== undefined) {
|
||||
baseCard.answer = card.correctAnswer === 0 // 0=Igaz, 1=Hamis
|
||||
} else if (card.isTrue !== undefined) {
|
||||
baseCard.answer = card.isTrue
|
||||
}
|
||||
break
|
||||
|
||||
case 4: // CLOSER
|
||||
if (card.correctAnswer !== undefined && card.tolerance !== undefined) {
|
||||
baseCard.answer = {
|
||||
correct: card.correctAnswer,
|
||||
percent: card.tolerance
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// LUCK típusú kártyákhoz consequence
|
||||
if (deckType === 'LUCK' && card.consequence) {
|
||||
baseCard.consequence = card.consequence
|
||||
}
|
||||
|
||||
return baseCard
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Módosítandó fájl: `DeckCreator.jsx`
|
||||
|
||||
**Jelenlegi kód (line ~120-165):**
|
||||
```javascript
|
||||
// ❌ RÉGI - Felesleges mezők küldése
|
||||
const cleanedCards = validCards.map(card => {
|
||||
const cleanedCard = {}
|
||||
if (card.id) cleanedCard.id = card.id
|
||||
if (card.subType && cardTypeMapping[card.subType] !== undefined) {
|
||||
cleanedCard.type = cardTypeMapping[card.subType]
|
||||
}
|
||||
cleanedCard.text = card.text || card.question || card.statement || ""
|
||||
if (card.question !== undefined) cleanedCard.question = card.question // FELESLEGES
|
||||
if (card.statement !== undefined) cleanedCard.statement = card.statement // FELESLEGES
|
||||
if (card.options !== undefined) cleanedCard.options = card.options // FELESLEGES
|
||||
// ... stb
|
||||
return cleanedCard
|
||||
})
|
||||
```
|
||||
|
||||
**Új kód:**
|
||||
```javascript
|
||||
// ✅ ÚJ - Csak szükséges mezők
|
||||
import { convertCardToBackendFormat } from '../../utils/cardBackendConverter'
|
||||
|
||||
const cleanedCards = validCards.map(card =>
|
||||
convertCardToBackendFormat(card, deck.type)
|
||||
)
|
||||
```
|
||||
|
||||
### 3. Tesztelési checklist
|
||||
|
||||
- [ ] QUIZ kártyák helyes answer formátummal mentődnek
|
||||
- [ ] SENTENCE_PAIRING kártyák helyes left-right párokkal mentődnek
|
||||
- [ ] OWN_ANSWER kártyák acceptedAnswers array-ként mentődnek
|
||||
- [ ] TRUE_FALSE kártyák boolean answer-rel mentődnek
|
||||
- [ ] CLOSER kártyák {correct, percent} formátummal mentődnek
|
||||
- [ ] LUCK kártyák consequence mezője megmarad
|
||||
- [ ] Mentett paklik betöltése és szerkesztése működik
|
||||
- [ ] Játék során kártyák feldolgozása helyes
|
||||
|
||||
---
|
||||
|
||||
**Utolsó frissítés:** 2025-11-03
|
||||
**Készítette:** GitHub Copilot
|
||||
**Cél:** Adatoptimalizálás és felesleges payload csökkentés
|
||||
|
||||
---
|
||||
|
||||
## 📈 VÁRHATÓ EREDMÉNYEK
|
||||
|
||||
### Payload méret csökkenés példa:
|
||||
|
||||
**ELŐTTE (jelenleg):**
|
||||
```json
|
||||
{
|
||||
"name": "Teszt Pakli",
|
||||
"type": 2,
|
||||
"ctype": 1,
|
||||
"description": "Ez egy leírás", // ❌ FELESLEGES
|
||||
"cards": [
|
||||
{
|
||||
"id": 1730123456789, // ❌ FELESLEGES
|
||||
"text": "Mi a főváros?",
|
||||
"question": "Mi a főváros?", // ❌ DUPLIKÁCIÓ
|
||||
"type": 0,
|
||||
"options": ["Budapest", "Berlin", "Prága"], // ❌ FELESLEGES
|
||||
"correctAnswer": 0 // ❌ FELESLEGES
|
||||
}
|
||||
]
|
||||
}
|
||||
// Méret: ~280 byte
|
||||
```
|
||||
|
||||
**UTÁNA (optimalizált):**
|
||||
```json
|
||||
{
|
||||
"name": "Teszt Pakli",
|
||||
"type": 2,
|
||||
"ctype": 1,
|
||||
"cards": [
|
||||
{
|
||||
"text": "Mi a főváros?",
|
||||
"type": 0,
|
||||
"answer": [
|
||||
{"answer": "A", "text": "Budapest", "correct": true},
|
||||
{"answer": "B", "text": "Berlin", "correct": false},
|
||||
{"answer": "C", "text": "Prága", "correct": false}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
// Méret: ~190 byte
|
||||
```
|
||||
|
||||
**💾 Megtakarítás: ~32% ebben a példában!**
|
||||
|
||||
---
|
||||
|
||||
## 🎉 VÉGSŐ ÖSSZEFOGLALÁS
|
||||
|
||||
### Felesleges mezők száma:
|
||||
- **Deck level:** 1 mező (`description`)
|
||||
- **Card level:** 9 mező (`id`, `question`, `statement`, `options`, `correctAnswer`, `leftItems`, `rightItems`, `correctPairs`, `acceptedAnswers`, `hint`)
|
||||
|
||||
### Összes felesleges mező: **10 db**
|
||||
|
||||
### Ajánlott lépések:
|
||||
1. ✅ Dokumentáció áttekintése
|
||||
2. 🔄 `cardBackendConverter.js` implementálása
|
||||
3. 🔧 `DeckCreator.jsx` módosítása
|
||||
4. ✅ Tesztelés minden kártyatípussal
|
||||
5. 🚀 Deploy
|
||||
|
||||
**Becsült munkaidő:** 2-3 óra implementálás + 1 óra tesztelés
|
||||
|
||||
---
|
||||
|
||||
## 📞 Kérdések / Problémák esetén
|
||||
|
||||
Ha bármilyen kérdés merül fel az implementálás során:
|
||||
1. Ellenőrizd a backend `CardProcessingService.ts` fájlt
|
||||
2. Nézd meg a példákat ebben a dokumentációban
|
||||
3. Teszteld lokálisan először egy kis paklival
|
||||
|
||||
**Fontos:** A backend JSON mezőként tárolja a `cards` array-t, ezért bármit elfogad - de csak a dokumentált mezőket használja!
|
||||
@@ -1,10 +1,10 @@
|
||||
<!doctype html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/src/assets/pictures/Logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React</title>
|
||||
<title>SerpentRace</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+303
@@ -9,11 +9,13 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
"axios": "^1.12.2",
|
||||
"framer-motion": "^12.19.1",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-router-dom": "^7.6.0",
|
||||
"react-toastify": "^11.0.5",
|
||||
"tailwindcss": "^4.1.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -1678,6 +1680,23 @@
|
||||
"dev": true,
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
|
||||
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
@@ -1728,6 +1747,19 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/callsites": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||
@@ -1785,6 +1817,15 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -1805,6 +1846,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
@@ -1865,6 +1918,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
|
||||
@@ -1874,6 +1936,20 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.155",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.155.tgz",
|
||||
@@ -1894,6 +1970,51 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz",
|
||||
@@ -2216,6 +2337,42 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
|
||||
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "12.19.1",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.19.1.tgz",
|
||||
@@ -2257,6 +2414,15 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/gensync": {
|
||||
"version": "1.0.0-beta.2",
|
||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||
@@ -2267,6 +2433,43 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/glob-parent": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
@@ -2293,6 +2496,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
@@ -2309,6 +2524,45 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@@ -2745,6 +2999,36 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
@@ -2986,6 +3270,12 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@@ -3083,6 +3373,19 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/react-toastify": {
|
||||
"version": "11.0.5",
|
||||
"resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-11.0.5.tgz",
|
||||
"integrity": "sha512-EpqHBGvnSTtHYhCPLxML05NLY2ZX0JURbAdNYa6BUkk+amz4wbKBQvoKQAB0ardvSarUBuY4Q4s1sluAzZwkmA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19",
|
||||
"react-dom": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-from": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||
|
||||
@@ -11,22 +11,24 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
"axios": "^1.12.2",
|
||||
"framer-motion": "^12.19.1",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-router-dom": "^7.6.0",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"vite": "^6.3.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.19"
|
||||
"react-toastify": "^11.0.5",
|
||||
"tailwindcss": "^4.1.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.25.0",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"eslint": "^9.25.0",
|
||||
"globals": "^16.0.0"
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^16.0.0",
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,17 +2,23 @@ import { useState, useEffect } from "react"
|
||||
import { BrowserRouter as Router, Route, Routes } from "react-router-dom"
|
||||
import AuthRegister from "./pages/Auth/AuthRegister"
|
||||
import AuthLogin from "./pages/Auth/AuthLogin"
|
||||
import EmailVerification from "./pages/Auth/EmailVerification"
|
||||
import Test from "./pages/Testing/Test"
|
||||
import ForgotPassword from "./pages/Auth/ForgotPassword"
|
||||
import ResetPassword from "./pages/Auth/ResetPassword"
|
||||
import Landingpage from "./pages/Landing/Landingpage"
|
||||
import Home from "./pages/Landing/Home"
|
||||
import DeckManagerPage from "./pages/Decks/DeckManagerPage"
|
||||
import CompanyHub from "./pages/Companies/Companies"
|
||||
import Card_display from "./pages/Decks/Card_display"
|
||||
import DeckCreator from "./pages/DeckCreator/DeckCreator"
|
||||
import CompanyHub from "./pages/Contacts/Contacts"
|
||||
import About from "./pages/About/About"
|
||||
import ScrollToTop from "./components/ScrollToTop"
|
||||
import GameScreen from "./pages/Game/GameScreen"
|
||||
import Reports from "./pages/Report/Reports"
|
||||
import Lobby from "./pages/Lobby/Lobby"
|
||||
import ProfileCard from "./components/Userdetails/Userdetails"
|
||||
import { ToastConfig } from "./components/Toastify/toastifyServices" // ✅ fontos: named import, nem default!
|
||||
import VerifyEmailPage from "./pages/Auth/VerifyEmailPage"
|
||||
|
||||
function App() {
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
@@ -41,24 +47,33 @@ function App() {
|
||||
// }
|
||||
|
||||
return (
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/about" element={<About />} />
|
||||
<Route path="/register" element={<AuthRegister />} />
|
||||
<Route path="/login" element={<AuthLogin />} />
|
||||
<Route path="/verify-email" element={<EmailVerification />} />
|
||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||
<Route path="/reset-password" element={<ResetPassword />} />
|
||||
<Route path="/test" element={<Test />} />
|
||||
<Route path="/" element={<Landingpage />} />
|
||||
<Route path="/home" element={<Home />} />
|
||||
<Route path="/decks" element={<DeckManagerPage />} />
|
||||
<Route path="/game" element={<GameScreen />} />
|
||||
<Route path="/companies" element={<CompanyHub />} />
|
||||
<>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/verify-email" element={<VerifyEmailPage />} />
|
||||
<Route path="/about" element={<About />} />
|
||||
<Route path="/lobby" element={<Lobby />} />
|
||||
<Route path="/register" element={<AuthRegister />} />
|
||||
<Route path="/login" element={<AuthLogin />} />
|
||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||
<Route path="/reset-password" element={<ResetPassword />} />
|
||||
<Route path="/profile" element={<ProfileCard />} />
|
||||
<Route path="/test" element={<Test />} />
|
||||
<Route path="/" element={<Landingpage />} />
|
||||
<Route path="/home" element={<Home />} />
|
||||
<Route path="/decks" element={<DeckManagerPage />} />
|
||||
<Route path="/deck/:deckId" element={<Card_display />} />
|
||||
<Route path="/deck-creator" element={<DeckCreator />} />
|
||||
<Route path="/deck-creator/:deckId" element={<DeckCreator />} />
|
||||
<Route path="/game" element={<GameScreen />} />
|
||||
{/* <Route path="/contacts" element={<CompanyHub />} /> */}
|
||||
<Route path="/report" element={<Reports />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
|
||||
{/* Add more routes as needed */}
|
||||
</Routes>
|
||||
</Router>
|
||||
{/* ✅ Toastify Container */}
|
||||
<ToastConfig />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { apiClient } from './userApi'
|
||||
|
||||
// Create a new deck in the backend
|
||||
export const createDeck = async (deck) => {
|
||||
try {
|
||||
const response = await apiClient.post('/decks', deck)
|
||||
return response.data
|
||||
} catch (err) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Get paginated decks (authenticated)
|
||||
export const getDecksPage = async (from = 0, to = 49) => {
|
||||
try {
|
||||
const response = await apiClient.get(`/decks/page/${from}/${to}`)
|
||||
return response.data
|
||||
} catch (err) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Get a specific deck by ID (authenticated)
|
||||
export const getDeckById = async (deckId) => {
|
||||
try {
|
||||
const response = await apiClient.get(`/decks/${deckId}`)
|
||||
return response.data
|
||||
} catch (err) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Update an existing deck (authenticated)
|
||||
export const updateDeck = async (deckId, deck) => {
|
||||
try {
|
||||
const response = await apiClient.patch(`/decks/${deckId}`, deck)
|
||||
return response.data
|
||||
} catch (err) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Delete a deck (soft delete) (authenticated)
|
||||
export const deleteDeck = async (deckId) => {
|
||||
try {
|
||||
const response = await apiClient.delete(`/decks/${deckId}`)
|
||||
return response.data
|
||||
} catch (err) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
createDeck,
|
||||
getDeckById,
|
||||
updateDeck,
|
||||
deleteDeck
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import axios from "axios"
|
||||
|
||||
export const API_CONFIG = {
|
||||
baseURL: (import.meta.env.VITE_API_URL ? import.meta.env.VITE_API_URL : "") + "/api",
|
||||
wsURL: "http://localhost:3000",
|
||||
timeout: 10000,
|
||||
retryAttempts: 3,
|
||||
}
|
||||
|
||||
export const apiClient = axios.create({
|
||||
baseURL: API_CONFIG.baseURL,
|
||||
timeout: API_CONFIG.timeout,
|
||||
withCredentials: true, // Important for cookie-based auth
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
|
||||
//login
|
||||
export const login = async (username, password) => {
|
||||
try {
|
||||
const response = await apiClient.post("/users/login", { username, password })
|
||||
return response
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
//register
|
||||
export const register = async (username, email, password, fname, lname, phone) => {
|
||||
try {
|
||||
const response = await apiClient.post("/users/create", { username, email, password, fname, lname, phone })
|
||||
return response
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Get current user's game statistics
|
||||
export const getUserStats = async () => {
|
||||
try {
|
||||
const response = await apiClient.get("/users/me/stats")
|
||||
return response.data
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Email verification - POST
|
||||
export const verifyEmail = async (token) => {
|
||||
try {
|
||||
const response = await apiClient.post(`/users/verify-email/${token}`);
|
||||
return response;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Get current user profile
|
||||
export const getUserProfile = async () => {
|
||||
try {
|
||||
const response = await apiClient.get("/users/profile");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Update current user profile
|
||||
export const updateUserProfile = async (data) => {
|
||||
try {
|
||||
const response = await apiClient.patch("/users/profile", data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Delete current user profile
|
||||
export const deleteUserProfile = async () => {
|
||||
try {
|
||||
const response = await apiClient.delete("/users/profile");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Request password reset
|
||||
export const forgotPassword = async (email) => {
|
||||
try {
|
||||
const response = await apiClient.post("/users/forgot-password", { email });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Reset password with token
|
||||
export const resetPassword = async (token, newPassword) => {
|
||||
try {
|
||||
const response = await apiClient.post("/users/reset-password", { token, newPassword });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
// src/components/DeckCreator/CardEditor.jsx
|
||||
// Jobb oldali kártya szerkesztő
|
||||
|
||||
import React, { useState, useEffect } from "react"
|
||||
import { FaSave, FaTimes, FaEye } from "react-icons/fa"
|
||||
import TaskCardEditor from "./TaskCardEditor.jsx"
|
||||
import JokerCardEditor from "./JokerCardEditor.jsx"
|
||||
import LuckCardEditor from "./LuckCardEditor.jsx"
|
||||
import CardPreview from "./CardPreview.jsx"
|
||||
import { notifySuccess, notifyError,notifyWarning } from "../../components/Toastify/toastifyServices"
|
||||
|
||||
|
||||
export default function CardEditor({ card, isCreating, cardType, onSave, onCancel }) {
|
||||
const [cardData, setCardData] = useState(null)
|
||||
const [showPreview, setShowPreview] = useState(false)
|
||||
|
||||
// Alapértelmezett kártya adatok
|
||||
const getDefaultCardData = (type) => {
|
||||
const baseData = {
|
||||
id: null,
|
||||
type: type,
|
||||
points: 10,
|
||||
timeLimit: 30,
|
||||
consequence: { type: 0, value: 1 }
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'QUESTION':
|
||||
return {
|
||||
...baseData,
|
||||
subType: 'quiz',
|
||||
question: '',
|
||||
options: ['', '', '', ''],
|
||||
correctAnswer: 0,
|
||||
explanation: '',
|
||||
acceptedAnswers: [''],
|
||||
wrongConsequence: { type: 1, value: 1 }
|
||||
}
|
||||
case 'PAIRING':
|
||||
case 'MATCHING':
|
||||
return {
|
||||
...baseData,
|
||||
type: 'QUESTION',
|
||||
subType: 'matching',
|
||||
taskDescription: '',
|
||||
leftItems: ['', ''],
|
||||
rightItems: ['', ''],
|
||||
correctPairs: { 0: 0, 1: 1 },
|
||||
wrongConsequence: { type: 1, value: 1 }
|
||||
}
|
||||
case 'JOKER':
|
||||
return {
|
||||
...baseData,
|
||||
title: '',
|
||||
description: '',
|
||||
effect: '',
|
||||
actionType: 'skip',
|
||||
usage: 'once',
|
||||
wrongConsequence: { type: 1, value: 1 }
|
||||
}
|
||||
case 'LUCK':
|
||||
return {
|
||||
...baseData,
|
||||
event: '',
|
||||
positiveEffect: '',
|
||||
negativeEffect: '',
|
||||
probability: 50,
|
||||
risk: 'low'
|
||||
}
|
||||
default:
|
||||
return baseData
|
||||
}
|
||||
}
|
||||
|
||||
// Kártya adatok inicializálása
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (isCreating && cardType) {
|
||||
const defaultData = getDefaultCardData(cardType)
|
||||
setCardData(defaultData)
|
||||
} else if (card) {
|
||||
setCardData({ ...card })
|
||||
} else {
|
||||
setCardData(null)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Kártya inicializálási hiba:', error)
|
||||
setCardData(null)
|
||||
}
|
||||
}, [card, isCreating, cardType])
|
||||
|
||||
const validateCard = (data) => {
|
||||
try {
|
||||
if (!data || !data.type) {
|
||||
notifyError("Érvénytelen kártya adatok!")
|
||||
return false
|
||||
}
|
||||
|
||||
if (data.type === 'QUESTION') {
|
||||
// Quiz típus validálás
|
||||
if (data.subType === 'quiz') {
|
||||
if (!data.question || !data.question.trim()) {
|
||||
notifyError("Kérdés megadása kötelező!")
|
||||
return false
|
||||
}
|
||||
if (data.options && data.options.some(opt => !opt.trim())) {
|
||||
notifyError("Minden válaszlehetőséget ki kell tölteni!")
|
||||
return false
|
||||
}
|
||||
}
|
||||
// Igaz/Hamis típus validálás
|
||||
else if (data.subType === 'truefalse') {
|
||||
if (!data.statement || !data.statement.trim()) {
|
||||
notifyError("Állítás megadása kötelező!")
|
||||
return false
|
||||
}
|
||||
if (data.isTrue === undefined || data.isTrue === null) {
|
||||
notifyError("Válaszd ki, hogy az állítás igaz vagy hamis!")
|
||||
return false
|
||||
}
|
||||
}
|
||||
// Párosítás típus validálás
|
||||
else if (data.subType === 'matching') {
|
||||
if (!data.taskDescription || !data.taskDescription.trim()) {
|
||||
notifyError("Feladat leírása kötelező!")
|
||||
return false
|
||||
}
|
||||
if (!data.leftItems || data.leftItems.length === 0) {
|
||||
notifyError("Legalább egy párosítást meg kell adni!")
|
||||
return false
|
||||
}
|
||||
if (data.leftItems.some(item => !item.trim()) || data.rightItems.some(item => !item.trim())) {
|
||||
notifyError("Minden párosítási elemet ki kell tölteni!")
|
||||
return false
|
||||
}
|
||||
}
|
||||
// Szöveges válasz típus validálás
|
||||
else if (data.subType === 'text') {
|
||||
if (!data.question || !data.question.trim()) {
|
||||
notifyError("Kérdés megadása kötelező!")
|
||||
return false
|
||||
}
|
||||
if (!data.acceptedAnswers || data.acceptedAnswers.length === 0 || data.acceptedAnswers.every(ans => !ans.trim())) {
|
||||
notifyError("Legalább egy elfogadott választ meg kell adni!")
|
||||
return false
|
||||
}
|
||||
}
|
||||
// Általános validálás (ha nincs subType megadva)
|
||||
else {
|
||||
if (!data.question && !data.statement) {
|
||||
notifyError("Kérdés vagy állítás megadása kötelező!")
|
||||
return false
|
||||
}
|
||||
}
|
||||
} else if (data.type === 'JOKER') {
|
||||
if (!data.text || !data.text.trim()) {
|
||||
notifyError("Joker kártya szövege nem lehet üres!")
|
||||
return false
|
||||
}
|
||||
} else if (data.type === 'LUCK') {
|
||||
if (!data.text || !data.text.trim()) {
|
||||
notifyError("Szerencse kártya szövege nem lehet üres!")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Validálási hiba:', error)
|
||||
notifyError("Hiba történt a kártya ellenőrzése során")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const updateCardData = (updates) => {
|
||||
setCardData(prev => prev ? { ...prev, ...updates } : null)
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
if (!cardData) {
|
||||
notifyError("Nincs mentendő kártya adat!")
|
||||
return
|
||||
}
|
||||
|
||||
if (!validateCard(cardData)) return
|
||||
|
||||
onSave(cardData)
|
||||
}
|
||||
|
||||
// Ha nincs kiválasztott kártya vagy új kártya létrehozás
|
||||
if (!cardData) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center bg-[color:var(--color-background)]">
|
||||
<div className="text-center">
|
||||
<div className="text-6xl mb-4">🃏</div>
|
||||
<div className="text-[color:var(--color-text)] text-xl font-semibold mb-2">
|
||||
Válassz ki egy kártyát
|
||||
</div>
|
||||
<div className="text-[color:var(--color-text-muted)]">
|
||||
Klikkelj egy kártyára a bal oldalon a szerkesztéshez,<br />
|
||||
vagy hozz létre egy újat.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* Type Mismatch Warning */}
|
||||
{cardData?.type && cardType && cardData.type !== cardType && !isCreating && (
|
||||
<div className="bg-[color:var(--color-error)]/10 border-l-4 border-[color:var(--color-error)] px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-[color:var(--color-error)] text-xl">⚠️</div>
|
||||
<div>
|
||||
<div className="text-[color:var(--color-error)] font-semibold">
|
||||
Figyelmeztetés: Nem megfelelő kártya típus
|
||||
</div>
|
||||
<div className="text-[color:var(--color-error)]/80 text-sm">
|
||||
{`Ez egy ${
|
||||
cardData.type === 'QUESTION' ? 'Feladat' :
|
||||
cardData.type === 'JOKER' ? 'Joker' : 'Szerencse'
|
||||
} kártya, de a pakli típusa ${
|
||||
cardType === 'QUESTION' ? 'Feladat' :
|
||||
cardType === 'JOKER' ? 'Joker' : 'Szerencse'
|
||||
}.`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="bg-[color:var(--color-surface)] border-b border-[color:var(--color-surface-selected)] px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-2xl">
|
||||
{cardData.type === 'QUESTION' && '📋'}
|
||||
{cardData.type === 'JOKER' && '🃏'}
|
||||
{cardData.type === 'LUCK' && '🎲'}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-[color:var(--color-text)]">
|
||||
{isCreating ? 'Új' : 'Szerkesztés'} {' '}
|
||||
{(isCreating ? cardType : cardData.type) === 'QUESTION' && 'Feladat kártya'}
|
||||
{(isCreating ? cardType : cardData.type) === 'JOKER' && 'Joker kártya'}
|
||||
{(isCreating ? cardType : cardData.type) === 'LUCK' && 'Szerencse kártya'}
|
||||
</h2>
|
||||
<div className="text-[color:var(--color-text-muted)] text-sm">
|
||||
{cardData.type === 'QUESTION' && cardData.subType && (
|
||||
<>
|
||||
{cardData.subType === 'quiz' && 'Quiz (A/B/C/D)'}
|
||||
{cardData.subType === 'truefalse' && 'Igaz/Hamis'}
|
||||
{cardData.subType === 'matching' && 'Párosítás'}
|
||||
{cardData.subType === 'text' && 'Szöveges válasz'}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
className={`
|
||||
flex items-center gap-2 px-4 py-2 rounded-xl font-medium transition-all duration-200
|
||||
${showPreview
|
||||
? 'bg-[color:var(--color-success)] text-[color:var(--color-text-inverse)]'
|
||||
: 'bg-[color:var(--color-background)] text-[color:var(--color-text)] hover:bg-[color:var(--color-surface-selected)]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<FaEye />
|
||||
Előnézet
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
notifyWarning('Kártya készítés megszakítva')
|
||||
onCancel()
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-[color:var(--color-background)] hover:bg-[color:var(--color-surface-selected)] text-[color:var(--color-text)] transition-all duration-200"
|
||||
>
|
||||
<FaTimes />
|
||||
Mégse
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="flex items-center gap-2 px-6 py-2 rounded-xl bg-[color:var(--color-success)] hover:bg-[color:var(--color-success)]/80 text-[color:var(--color-text-inverse)] font-semibold transition-all duration-200 hover:scale-105 shadow-lg"
|
||||
>
|
||||
<FaSave />
|
||||
Mentés
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{showPreview ? (
|
||||
<div className="h-full bg-[color:var(--color-background)] flex items-center justify-center p-6">
|
||||
<CardPreview card={cardData} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full overflow-y-auto p-6">
|
||||
{cardData.type === 'QUESTION' && (
|
||||
<TaskCardEditor
|
||||
card={cardData}
|
||||
onChange={updateCardData}
|
||||
/>
|
||||
)}
|
||||
|
||||
{cardData.type === 'JOKER' && (
|
||||
<JokerCardEditor
|
||||
card={cardData}
|
||||
onChange={updateCardData}
|
||||
/>
|
||||
)}
|
||||
|
||||
{cardData.type === 'LUCK' && (
|
||||
<LuckCardEditor
|
||||
card={cardData}
|
||||
onChange={updateCardData}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
// src/components/DeckCreator/CardPreview.jsx
|
||||
// Kártya előnézet komponens
|
||||
|
||||
import React from "react"
|
||||
import { FaQuestionCircle, FaTheaterMasks, FaDice, FaClock, FaStar } from "react-icons/fa"
|
||||
|
||||
export default function CardPreview({ card }) {
|
||||
if (!card) {
|
||||
return (
|
||||
<div className="text-center text-[color:var(--color-text-muted)]">
|
||||
<div className="text-6xl mb-4">🃏</div>
|
||||
<div>Nincs kiválasztott kártya az előnézethez</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Kártya típus specifikus beállítások
|
||||
const getCardConfig = (card) => {
|
||||
switch (card.type) {
|
||||
case 'task':
|
||||
return {
|
||||
bgColor: 'var(--color-question)',
|
||||
icon: FaQuestionCircle,
|
||||
title: 'FELADAT KÁRTYA',
|
||||
emoji: '📋'
|
||||
}
|
||||
case 'joker':
|
||||
return {
|
||||
bgColor: 'var(--color-fun)',
|
||||
icon: FaTheaterMasks,
|
||||
title: 'JOKER KÁRTYA',
|
||||
emoji: '🎭'
|
||||
}
|
||||
case 'luck':
|
||||
return {
|
||||
bgColor: 'var(--color-luck)',
|
||||
icon: FaDice,
|
||||
title: 'SZERENCSE KÁRTYA',
|
||||
emoji: '🎲'
|
||||
}
|
||||
default:
|
||||
return {
|
||||
bgColor: 'var(--color-border)',
|
||||
icon: FaQuestionCircle,
|
||||
title: 'ISMERETLEN KÁRTYA',
|
||||
emoji: '❓'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const config = getCardConfig(card)
|
||||
|
||||
// Kártya tartalom meghatározása
|
||||
const getCardContent = (card) => {
|
||||
if (card.type === 'task') {
|
||||
return card.question || card.statement || 'Feladat leírása...'
|
||||
}
|
||||
if (card.type === 'joker' || card.type === 'luck') {
|
||||
return card.text || 'Kártya szövege...'
|
||||
}
|
||||
return 'Kártya tartalma...'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center p-6">
|
||||
{/* Kártya container */}
|
||||
<div
|
||||
className="relative w-80 h-96 rounded-2xl shadow-2xl transform transition-all duration-300 hover:scale-105"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${config.bgColor}15, ${config.bgColor}05)`,
|
||||
border: `3px solid ${config.bgColor}`,
|
||||
}}
|
||||
>
|
||||
{/* Kártya header */}
|
||||
<div
|
||||
className="h-16 rounded-t-xl flex items-center justify-center relative overflow-hidden"
|
||||
style={{ backgroundColor: config.bgColor }}
|
||||
>
|
||||
{/* Háttér pattern */}
|
||||
<div className="absolute inset-0 opacity-20">
|
||||
<div className="w-full h-full" style={{
|
||||
backgroundImage: `repeating-linear-gradient(45deg, transparent, transparent 10px, rgba(255,255,255,0.1) 10px, rgba(255,255,255,0.1) 20px)`
|
||||
}} />
|
||||
</div>
|
||||
|
||||
<div className="relative flex items-center gap-3">
|
||||
<config.icon className="text-white text-xl" />
|
||||
<span className="text-white font-bold text-sm tracking-wide">
|
||||
{config.title}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Kártya body */}
|
||||
<div className="p-6 h-80 flex flex-col">
|
||||
{/* Főikon */}
|
||||
<div className="text-center mb-4">
|
||||
<div className="text-5xl mb-2">{config.emoji}</div>
|
||||
</div>
|
||||
|
||||
{/* Tartalom */}
|
||||
<div className="flex-1 flex flex-col justify-center">
|
||||
<div
|
||||
className="text-[color:var(--color-text)] text-center leading-relaxed"
|
||||
style={{
|
||||
fontSize: card.text && card.text.length > 100 ? '14px' : '16px'
|
||||
}}
|
||||
>
|
||||
{getCardContent(card)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alsó információk */}
|
||||
<div className="border-t border-[color:var(--color-border)] pt-4 mt-4">
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
{/* Idő */}
|
||||
{card.timeLimit && (
|
||||
<div className="flex items-center gap-1 text-[color:var(--color-text-muted)]">
|
||||
<FaClock className="text-xs" />
|
||||
<span>{card.timeLimit}s</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pontok */}
|
||||
{card.points && (
|
||||
<div className="flex items-center gap-1 text-[color:var(--color-text-muted)]">
|
||||
<FaStar className="text-xs" />
|
||||
<span>{card.points} pont</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ha nincs idő/pont info */}
|
||||
{!card.timeLimit && !card.points && (
|
||||
<div className="text-[color:var(--color-text-muted)] text-xs w-full text-center">
|
||||
SerpentRace Deck
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Kártya corner dekoráció */}
|
||||
<div className="absolute top-2 right-2 w-4 h-4 rounded-full" style={{ backgroundColor: config.bgColor, opacity: 0.3 }} />
|
||||
<div className="absolute bottom-2 left-2 w-4 h-4 rounded-full" style={{ backgroundColor: config.bgColor, opacity: 0.3 }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
// src/components/DeckCreator/CardsList.jsx
|
||||
// Bal oldali kártyák listája és új kártya létrehozás
|
||||
|
||||
import React, { useState } from "react"
|
||||
import {
|
||||
FaPlus,
|
||||
FaEdit,
|
||||
FaTrash,
|
||||
FaQuestionCircle,
|
||||
FaCheck,
|
||||
FaTimes,
|
||||
FaDice,
|
||||
FaTheaterMasks
|
||||
} from "react-icons/fa"
|
||||
import { notifySuccess, notifyError } from "../../components/Toastify/toastifyServices"
|
||||
|
||||
const cardTypeIcons = {
|
||||
QUESTION: { icon: FaQuestionCircle, color: "var(--color-question)" },
|
||||
JOKER: { icon: FaTheaterMasks, color: "var(--color-fun)" },
|
||||
LUCK: { icon: FaDice, color: "var(--color-luck)" }
|
||||
}
|
||||
|
||||
const cardSubTypeLabels = {
|
||||
quiz: "Quiz",
|
||||
truefalse: "Igaz/Hamis",
|
||||
matching: "Párosítás",
|
||||
text: "Szöveges válasz"
|
||||
}
|
||||
|
||||
export default function CardsList({
|
||||
cards,
|
||||
selectedCard,
|
||||
deckType,
|
||||
onSelectCard,
|
||||
onCreateCard,
|
||||
onDeleteCard,
|
||||
isCreatingCard,
|
||||
newCardType
|
||||
}) {
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(null)
|
||||
|
||||
const getCardPreview = (card) => {
|
||||
if (card.type === 'QUESTION') {
|
||||
return card.question || card.statement || 'Új feladat kártya'
|
||||
}
|
||||
if (card.type === 'JOKER') {
|
||||
return card.text || 'Új joker kártya'
|
||||
}
|
||||
if (card.type === 'LUCK') {
|
||||
return card.text || 'Új szerencse kártya'
|
||||
}
|
||||
return "Ismeretlen kártya"
|
||||
}
|
||||
|
||||
const getCardTypeLabel = (card) => {
|
||||
if (card.type === 'QUESTION') {
|
||||
if (card.subType) {
|
||||
return cardSubTypeLabels[card.subType] || "Feladat"
|
||||
}
|
||||
return "Feladat"
|
||||
}
|
||||
if (card.type === 'JOKER') {
|
||||
return 'Joker'
|
||||
}
|
||||
if (card.type === 'LUCK') {
|
||||
return 'Szerencse'
|
||||
}
|
||||
return "Ismeretlen"
|
||||
}
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
if (confirmingDelete) {
|
||||
onDeleteCard(confirmingDelete)
|
||||
notifySuccess("Kártya sikeresen törölve a pakliból!")
|
||||
setConfirmingDelete(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelDelete = () => {
|
||||
setConfirmingDelete(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full relative">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-[color:var(--color-surface-selected)]">
|
||||
<h2 className="text-lg font-bold text-[color:var(--color-text)] mb-4 flex items-center gap-2">
|
||||
🃏 Kártyák
|
||||
</h2>
|
||||
|
||||
{/* New Card Button */}
|
||||
<button
|
||||
onClick={() => onCreateCard(deckType)}
|
||||
className={`w-full flex items-center justify-center gap-2 px-4 py-3 rounded-xl text-[color:var(--color-text-inverse)] font-semibold transition-all duration-200 hover:scale-105 shadow-lg ${
|
||||
deckType === 'QUESTION' ? 'bg-[color:var(--color-question)] hover:bg-[color:var(--color-question)]/80' :
|
||||
deckType === 'LUCK' ? 'bg-[color:var(--color-luck)] hover:bg-[color:var(--color-luck)]/80' :
|
||||
'bg-[color:var(--color-fun)] hover:bg-[color:var(--color-fun)]/80'
|
||||
}`}
|
||||
>
|
||||
<FaPlus />
|
||||
<span>
|
||||
{deckType === 'QUESTION' && '📋 Új feladat kártya'}
|
||||
{deckType === 'JOKER' && '🃏 Új joker kártya'}
|
||||
{deckType === 'LUCK' && '🎲 Új szerencse kártya'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Cards List */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
{/* Creating Card Indicator */}
|
||||
{isCreatingCard && (
|
||||
<div className="bg-[color:var(--color-background)]/50 border-2 border-dashed border-[color:var(--color-success)] rounded-xl p-4 animate-pulse">
|
||||
<div className="flex items-center gap-3">
|
||||
{newCardType && (
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-[color:var(--color-success)]/20">
|
||||
{React.createElement(cardTypeIcons[newCardType]?.icon || FaQuestionCircle, {
|
||||
className: "text-[color:var(--color-success)] text-sm"
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-[color:var(--color-text)] font-medium">
|
||||
Új {newCardType === "QUESTION" ? "feladat" : newCardType === "JOKER" ? "joker" : "szerencse"} kártya
|
||||
</div>
|
||||
<div className="text-[color:var(--color-text-muted)] text-sm">
|
||||
Szerkesztés folyamatban...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Existing Cards */}
|
||||
{cards.map((card, index) => {
|
||||
const cardIcon = cardTypeIcons[card.type] || cardTypeIcons.task
|
||||
const isSelected = selectedCard?.id === card.id
|
||||
|
||||
return (
|
||||
<div
|
||||
key={card.id}
|
||||
onClick={() => onSelectCard(card)}
|
||||
className={`
|
||||
p-4 rounded-xl border cursor-pointer transition-all duration-200 hover:scale-105 group relative
|
||||
${
|
||||
isSelected
|
||||
? "bg-[color:var(--color-success)]/10 border-[color:var(--color-success)] shadow-lg"
|
||||
: "bg-[color:var(--color-background)]/50 border-[color:var(--color-surface-selected)] hover:bg-[color:var(--color-background)]/80"
|
||||
}
|
||||
${card.type !== deckType ? "opacity-70" : ""}
|
||||
`}
|
||||
>
|
||||
{card.type !== deckType && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-[color:var(--color-error)]/5 backdrop-blur-[1px] rounded-xl"></div>
|
||||
<div className="absolute rotate-[-15deg] border-t-2 border-[color:var(--color-error)] w-full"></div>
|
||||
<span className="absolute top-1 right-1 text-xs text-[color:var(--color-error)] bg-[color:var(--color-error)]/10 px-2 py-1 rounded-lg">
|
||||
⚠️ Nem megfelelő típus
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Card Header */}
|
||||
<div className="flex items-start justify-between gap-2 mb-3">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div
|
||||
className="flex items-center justify-center w-10 h-10 rounded-full border-2"
|
||||
style={{ borderColor: cardIcon.color }}
|
||||
>
|
||||
{React.createElement(cardIcon.icon, {
|
||||
style: { color: cardIcon.color },
|
||||
className: "text-lg"
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[color:var(--color-text)] font-bold text-sm mb-1">
|
||||
#{index + 1} - {getCardTypeLabel(card)}
|
||||
</div>
|
||||
{card.timeLimit && (
|
||||
<div className="text-[color:var(--color-text-muted)] text-xs flex items-center gap-1">
|
||||
⏱️ {card.timeLimit} másodperc
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setConfirmingDelete(card.id)
|
||||
}}
|
||||
className="p-1.5 rounded-lg bg-[color:var(--color-error)]/10 hover:bg-[color:var(--color-error)]/20 text-[color:var(--color-error)] transition-colors duration-200"
|
||||
>
|
||||
<FaTrash className="text-xs" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card Content Preview */}
|
||||
<div className="bg-[color:var(--color-surface)]/30 rounded-lg p-3 mb-2">
|
||||
<div
|
||||
className="text-[color:var(--color-text)] text-sm leading-relaxed"
|
||||
style={{
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: "vertical",
|
||||
overflow: "hidden"
|
||||
}}
|
||||
>
|
||||
{getCardPreview(card)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Empty State */}
|
||||
{cards.length === 0 && !isCreatingCard && (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-[color:var(--color-text-muted)] text-lg mb-2">🃏</div>
|
||||
<div className="text-[color:var(--color-text-muted)] text-sm">
|
||||
Még nincsenek kártyák.
|
||||
<br />
|
||||
Hozz létre az első kártyát!
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirm Delete Popup */}
|
||||
{confirmingDelete && (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-xl p-6 w-80 text-center animate-fadeIn">
|
||||
<h3 className="text-lg font-semibold mb-4 text-gray-800">
|
||||
Biztosan törölni szeretnéd?
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 mb-6">
|
||||
Ez a művelet nem visszavonható.
|
||||
</p>
|
||||
<div className="flex justify-center gap-4">
|
||||
<button
|
||||
onClick={handleConfirmDelete}
|
||||
className="bg-[color:var(--color-error)] text-white px-4 py-2 rounded-lg hover:bg-red-600 transition"
|
||||
>
|
||||
Igen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancelDelete}
|
||||
className="bg-gray-200 px-4 py-2 rounded-lg hover:bg-gray-300 transition"
|
||||
>
|
||||
Mégse
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer Stats */}
|
||||
<div className="p-4 border-t border-[color:var(--color-surface-selected)] bg-[color:var(--color-background)]/30">
|
||||
<div className="text-center">
|
||||
<div className="text-[color:var(--color-text)] font-semibold">
|
||||
📊 Összesen: {cards.length} kártya
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
// src/components/DeckCreator/DeckHeader.jsx
|
||||
// Deck alapadatok szerkesztése és mentés
|
||||
|
||||
import React, { useState, useRef, useEffect } from "react"
|
||||
import { FaSave, FaArrowLeft, FaGlobe, FaLock, FaQuestionCircle, FaDice, FaLaughBeam, FaTrash } from "react-icons/fa"
|
||||
|
||||
const deckTypes = [
|
||||
{ value: "QUESTION", label: "Kérdés", icon: FaQuestionCircle, color: "var(--color-question)" },
|
||||
{ value: "LUCK", label: "Szerencse", icon: FaDice, color: "var(--color-luck)" },
|
||||
{ value: "JOKER", label: "Joker", icon: FaLaughBeam, color: "var(--color-fun)" }
|
||||
]
|
||||
|
||||
const privacyOptions = [
|
||||
{ value: "private", label: "Privát", icon: FaLock },
|
||||
{ value: "public", label: "Publikus", icon: FaGlobe }
|
||||
]
|
||||
|
||||
export default function DeckHeader({ deck, onUpdate, onSave, onBack, onDelete }) {
|
||||
const [isTypeDropdownOpen, setIsTypeDropdownOpen] = useState(false);
|
||||
const [isPrivacyDropdownOpen, setIsPrivacyDropdownOpen] = useState(false);
|
||||
const typeDropdownRef = useRef(null);
|
||||
const privacyDropdownRef = useRef(null);
|
||||
|
||||
const currentDeckType = deckTypes.find(type => type.value === deck.type) || deckTypes[0]
|
||||
const currentPrivacy = privacyOptions.find(option => option.value === deck.privacy) || privacyOptions[0]
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event) {
|
||||
if (typeDropdownRef.current && !typeDropdownRef.current.contains(event.target)) {
|
||||
setIsTypeDropdownOpen(false);
|
||||
}
|
||||
if (privacyDropdownRef.current && !privacyDropdownRef.current.contains(event.target)) {
|
||||
setIsPrivacyDropdownOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleInputChange = (field, value) => {
|
||||
onUpdate({ [field]: value })
|
||||
}
|
||||
|
||||
// Remove unused card count variables
|
||||
|
||||
return (
|
||||
<div className="bg-[color:var(--color-surface)] border-b border-[color:var(--color-surface-selected)] px-6 py-4">
|
||||
{/* Top Row - Title and Actions */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-[color:var(--color-background)] hover:bg-[color:var(--color-surface-selected)] text-[color:var(--color-text)] transition-all duration-200"
|
||||
>
|
||||
<FaArrowLeft />
|
||||
Vissza
|
||||
</button>
|
||||
|
||||
<h1 className="text-2xl font-bold text-[color:var(--color-text)]">
|
||||
📝 Pakli Szerkesztés
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{deck.id && (
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="flex items-center gap-2 px-6 py-2 rounded-xl bg-red-600 hover:bg-red-700 text-white font-semibold transition-all duration-200 hover:scale-105 shadow-lg"
|
||||
>
|
||||
<FaTrash />
|
||||
Törlés
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onSave}
|
||||
className="flex items-center gap-2 px-6 py-2 rounded-xl bg-[color:var(--color-success)] hover:bg-[color:var(--color-success)]/80 text-[color:var(--color-text-inverse)] font-semibold transition-all duration-200 hover:scale-105 shadow-lg"
|
||||
>
|
||||
<FaSave />
|
||||
Mentés
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content Row */}
|
||||
<div className="space-y-4">
|
||||
{/* Two Column Layout */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Deck Name - Takes up 2 columns */}
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-[color:var(--color-text-muted)] text-sm font-medium mb-2">
|
||||
📦 Pakli neve
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={deck.name}
|
||||
onChange={(e) => handleInputChange('name', e.target.value)}
|
||||
className="w-full px-4 py-2 rounded-xl bg-[color:var(--color-background)] border border-[color:var(--color-surface-selected)] text-[color:var(--color-text)] focus:ring-2 focus:ring-[color:var(--color-success)] focus:border-transparent outline-none transition-all duration-200"
|
||||
placeholder="Add meg a pakli nevét..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Empty space for visual balance */}
|
||||
<div className="hidden md:block"></div>
|
||||
</div>
|
||||
|
||||
{/* Type, Privacy and Description Row */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Deck Type */}
|
||||
<div>
|
||||
<label className="block text-[color:var(--color-text-muted)] text-sm font-medium mb-2">
|
||||
🎯 Típus
|
||||
</label>
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsTypeDropdownOpen(!isTypeDropdownOpen)}
|
||||
className="w-full px-4 py-2 rounded-xl bg-[color:var(--color-background)] border border-[color:var(--color-surface-selected)] text-[color:var(--color-text)] focus:ring-2 focus:ring-[color:var(--color-success)] focus:border-transparent outline-none transition-all duration-200 flex items-center"
|
||||
style={{
|
||||
paddingLeft: "2.5rem"
|
||||
}}
|
||||
>
|
||||
<div className="absolute left-4 top-1/2 -translate-y-1/2">
|
||||
{React.createElement(currentDeckType.icon, {
|
||||
style: { color: currentDeckType.color }
|
||||
})}
|
||||
</div>
|
||||
{currentDeckType.label}
|
||||
<div className="absolute right-4 top-1/2 -translate-y-1/2">
|
||||
<svg className={`w-4 h-4 text-[color:var(--color-text-muted)] transform transition-transform ${isTypeDropdownOpen ? 'rotate-180' : ''}`} viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isTypeDropdownOpen && (
|
||||
<div
|
||||
className="absolute z-10 w-full mt-1 bg-[color:var(--color-background)] border border-[color:var(--color-surface-selected)] rounded-xl shadow-lg overflow-hidden"
|
||||
ref={typeDropdownRef}
|
||||
>
|
||||
{deckTypes.map(type => (
|
||||
<button
|
||||
key={type.value}
|
||||
onClick={() => {
|
||||
handleInputChange('type', type.value);
|
||||
setIsTypeDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full px-4 py-2 flex items-center gap-3 hover:bg-[color:var(--color-surface-selected)] transition-colors text-[color:var(--color-text)] ${deck.type === type.value ? 'bg-[color:var(--color-surface-selected)]' : ''}`}
|
||||
>
|
||||
{React.createElement(type.icon, {
|
||||
style: { color: type.color }
|
||||
})}
|
||||
<span>{type.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Privacy */}
|
||||
<div>
|
||||
<label className="block text-[color:var(--color-text-muted)] text-sm font-medium mb-2">
|
||||
👁️ Láthatóság
|
||||
</label>
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPrivacyDropdownOpen(!isPrivacyDropdownOpen)}
|
||||
className="w-full px-4 py-2 rounded-xl bg-[color:var(--color-background)] border border-[color:var(--color-surface-selected)] text-[color:var(--color-text)] focus:ring-2 focus:ring-[color:var(--color-success)] focus:border-transparent outline-none transition-all duration-200 flex items-center"
|
||||
style={{
|
||||
paddingLeft: "2.5rem"
|
||||
}}
|
||||
>
|
||||
<div className="absolute left-4 top-1/2 -translate-y-1/2">
|
||||
{React.createElement(currentPrivacy.icon, {
|
||||
className: "text-[color:var(--color-text)]"
|
||||
})}
|
||||
</div>
|
||||
{currentPrivacy.label}
|
||||
<div className="absolute right-4 top-1/2 -translate-y-1/2">
|
||||
<svg className={`w-4 h-4 text-[color:var(--color-text-muted)] transform transition-transform ${isPrivacyDropdownOpen ? 'rotate-180' : ''}`} viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isPrivacyDropdownOpen && (
|
||||
<div
|
||||
className="absolute z-10 w-full mt-1 bg-[color:var(--color-background)] border border-[color:var(--color-surface-selected)] rounded-xl shadow-lg overflow-hidden"
|
||||
ref={privacyDropdownRef}
|
||||
>
|
||||
{privacyOptions.map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
handleInputChange('privacy', option.value);
|
||||
setIsPrivacyDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full px-4 py-2 flex items-center gap-3 hover:bg-[color:var(--color-surface-selected)] transition-colors text-[color:var(--color-text)] ${deck.privacy === option.value ? 'bg-[color:var(--color-surface-selected)]' : ''}`}
|
||||
>
|
||||
{React.createElement(option.icon, {
|
||||
className: "text-[color:var(--color-text)]"
|
||||
})}
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-[color:var(--color-text-muted)] text-sm font-medium mb-2">
|
||||
📝 Leírás
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={deck.description}
|
||||
onChange={(e) => handleInputChange('description', e.target.value)}
|
||||
className="w-full px-4 py-2 rounded-xl bg-[color:var(--color-background)] border border-[color:var(--color-surface-selected)] text-[color:var(--color-text)] focus:ring-2 focus:ring-[color:var(--color-success)] focus:border-transparent outline-none transition-all duration-200"
|
||||
placeholder="Rövid leírás..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+181
-26
@@ -1,4 +1,5 @@
|
||||
import React, { useState } from "react"
|
||||
import React, { useState, useEffect } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import {
|
||||
FaPlus,
|
||||
FaFilter,
|
||||
@@ -8,29 +9,20 @@ import {
|
||||
FaSortAlphaDown,
|
||||
FaSortAlphaUp,
|
||||
FaQuestionCircle,
|
||||
FaChevronLeft,
|
||||
FaChevronRight,
|
||||
} from "react-icons/fa"
|
||||
import SearchBox from "../Search/SearchBox"
|
||||
import PopUp from "../PopUp/PopUp"
|
||||
import DeckInfoPopUp from "../PopUp/DeckInfoPopUp"
|
||||
|
||||
const deckTypes = [
|
||||
{ label: "Luck", color: "var(--color-luck)" },
|
||||
{ label: "Question", color: "var(--color-question)" },
|
||||
{ label: "Fun", color: "var(--color-fun)" },
|
||||
{ label: "Joker", color: "var(--color-fun)" },
|
||||
]
|
||||
|
||||
const mockDecks = [
|
||||
// Just for visual mockup
|
||||
{ id: 1, name: "Party Luck", type: "Luck", created: "2025-07-01", origin: "Vállalati" },
|
||||
{ id: 2, name: "Quiz Night", type: "Question", created: "2025-07-02", origin: "Saját" },
|
||||
{ id: 3, name: "Fun Times", type: "Fun", created: "2025-07-03", origin: "Vállalati" },
|
||||
{ id: 4, name: "Corporate Challenge", type: "Question", created: "2025-07-04", origin: "Vállalati" },
|
||||
{ id: 5, name: "Randomizer", type: "Luck", created: "2025-07-05", origin: "Saját" },
|
||||
{ id: 6, name: "Afterwork luck", type: "Luck", created: "2025-07-06", origin: "Saját" },
|
||||
{ id: 7, name: "Serpent Quiz", type: "Question", created: "2025-07-07", origin: "Vállalati" },
|
||||
{ id: 8, name: "Green Fortune", type: "Luck", created: "2025-07-08", origin: "Vállalati" },
|
||||
{ id: 9, name: "Team Builder", type: "Fun", created: "2025-07-09", origin: "Saját" },
|
||||
{ id: 10, name: "Knowledge Race", type: "Question", created: "2025-07-10", origin: "Saját" },
|
||||
]
|
||||
// initial state will be fetched from backend
|
||||
|
||||
const origins = ["Mind", "Vállalati", "Saját"]
|
||||
|
||||
@@ -72,14 +64,55 @@ const sortOptions = [
|
||||
]
|
||||
|
||||
const DeckManager = () => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [selectedType, setSelectedType] = useState("All")
|
||||
const [selectedOrigin, setSelectedOrigin] = useState("Mind")
|
||||
const [sortBy, setSortBy] = useState("date-desc")
|
||||
const [search, setSearch] = useState("")
|
||||
const [showSortHelp, setShowSortHelp] = useState(false)
|
||||
const [selectedDeck, setSelectedDeck] = useState(null)
|
||||
const [allDecks, setAllDecks] = useState([]) // Összes pakli
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [itemsPerPage, setItemsPerPage] = useState(20)
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
|
||||
// Filter logic (mock)
|
||||
let filteredDecks = mockDecks.filter((deck) => {
|
||||
// Load all decks once
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// Load all decks (0-99 is the max limit = 100 decks)
|
||||
const result = await import('../../api/deckApi').then(m => m.getDecksPage(0, 99))
|
||||
if (!mounted) return
|
||||
|
||||
console.log('Loaded decks:', result) // Debug
|
||||
|
||||
// Map backend deck shape to UI shape
|
||||
const mapped = (result.decks || []).map(d => ({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
type: d.type === 2 ? 'Question' : d.type === 1 ? 'Joker' : 'Luck',
|
||||
created: d.creationdate ? new Date(d.creationdate).toLocaleDateString() : '',
|
||||
origin: d.ctype === 2 ? 'Vállalati' : d.ctype === 0 ? 'Mind' : 'Saját',
|
||||
raw: d
|
||||
}))
|
||||
|
||||
console.log('Mapped decks:', mapped) // Debug
|
||||
setAllDecks(mapped)
|
||||
} catch (err) {
|
||||
console.error('Failed to load decks', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
return () => { mounted = false }
|
||||
}, [])
|
||||
|
||||
// Filter logic
|
||||
let filteredDecks = allDecks.filter((deck) => {
|
||||
const typeMatch = selectedType === "All" || deck.type === selectedType
|
||||
const originMatch = selectedOrigin === "Mind" || deck.origin === selectedOrigin
|
||||
const searchMatch = !search || deck.name.toLowerCase().includes(search.toLowerCase())
|
||||
@@ -100,11 +133,23 @@ const DeckManager = () => {
|
||||
return 0
|
||||
})
|
||||
|
||||
// Pagination logic - frontend only
|
||||
const totalDecks = filteredDecks.length
|
||||
const totalPages = Math.ceil(totalDecks / itemsPerPage)
|
||||
const startIndex = (currentPage - 1) * itemsPerPage
|
||||
const endIndex = startIndex + itemsPerPage
|
||||
const paginatedDecks = filteredDecks.slice(startIndex, endIndex)
|
||||
|
||||
// Reset to page 1 when filters or items per page change
|
||||
useEffect(() => {
|
||||
setCurrentPage(1)
|
||||
}, [selectedType, selectedOrigin, search, sortBy, itemsPerPage])
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col bg-[color:var(--color-background)]">
|
||||
<div className="w-full max-w-6xl mx-auto px-4 py-10">
|
||||
<div className="w-full max-w-[1200px] mx-auto px-4 py-10">
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col md:flex-row gap-4 justify-between items-center mb-10 bg-[color:var(--color-surface)]/80 backdrop-blur-lg rounded-2xl px-6 py-4 shadow-lg">
|
||||
<div className="flex flex-col md:flex-row gap-3 justify-between items-center mb-10 bg-[color:var(--color-surface)]/80 backdrop-blur-lg rounded-2xl px-6 py-4 shadow-lg">
|
||||
<div className="flex gap-2 items-center w-full md:w-auto">
|
||||
<SearchBox
|
||||
value={search}
|
||||
@@ -140,8 +185,8 @@ const DeckManager = () => {
|
||||
? "Szerencse"
|
||||
: type.label === "Question"
|
||||
? "Kérdés"
|
||||
: type.label === "Fun"
|
||||
? "Szórakozás"
|
||||
: type.label === "Joker"
|
||||
? "Joker"
|
||||
: type.label}
|
||||
</button>
|
||||
))}
|
||||
@@ -240,22 +285,62 @@ const DeckManager = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Items per page selector and pagination info */}
|
||||
<div className="flex flex-col md:flex-row gap-4 justify-between items-center mb-6 bg-[color:var(--color-surface)]/60 backdrop-blur-lg rounded-xl px-6 py-3 shadow">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[color:var(--color-text-muted)] text-sm font-medium">
|
||||
Elemek oldalanként:
|
||||
</span>
|
||||
<select
|
||||
value={itemsPerPage}
|
||||
onChange={(e) => setItemsPerPage(Number(e.target.value))}
|
||||
className="px-3 py-1.5 rounded-lg bg-[color:var(--color-background)] text-[color:var(--color-text)] border border-[color:var(--color-surface-selected)] focus:ring-2 focus:ring-[color:var(--color-success)] outline-none transition-all duration-200"
|
||||
>
|
||||
<option value={20}>20</option>
|
||||
<option value={30}>30</option>
|
||||
<option value={40}>40</option>
|
||||
<option value={50}>50</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="text-[color:var(--color-text-muted)] text-sm">
|
||||
{totalDecks > 0 ? (
|
||||
<>
|
||||
{startIndex + 1}-{Math.min(endIndex, totalDecks)} / {totalDecks} pakli
|
||||
</>
|
||||
) : (
|
||||
<>0 pakli</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decks Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-8 mt-8">
|
||||
{/* Create New Deck (Mockup) */}
|
||||
<div className="flex flex-col items-center justify-center h-48 bg-[color:var(--color-card)] border-2 border-dashed border-[color:var(--color-success)] rounded-2xl cursor-pointer hover:bg-[color:var(--color-success)]/20 transition-all duration-200 shadow-lg">
|
||||
<div
|
||||
onClick={() => navigate("/deck-creator")}
|
||||
className="flex flex-col items-center justify-center h-48 bg-[color:var(--color-card)] border-2 border-dashed border-[color:var(--color-success)] rounded-2xl cursor-pointer hover:bg-[color:var(--color-success)]/20 transition-all duration-200 shadow-lg"
|
||||
>
|
||||
<FaPlus style={{ color: "var(--color-success)" }} className="text-5xl mb-2" />
|
||||
<span className="text-[color:var(--color-text)] font-semibold">Új pakli létrehozása</span>
|
||||
</div>
|
||||
{/* Existing Decks (Mockup) */}
|
||||
{filteredDecks.map((deck) => {
|
||||
{/* Existing Decks (from backend) */}
|
||||
{loading && (
|
||||
<div className="col-span-full text-center text-[color:var(--color-text-muted)]">Betöltés...</div>
|
||||
)}
|
||||
{!loading && filteredDecks.length === 0 && (
|
||||
<div className="col-span-full text-center text-[color:var(--color-text-muted)]">Nincsenek mentett paklik.</div>
|
||||
)}
|
||||
{!loading && paginatedDecks.map((deck) => {
|
||||
const deckType = deckTypes.find((t) => t.label === deck.type)
|
||||
const borderColor = deckType ? deckType.color : "var(--color-success)"
|
||||
return (
|
||||
<div
|
||||
key={deck.id}
|
||||
className="flex flex-col justify-between h-48 bg-[color:var(--color-card)] rounded-2xl p-6 shadow-lg border-t-4 hover:scale-105 transition-transform duration-200"
|
||||
className="flex flex-col justify-between h-48 bg-[color:var(--color-card)] rounded-2xl p-6 shadow-lg border-t-4 hover:scale-105 transition-transform duration-200 cursor-pointer"
|
||||
style={{ borderTopColor: borderColor }}
|
||||
onClick={() => setSelectedDeck(deck)}
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
@@ -270,7 +355,7 @@ const DeckManager = () => {
|
||||
: deck.type === "Question"
|
||||
? "Kérdés"
|
||||
: deck.type === "Fun"
|
||||
? "Szórakozás"
|
||||
? "Joker"
|
||||
: deck.type}
|
||||
</span>
|
||||
<h2 className="text-xl font-bold text-[color:var(--color-text)] mb-1 truncate">
|
||||
@@ -284,7 +369,77 @@ const DeckManager = () => {
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center items-center gap-2 mt-8">
|
||||
<button
|
||||
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center gap-2 ${
|
||||
currentPage === 1
|
||||
? 'bg-[color:var(--color-surface)] text-[color:var(--color-text-muted)] cursor-not-allowed'
|
||||
: 'bg-[color:var(--color-success)] text-[color:var(--color-text-inverse)] hover:bg-[color:var(--color-success)]/80 hover:scale-105'
|
||||
}`}
|
||||
>
|
||||
<FaChevronLeft />
|
||||
Előző
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{[...Array(totalPages)].map((_, index) => {
|
||||
const pageNum = index + 1
|
||||
// Show first page, last page, current page and neighbors
|
||||
if (
|
||||
pageNum === 1 ||
|
||||
pageNum === totalPages ||
|
||||
(pageNum >= currentPage - 1 && pageNum <= currentPage + 1)
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
key={pageNum}
|
||||
onClick={() => setCurrentPage(pageNum)}
|
||||
className={`w-10 h-10 rounded-lg font-medium transition-all duration-200 ${
|
||||
currentPage === pageNum
|
||||
? 'bg-[color:var(--color-success)] text-[color:var(--color-text-inverse)] scale-110 shadow-lg'
|
||||
: 'bg-[color:var(--color-surface)] text-[color:var(--color-text)] hover:bg-[color:var(--color-surface-selected)]'
|
||||
}`}
|
||||
>
|
||||
{pageNum}
|
||||
</button>
|
||||
)
|
||||
} else if (
|
||||
pageNum === currentPage - 2 ||
|
||||
pageNum === currentPage + 2
|
||||
) {
|
||||
return (
|
||||
<span key={pageNum} className="text-[color:var(--color-text-muted)]">
|
||||
...
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center gap-2 ${
|
||||
currentPage === totalPages
|
||||
? 'bg-[color:var(--color-surface)] text-[color:var(--color-text-muted)] cursor-not-allowed'
|
||||
: 'bg-[color:var(--color-success)] text-[color:var(--color-text-inverse)] hover:bg-[color:var(--color-success)]/80 hover:scale-105'
|
||||
}`}
|
||||
>
|
||||
Következő
|
||||
<FaChevronRight />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Deck Info Popup */}
|
||||
{selectedDeck && <DeckInfoPopUp deck={selectedDeck} onClose={() => setSelectedDeck(null)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// src/components/DeckCreator/JokerCardEditor.jsx
|
||||
// Joker kártya szerkesztő
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { FaTheaterMasks, FaInfoCircle, FaUsers } from 'react-icons/fa'
|
||||
|
||||
export default function JokerCardEditor({ card, onChange }) {
|
||||
const [cardData, setCardData] = useState({
|
||||
type: 'JOKER',
|
||||
text: ''
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (card) {
|
||||
setCardData({
|
||||
type: 'JOKER',
|
||||
text: card.text || ''
|
||||
})
|
||||
}
|
||||
}, [card])
|
||||
|
||||
const handleTextChange = (e) => {
|
||||
const newCardData = {
|
||||
...cardData,
|
||||
text: e.target.value
|
||||
}
|
||||
setCardData(newCardData)
|
||||
|
||||
if (onChange) {
|
||||
onChange(newCardData)
|
||||
}
|
||||
}
|
||||
|
||||
// Példa joker kártyák
|
||||
const exampleCards = [
|
||||
"Felelsz vagy mersz? (Az előző játékos kérdez)",
|
||||
"Csinálj 20 felülést!",
|
||||
"Mesélj el egy vicces történetet az életedből!",
|
||||
"Utánozd a kedvenc állatodat 30 másodpercig!",
|
||||
"Énekelj el egy dalt amit mindenki ismer!",
|
||||
"Mondj el 5 dolgot amiért hálás vagy!",
|
||||
"Táncolj 1 percig zene nélkül!"
|
||||
]
|
||||
|
||||
const insertExample = (example) => {
|
||||
setCardData(prev => ({
|
||||
...prev,
|
||||
text: example
|
||||
}))
|
||||
|
||||
if (onChange) {
|
||||
onChange({
|
||||
...cardData,
|
||||
text: example
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Info box */}
|
||||
<div className="bg-[color:var(--color-surface)] rounded-xl p-6">
|
||||
<div className="bg-[color:var(--color-fun)]/10 border border-[color:var(--color-fun)]/30 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<FaInfoCircle className="text-[color:var(--color-fun)] mt-1 flex-shrink-0" />
|
||||
<div>
|
||||
<h4 className="font-semibold text-[color:var(--color-text)] mb-2 flex items-center gap-2">
|
||||
<FaUsers className="text-sm" />
|
||||
Joker kártya működése:
|
||||
</h4>
|
||||
<p className="text-[color:var(--color-text-muted)] text-sm mb-2">
|
||||
A joker kártyák interaktív feladatokat tartalmaznak, melyek megtörik a jeget a játékosok között.
|
||||
Ezek lehetnek fizikai feladatok, kérdések, vagy szórakoztató kihívások.
|
||||
</p>
|
||||
<p className="text-[color:var(--color-fun)] text-sm font-medium">
|
||||
Cél: Szórakozás és játékosok közötti kapcsolat erősítése
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Kártya szövege */}
|
||||
<div className="bg-[color:var(--color-surface)] rounded-xl p-6">
|
||||
<h3 className="text-lg font-semibold text-[color:var(--color-text)] mb-4 flex items-center gap-2">
|
||||
<FaTheaterMasks className="text-[color:var(--color-fun)]" />
|
||||
Kártya szövege
|
||||
</h3>
|
||||
|
||||
<div>
|
||||
<label className="block text-[color:var(--color-text-muted)] text-sm font-medium mb-2">
|
||||
Joker kártya feladat *
|
||||
</label>
|
||||
<textarea
|
||||
value={cardData.text}
|
||||
onChange={handleTextChange}
|
||||
placeholder="Pl: Felelsz vagy mersz? (Az előző játékos kérdez)"
|
||||
className="w-full h-32 px-4 py-2 rounded-xl bg-[color:var(--color-background)] border border-[color:var(--color-surface-selected)] text-[color:var(--color-text)] placeholder-[color:var(--color-text-muted)] resize-none focus:ring-2 focus:ring-[color:var(--color-fun)] focus:border-transparent outline-none transition-all duration-200"
|
||||
maxLength={150}
|
||||
/>
|
||||
<div className="flex justify-between items-center mt-2">
|
||||
<span className="text-xs text-[color:var(--color-text-muted)]">
|
||||
Maximális hossz: 150 karakter
|
||||
</span>
|
||||
<span className="text-xs text-[color:var(--color-text-muted)]">
|
||||
{cardData.text.length}/150
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Example cards */}
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-medium text-[color:var(--color-text)] mb-3">
|
||||
💡 Példa joker kártyák (kattints rájuk a beszúráshoz):
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{exampleCards.map((example, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => insertExample(example)}
|
||||
className="text-left p-3 bg-[color:var(--color-fun)]/5 hover:bg-[color:var(--color-fun)]/10 border border-[color:var(--color-fun)]/20 rounded-lg text-sm text-[color:var(--color-text)] transition-colors"
|
||||
>
|
||||
{example}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
{cardData.text.trim() && (
|
||||
<div className="mt-6 p-4 bg-[color:var(--color-fun)]/5 border border-[color:var(--color-fun)]/20 rounded-lg">
|
||||
<h4 className="text-sm font-medium text-[color:var(--color-text)] mb-2">
|
||||
Kártya előnézet:
|
||||
</h4>
|
||||
<div className="bg-[color:var(--color-surface)] border-2 border-[color:var(--color-fun)] rounded-lg p-4 text-center">
|
||||
<FaTheaterMasks className="text-2xl text-[color:var(--color-fun)] mx-auto mb-2" />
|
||||
<p className="text-[color:var(--color-text)] font-medium">
|
||||
{cardData.text}
|
||||
</p>
|
||||
<div className="mt-2 text-xs text-[color:var(--color-fun)] font-medium">
|
||||
🃏 JOKER KÁRTYA
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// src/components/DeckCreator/LuckCardEditor.jsx
|
||||
// Szerencse kártya szerkesztő
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { FaDice, FaInfoCircle } from 'react-icons/fa'
|
||||
|
||||
const consequenceTypes = [
|
||||
{ value: 0, label: '⬆️ Előre lépés', description: 'A játékos előre lép X mezőt' },
|
||||
{ value: 1, label: '⬇️ Hátra lépés', description: 'A játékos hátra lép X mezőt' },
|
||||
{ value: 2, label: '⏸️ Kör kihagyás', description: 'A játékos kihagy egy kört' },
|
||||
{ value: 3, label: '⏩ Extra kör', description: 'A játékos kap egy extra kört' },
|
||||
{ value: 5, label: '🏁 Vissza a starthoz', description: 'A játékos visszakerül a starthoz' }
|
||||
]
|
||||
|
||||
export default function LuckCardEditor({ card, onChange }) {
|
||||
const [cardData, setCardData] = useState({
|
||||
type: 'LUCK',
|
||||
text: '',
|
||||
consequence: { type: 0, value: 1 }
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (card) {
|
||||
setCardData({
|
||||
type: 'LUCK',
|
||||
text: card.text || '',
|
||||
consequence: card.consequence || { type: 0, value: 1 }
|
||||
})
|
||||
}
|
||||
}, [card])
|
||||
|
||||
const handleTextChange = (e) => {
|
||||
const newCardData = {
|
||||
...cardData,
|
||||
text: e.target.value
|
||||
}
|
||||
setCardData(newCardData)
|
||||
|
||||
if (onChange) {
|
||||
onChange(newCardData)
|
||||
}
|
||||
}
|
||||
|
||||
const updateConsequence = (field, value) => {
|
||||
const newCardData = {
|
||||
...cardData,
|
||||
consequence: {
|
||||
...cardData.consequence,
|
||||
[field]: value
|
||||
}
|
||||
}
|
||||
setCardData(newCardData)
|
||||
|
||||
if (onChange) {
|
||||
onChange(newCardData)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Info box */}
|
||||
<div className="bg-[color:var(--color-surface)] rounded-xl p-6">
|
||||
<div className="bg-[color:var(--color-luck)]/10 border border-[color:var(--color-luck)]/30 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<FaInfoCircle className="text-[color:var(--color-luck)] mt-1 flex-shrink-0" />
|
||||
<div>
|
||||
<h4 className="font-semibold text-[color:var(--color-text)] mb-2">
|
||||
Szerencse kártya működése:
|
||||
</h4>
|
||||
<p className="text-[color:var(--color-text-muted)] text-sm mb-2">
|
||||
Amikor egy játékos szerencse mezőre lép, kap egy kártyát amit felolvas.
|
||||
A kártyán lévő utasítás azonnal teljesül.
|
||||
</p>
|
||||
<p className="text-[color:var(--color-luck)] text-sm font-medium">
|
||||
Példa: "Órai projektekkel kiváltottál több vizsgát is! Lépj előre 4 mezőt"
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Kártya szövege */}
|
||||
<div className="bg-[color:var(--color-surface)] rounded-xl p-6">
|
||||
<h3 className="text-lg font-semibold text-[color:var(--color-text)] mb-4 flex items-center gap-2">
|
||||
<FaDice className="text-[color:var(--color-luck)]" />
|
||||
Kártya szövege
|
||||
</h3>
|
||||
<div>
|
||||
<label className="block text-[color:var(--color-text-muted)] text-sm font-medium mb-2">
|
||||
Szerencse esemény leírása *
|
||||
</label>
|
||||
<textarea
|
||||
value={cardData.text}
|
||||
onChange={handleTextChange}
|
||||
placeholder="Pl: Órai projektekkel kiváltottál több vizsgát is! Lépj előre 4 mezőt"
|
||||
className="w-full h-32 px-4 py-2 rounded-xl bg-[color:var(--color-background)] border border-[color:var(--color-surface-selected)] text-[color:var(--color-text)] placeholder-[color:var(--color-text-muted)] resize-none focus:ring-2 focus:ring-[color:var(--color-luck)] focus:border-transparent outline-none transition-all duration-200"
|
||||
maxLength={200}
|
||||
/>
|
||||
<div className="flex justify-between items-center mt-2">
|
||||
<span className="text-xs text-[color:var(--color-text-muted)]">
|
||||
Maximális hossz: 200 karakter
|
||||
</span>
|
||||
<span className="text-xs text-[color:var(--color-text-muted)]">
|
||||
{cardData.text.length}/200
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
{cardData.text.trim() && (
|
||||
<div className="mt-6 p-4 bg-[color:var(--color-luck)]/5 border border-[color:var(--color-luck)]/20 rounded-lg">
|
||||
<h4 className="text-sm font-medium text-[color:var(--color-text)] mb-2">
|
||||
Kártya előnézet:
|
||||
</h4>
|
||||
<div className="bg-[color:var(--color-surface)] border-2 border-[color:var(--color-luck)] rounded-lg p-4 text-center">
|
||||
<FaDice className="text-2xl text-[color:var(--color-luck)] mx-auto mb-2" />
|
||||
<p className="text-[color:var(--color-text)] font-medium">
|
||||
{cardData.text}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Következmények */}
|
||||
<div className="bg-[color:var(--color-surface)] rounded-xl p-6">
|
||||
<h3 className="text-lg font-semibold text-[color:var(--color-text)] mb-4">
|
||||
🎯 Következmények
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Consequence Type */}
|
||||
<div>
|
||||
<label className="block text-[color:var(--color-text-muted)] text-sm font-medium mb-2">
|
||||
Hatás típusa
|
||||
</label>
|
||||
<select
|
||||
value={cardData.consequence?.type ?? 0}
|
||||
onChange={(e) => updateConsequence('type', parseInt(e.target.value))}
|
||||
className="w-full px-4 py-2 rounded-xl bg-[color:var(--color-background)] border border-[color:var(--color-surface-selected)] text-[color:var(--color-text)] focus:ring-2 focus:ring-[color:var(--color-luck)] focus:border-transparent outline-none transition-all duration-200"
|
||||
>
|
||||
{consequenceTypes.map(type => (
|
||||
<option key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="text-xs text-[color:var(--color-text-muted)] mt-1">
|
||||
{consequenceTypes.find(t => t.value === (cardData.consequence?.type ?? 0))?.description}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Consequence Value - csak kör kihagyás és extra kör */}
|
||||
{(cardData.consequence?.type === 2 || cardData.consequence?.type === 3) && (
|
||||
<div>
|
||||
<label className="block text-[color:var(--color-text-muted)] text-sm font-medium mb-2">
|
||||
{cardData.consequence?.type === 2 ? 'Körök kihagyása' : 'Extra körök száma'}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="5"
|
||||
value={cardData.consequence?.value ?? 1}
|
||||
onChange={(e) => updateConsequence('value', parseInt(e.target.value) || 1)}
|
||||
className="w-full px-4 py-2 rounded-xl bg-[color:var(--color-background)] border border-[color:var(--color-surface-selected)] text-[color:var(--color-text)] focus:ring-2 focus:ring-[color:var(--color-luck)] focus:border-transparent outline-none transition-all duration-200"
|
||||
/>
|
||||
<div className="text-xs text-[color:var(--color-text-muted)] mt-1">
|
||||
Érték: 1-5 között
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
// src/components/DeckCreator/TaskCardEditor.jsx
|
||||
// Feladat kártya szerkesztő (Quiz, Igaz/Hamis, Párosítás, Szöveges)
|
||||
|
||||
import React from "react"
|
||||
import { FaPlus, FaTrash, FaCheck, FaTimes } from "react-icons/fa"
|
||||
|
||||
const taskSubTypes = [
|
||||
{ value: 'quiz', label: '📋 Quiz (A/B/C/D)', description: 'Feleletválasztós kérdés' },
|
||||
{ value: 'truefalse', label: '✅ Igaz/Hamis', description: 'Igaz vagy hamis állítás' },
|
||||
{ value: 'matching', label: '🔗 Párosítás', description: 'Elemek összekapcsolása' },
|
||||
{ value: 'text', label: '✏️ Szöveges válasz', description: 'Szabadszöveges válasz' }
|
||||
]
|
||||
|
||||
const timeLimits = [
|
||||
{ value: 15, label: '15 másodperc' },
|
||||
{ value: 30, label: '30 másodperc' },
|
||||
{ value: 45, label: '45 másodperc' },
|
||||
{ value: 60, label: '1 perc' },
|
||||
{ value: 90, label: '1.5 perc' },
|
||||
{ value: 120, label: '2 perc' }
|
||||
]
|
||||
|
||||
export default function TaskCardEditor({ card, onChange }) {
|
||||
|
||||
const updateField = (field, value) => {
|
||||
onChange({ [field]: value })
|
||||
}
|
||||
|
||||
const updateOption = (index, value) => {
|
||||
const newOptions = [...card.options]
|
||||
newOptions[index] = value
|
||||
onChange({ options: newOptions })
|
||||
}
|
||||
|
||||
const addMatchingPair = () => {
|
||||
const newLeft = [...(card.leftItems || []), '']
|
||||
const newRight = [...(card.rightItems || []), '']
|
||||
const newCorrectPairs = { ...(card.correctPairs || {}), [newLeft.length - 1]: newRight.length - 1 }
|
||||
|
||||
onChange({
|
||||
leftItems: newLeft,
|
||||
rightItems: newRight,
|
||||
correctPairs: newCorrectPairs
|
||||
})
|
||||
}
|
||||
|
||||
const removeMatchingPair = (index) => {
|
||||
const newLeft = card.leftItems.filter((_, i) => i !== index)
|
||||
const newRight = card.rightItems.filter((_, i) => i !== index)
|
||||
const newCorrectPairs = {}
|
||||
|
||||
// Újraszámozás
|
||||
Object.entries(card.correctPairs).forEach(([leftIdx, rightIdx]) => {
|
||||
const newLeftIdx = parseInt(leftIdx) > index ? parseInt(leftIdx) - 1 : parseInt(leftIdx)
|
||||
const newRightIdx = parseInt(rightIdx) > index ? parseInt(rightIdx) - 1 : parseInt(rightIdx)
|
||||
if (newLeftIdx !== index && newRightIdx !== index) {
|
||||
newCorrectPairs[newLeftIdx] = newRightIdx
|
||||
}
|
||||
})
|
||||
|
||||
onChange({
|
||||
leftItems: newLeft,
|
||||
rightItems: newRight,
|
||||
correctPairs: newCorrectPairs
|
||||
})
|
||||
}
|
||||
|
||||
const addAcceptedAnswer = () => {
|
||||
const newAnswers = [...(card.acceptedAnswers || []), '']
|
||||
onChange({ acceptedAnswers: newAnswers })
|
||||
}
|
||||
|
||||
const updateAcceptedAnswer = (index, value) => {
|
||||
const currentAnswers = card.acceptedAnswers || ['']
|
||||
const newAnswers = [...currentAnswers]
|
||||
newAnswers[index] = value
|
||||
onChange({ acceptedAnswers: newAnswers })
|
||||
}
|
||||
|
||||
const removeAcceptedAnswer = (index) => {
|
||||
const currentAnswers = card.acceptedAnswers || ['']
|
||||
if (currentAnswers.length <= 1) return // Legalább egy válasz maradjon
|
||||
const newAnswers = currentAnswers.filter((_, i) => i !== index)
|
||||
onChange({ acceptedAnswers: newAnswers })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{/* Feladat típus választó */}
|
||||
<div className="bg-[color:var(--color-surface)] rounded-xl p-6">
|
||||
<h3 className="text-lg font-semibold text-[color:var(--color-text)] mb-4">
|
||||
🎯 Feladat típusa
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{taskSubTypes.map(type => (
|
||||
<button
|
||||
key={type.value}
|
||||
onClick={() => updateField('subType', type.value)}
|
||||
className={`
|
||||
p-4 rounded-xl border text-left transition-all duration-200 hover:scale-105
|
||||
${card.subType === type.value
|
||||
? 'bg-[color:var(--color-success)]/10 border-[color:var(--color-success)] shadow-lg'
|
||||
: 'bg-[color:var(--color-background)] border-[color:var(--color-surface-selected)] hover:bg-[color:var(--color-surface-selected)]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="font-semibold text-[color:var(--color-text)]">
|
||||
{type.label}
|
||||
</div>
|
||||
<div className="text-sm text-[color:var(--color-text-muted)] mt-1">
|
||||
{type.description}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quiz típus szerkesztő */}
|
||||
{card.subType === 'quiz' && (
|
||||
<div className="bg-[color:var(--color-surface)] rounded-xl p-6">
|
||||
<h3 className="text-lg font-semibold text-[color:var(--color-text)] mb-4">
|
||||
📋 Quiz kérdés
|
||||
</h3>
|
||||
|
||||
{/* Kérdés */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-[color:var(--color-text-muted)] text-sm font-medium mb-2">
|
||||
Kérdés
|
||||
</label>
|
||||
<textarea
|
||||
value={card.question || ''}
|
||||
onChange={(e) => updateField('question', e.target.value)}
|
||||
className="w-full px-4 py-3 rounded-xl bg-[color:var(--color-background)] border border-[color:var(--color-surface-selected)] text-[color:var(--color-text)] focus:ring-2 focus:ring-[color:var(--color-success)] focus:border-transparent outline-none transition-all duration-200 resize-none"
|
||||
rows="3"
|
||||
placeholder="Írd be a kérdést..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Válaszlehetőségek */}
|
||||
<div className="space-y-3">
|
||||
<label className="block text-[color:var(--color-text-muted)] text-sm font-medium">
|
||||
Válaszlehetőségek
|
||||
</label>
|
||||
|
||||
{['A', 'B', 'C', 'D'].map((letter, index) => (
|
||||
<div key={index} className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => updateField('correctAnswer', index)}
|
||||
className={`
|
||||
w-8 h-8 rounded-full flex items-center justify-center font-bold text-sm transition-all duration-200
|
||||
${card.correctAnswer === index
|
||||
? 'bg-[color:var(--color-success)] text-[color:var(--color-text-inverse)]'
|
||||
: 'bg-[color:var(--color-background)] text-[color:var(--color-text)] border border-[color:var(--color-surface-selected)]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{letter}
|
||||
</button>
|
||||
|
||||
{card.correctAnswer === index && (
|
||||
<FaCheck className="text-[color:var(--color-success)] text-sm" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={card.options?.[index] || ''}
|
||||
onChange={(e) => updateOption(index, e.target.value)}
|
||||
className="flex-1 px-4 py-2 rounded-xl bg-[color:var(--color-background)] border border-[color:var(--color-surface-selected)] text-[color:var(--color-text)] focus:ring-2 focus:ring-[color:var(--color-success)] focus:border-transparent outline-none transition-all duration-200"
|
||||
placeholder={`${letter} válasz...`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Igaz/Hamis típus szerkesztő */}
|
||||
{card.subType === 'truefalse' && (
|
||||
<div className="bg-[color:var(--color-surface)] rounded-xl p-6">
|
||||
<h3 className="text-lg font-semibold text-[color:var(--color-text)] mb-4">
|
||||
✅ Igaz/Hamis állítás
|
||||
</h3>
|
||||
|
||||
{/* Állítás */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-[color:var(--color-text-muted)] text-sm font-medium mb-2">
|
||||
Állítás
|
||||
</label>
|
||||
<textarea
|
||||
value={card.statement || ''}
|
||||
onChange={(e) => updateField('statement', e.target.value)}
|
||||
className="w-full px-4 py-3 rounded-xl bg-[color:var(--color-background)] border border-[color:var(--color-surface-selected)] text-[color:var(--color-text)] focus:ring-2 focus:ring-[color:var(--color-success)] focus:border-transparent outline-none transition-all duration-200 resize-none"
|
||||
rows="3"
|
||||
placeholder="Írd be az állítást..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Helyes válasz */}
|
||||
<div>
|
||||
<label className="block text-[color:var(--color-text-muted)] text-sm font-medium mb-3">
|
||||
Helyes válasz
|
||||
</label>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => updateField('isTrue', true)}
|
||||
className={`
|
||||
flex items-center gap-3 px-6 py-3 rounded-xl font-medium transition-all duration-200 hover:scale-105
|
||||
${card.isTrue === true
|
||||
? 'bg-[color:var(--color-success)] text-[color:var(--color-text-inverse)] shadow-lg'
|
||||
: 'bg-[color:var(--color-background)] text-[color:var(--color-text)] border border-[color:var(--color-surface-selected)]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<FaCheck />
|
||||
✅ IGAZ
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => updateField('isTrue', false)}
|
||||
className={`
|
||||
flex items-center gap-3 px-6 py-3 rounded-xl font-medium transition-all duration-200 hover:scale-105
|
||||
${card.isTrue === false
|
||||
? 'bg-[color:var(--color-error)] text-[color:var(--color-text-inverse)] shadow-lg'
|
||||
: 'bg-[color:var(--color-background)] text-[color:var(--color-text)] border border-[color:var(--color-surface-selected)]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<FaTimes />
|
||||
❌ HAMIS
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Párosítás típus szerkesztő */}
|
||||
{card.subType === 'matching' && (
|
||||
<div className="bg-[color:var(--color-surface)] rounded-xl p-6">
|
||||
<h3 className="text-lg font-semibold text-[color:var(--color-text)] mb-4">
|
||||
🔗 Párosítás feladat
|
||||
</h3>
|
||||
|
||||
{/* Feladat leírás */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-[color:var(--color-text-muted)] text-sm font-medium mb-2">
|
||||
Feladat leírása
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={card.taskDescription || ''}
|
||||
onChange={(e) => updateField('taskDescription', e.target.value)}
|
||||
className="w-full px-4 py-2 rounded-xl bg-[color:var(--color-background)] border border-[color:var(--color-surface-selected)] text-[color:var(--color-text)] focus:ring-2 focus:ring-[color:var(--color-success)] focus:border-transparent outline-none transition-all duration-200"
|
||||
placeholder="Pl.: Párosítsd a országokat a fővárosukkal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Párosítások */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-[color:var(--color-text-muted)] text-sm font-medium">
|
||||
Párosítások
|
||||
</label>
|
||||
<button
|
||||
onClick={addMatchingPair}
|
||||
className="flex items-center gap-2 px-3 py-1 rounded-lg bg-[color:var(--color-success)] text-[color:var(--color-text-inverse)] text-sm font-medium hover:bg-[color:var(--color-success)]/80 transition-all duration-200"
|
||||
>
|
||||
<FaPlus />
|
||||
Új pár
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{(card.leftItems || []).map((leftItem, index) => (
|
||||
<div key={index} className="grid grid-cols-5 gap-3 items-center">
|
||||
<input
|
||||
type="text"
|
||||
value={leftItem}
|
||||
onChange={(e) => {
|
||||
const newLeft = [...card.leftItems]
|
||||
newLeft[index] = e.target.value
|
||||
onChange({ leftItems: newLeft })
|
||||
}}
|
||||
className="col-span-2 px-3 py-2 rounded-xl bg-[color:var(--color-background)] border border-[color:var(--color-surface-selected)] text-[color:var(--color-text)] focus:ring-2 focus:ring-[color:var(--color-success)] focus:border-transparent outline-none transition-all duration-200 text-sm"
|
||||
placeholder="Bal oldal..."
|
||||
/>
|
||||
|
||||
<div className="text-center text-[color:var(--color-text-muted)]">↔</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={card.rightItems?.[index] || ''}
|
||||
onChange={(e) => {
|
||||
const newRight = [...(card.rightItems || [])]
|
||||
newRight[index] = e.target.value
|
||||
onChange({ rightItems: newRight })
|
||||
}}
|
||||
className="px-3 py-2 rounded-xl bg-[color:var(--color-background)] border border-[color:var(--color-surface-selected)] text-[color:var(--color-text)] focus:ring-2 focus:ring-[color:var(--color-success)] focus:border-transparent outline-none transition-all duration-200 text-sm"
|
||||
placeholder="Jobb oldal..."
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={() => removeMatchingPair(index)}
|
||||
className="p-2 rounded-lg bg-[color:var(--color-error)]/10 text-[color:var(--color-error)] hover:bg-[color:var(--color-error)]/20 transition-all duration-200"
|
||||
>
|
||||
<FaTrash className="text-xs" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Szöveges válasz típus szerkesztő */}
|
||||
{card.subType === 'text' && (
|
||||
<div className="bg-[color:var(--color-surface)] rounded-xl p-6">
|
||||
<h3 className="text-lg font-semibold text-[color:var(--color-text)] mb-4">
|
||||
✏️ Szöveges válasz
|
||||
</h3>
|
||||
|
||||
{/* Kérdés */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-[color:var(--color-text-muted)] text-sm font-medium mb-2">
|
||||
Kérdés
|
||||
</label>
|
||||
<textarea
|
||||
value={card.question || ''}
|
||||
onChange={(e) => updateField('question', e.target.value)}
|
||||
className="w-full px-4 py-3 rounded-xl bg-[color:var(--color-background)] border border-[color:var(--color-surface-selected)] text-[color:var(--color-text)] focus:ring-2 focus:ring-[color:var(--color-success)] focus:border-transparent outline-none transition-all duration-200 resize-none"
|
||||
rows="3"
|
||||
placeholder="Írd be a kérdést..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Elfogadott válaszok */}
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<label className="text-[color:var(--color-text-muted)] text-sm font-medium">
|
||||
Elfogadott válaszok
|
||||
</label>
|
||||
<button
|
||||
onClick={addAcceptedAnswer}
|
||||
className="flex items-center gap-2 px-3 py-1 rounded-lg bg-[color:var(--color-success)] text-[color:var(--color-text-inverse)] text-sm font-medium hover:bg-[color:var(--color-success)]/80 transition-all duration-200"
|
||||
>
|
||||
<FaPlus />
|
||||
Új válasz
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{(card.acceptedAnswers || ['']).map((answer, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={answer}
|
||||
onChange={(e) => updateAcceptedAnswer(index, e.target.value)}
|
||||
className="flex-1 px-3 py-2 rounded-xl bg-[color:var(--color-background)] border border-[color:var(--color-surface-selected)] text-[color:var(--color-text)] focus:ring-2 focus:ring-[color:var(--color-success)] focus:border-transparent outline-none transition-all duration-200 text-sm"
|
||||
placeholder={`Elfogadott válasz ${index + 1}...`}
|
||||
/>
|
||||
|
||||
{(card.acceptedAnswers?.length || 1) > 1 && (
|
||||
<button
|
||||
onClick={() => removeAcceptedAnswer(index)}
|
||||
className="p-2 rounded-lg bg-[color:var(--color-error)]/10 text-[color:var(--color-error)] hover:bg-[color:var(--color-error)]/20 transition-all duration-200"
|
||||
>
|
||||
<FaTrash className="text-xs" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-[color:var(--color-text-muted)] mt-2">
|
||||
Vesszővel elválasztva is megadhatsz több elfogadott választ egy mezőben
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Beállítások - Később elérhető */}
|
||||
<div className="relative">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 opacity-50 pointer-events-none blur-[1px]">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={false}
|
||||
disabled
|
||||
className="w-4 h-4 text-[color:var(--color-success)] border-[color:var(--color-surface-selected)] rounded focus:ring-[color:var(--color-success)]"
|
||||
/>
|
||||
<span className="text-[color:var(--color-text)]">Kis/nagy betű érzékeny</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={false}
|
||||
disabled
|
||||
className="w-4 h-4 text-[color:var(--color-success)] border-[color:var(--color-surface-selected)] rounded focus:ring-[color:var(--color-success)]"
|
||||
/>
|
||||
<span className="text-[color:var(--color-text)]">Pontos egyezés</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={false}
|
||||
disabled
|
||||
className="w-4 h-4 text-[color:var(--color-success)] border-[color:var(--color-surface-selected)] rounded focus:ring-[color:var(--color-success)]"
|
||||
/>
|
||||
<span className="text-[color:var(--color-text)]">Részleges elfogadás</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Hamarosan elérhető felület */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="bg-[color:var(--color-warning)]/20 backdrop-blur-sm border-2 border-[color:var(--color-warning)] rounded-lg px-4 py-2">
|
||||
<span className="text-[color:var(--color-warning)] font-semibold text-sm flex items-center gap-2">
|
||||
🚧 Hamarosan elérhető
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tipp */}
|
||||
<div className="mt-4">
|
||||
<label className="block text-[color:var(--color-text-muted)] text-sm font-medium mb-2">
|
||||
Tipp (opcionális)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={card.hint || ''}
|
||||
onChange={(e) => updateField('hint', e.target.value)}
|
||||
className="w-full px-4 py-2 rounded-xl bg-[color:var(--color-background)] border border-[color:var(--color-surface-selected)] text-[color:var(--color-text)] focus:ring-2 focus:ring-[color:var(--color-success)] focus:border-transparent outline-none transition-all duration-200"
|
||||
placeholder="Segítő tipp a játékosoknak..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Közös beállítások - Később elérhető */}
|
||||
<div className="bg-[color:var(--color-surface)] rounded-xl p-6 relative">
|
||||
<h3 className="text-lg font-semibold text-[color:var(--color-text)] mb-4">
|
||||
⚙️ Beállítások
|
||||
</h3>
|
||||
|
||||
<div className="relative">
|
||||
<div className="opacity-50 pointer-events-none blur-[1px]">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Pontszám */}
|
||||
<div>
|
||||
<label className="block text-[color:var(--color-text-muted)] text-sm font-medium mb-2">
|
||||
💰 Pontszám
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={10}
|
||||
disabled
|
||||
className="w-full px-4 py-2 rounded-xl bg-[color:var(--color-background)] border border-[color:var(--color-surface-selected)] text-[color:var(--color-text)] focus:ring-2 focus:ring-[color:var(--color-success)] focus:border-transparent outline-none transition-all duration-200"
|
||||
min="0"
|
||||
max="1000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Időlimit */}
|
||||
<div>
|
||||
<label className="block text-[color:var(--color-text-muted)] text-sm font-medium mb-2">
|
||||
⏱️ Időlimit
|
||||
</label>
|
||||
<select
|
||||
disabled
|
||||
className="w-full px-4 py-2 rounded-xl bg-[color:var(--color-background)] border border-[color:var(--color-surface-selected)] text-[color:var(--color-text)] focus:ring-2 focus:ring-[color:var(--color-success)] focus:border-transparent outline-none transition-all duration-200"
|
||||
>
|
||||
<option>30 másodperc</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Karakterlimit (csak szöveges válasznál) */}
|
||||
{card.subType === 'text' && (
|
||||
<div>
|
||||
<label className="block text-[color:var(--color-text-muted)] text-sm font-medium mb-2">
|
||||
📝 Karakterlimit
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={100}
|
||||
disabled
|
||||
className="w-full px-4 py-2 rounded-xl bg-[color:var(--color-background)] border border-[color:var(--color-surface-selected)] text-[color:var(--color-text)] focus:ring-2 focus:ring-[color:var(--color-success)] focus:border-transparent outline-none transition-all duration-200"
|
||||
min="10"
|
||||
max="500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Magyarázat */}
|
||||
<div className="mt-6">
|
||||
<label className="block text-[color:var(--color-text-muted)] text-sm font-medium mb-2">
|
||||
💡 Magyarázat (opcionális)
|
||||
</label>
|
||||
<textarea
|
||||
disabled
|
||||
className="w-full px-4 py-3 rounded-xl bg-[color:var(--color-background)] border border-[color:var(--color-surface-selected)] text-[color:var(--color-text)] focus:ring-2 focus:ring-[color:var(--color-success)] focus:border-transparent outline-none transition-all duration-200 resize-none"
|
||||
rows="3"
|
||||
placeholder="Magyarázat a helyes válaszhoz..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hamarosan elérhető felület */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="bg-[color:var(--color-warning)]/20 backdrop-blur-sm border-2 border-[color:var(--color-warning)] rounded-lg px-4 py-2">
|
||||
<span className="text-[color:var(--color-warning)] font-semibold text-sm flex items-center gap-2">
|
||||
🚧 Hamarosan elérhető
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useEffect, useRef, useState } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import Logo from "../../assets/pictures/Logo"
|
||||
|
||||
import HandleNavigate from "../../utils/HandleNavigate/HandleNavigate"
|
||||
|
||||
const ArrowUpIcon = () => <span style={{ fontSize: "1.25rem" }}>↑</span>
|
||||
|
||||
@@ -9,6 +8,9 @@ const Footer = () => {
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const footerRef = useRef(null)
|
||||
|
||||
// ✅ Használjuk a navigációs függvényeket
|
||||
const { goLanding, goAbout, goContacts } = HandleNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
@@ -17,14 +19,10 @@ const Footer = () => {
|
||||
{ threshold: 0.3 }
|
||||
)
|
||||
|
||||
if (footerRef.current) {
|
||||
observer.observe(footerRef.current)
|
||||
}
|
||||
if (footerRef.current) observer.observe(footerRef.current)
|
||||
|
||||
return () => {
|
||||
if (footerRef.current) {
|
||||
observer.unobserve(footerRef.current)
|
||||
}
|
||||
if (footerRef.current) observer.unobserve(footerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -35,63 +33,79 @@ const Footer = () => {
|
||||
return (
|
||||
<footer
|
||||
ref={footerRef}
|
||||
className={`relative bg-zinc-900 text-white border-t-2 border-zinc-800 mt-auto py-8 transition-all duration-700 ease-out ${
|
||||
isVisible ? "opacity-100 scale-100 translate-y-0" : "opacity-0 scale-95 translate-y-10"
|
||||
}`}
|
||||
className="relative bg-zinc-900 text-zinc-400 border-t-2 border-zinc-800 mt-auto py-8"
|
||||
style={{ transformOrigin: "bottom center" }}
|
||||
>
|
||||
<style>
|
||||
{`
|
||||
.footer-animate {
|
||||
transition: opacity 0.8s ease, transform 0.8s ease;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
<div className="max-w-6xl mx-auto flex flex-wrap justify-between items-start gap-8 px-4">
|
||||
{/* Logó */}
|
||||
<div className="flex flex-col items-center footer-animate">
|
||||
<a
|
||||
href="/"
|
||||
className="transition-transform duration-500 hover:scale-105 hover:brightness-125"
|
||||
<div className="flex flex-col items-center">
|
||||
<button
|
||||
onClick={goLanding}
|
||||
className="hover:scale-105 hover:brightness-110 transition-transform"
|
||||
>
|
||||
<Logo size={100} />
|
||||
</a>
|
||||
<span className="font-extrabold text-xl mt-2 tracking-wide">SerpentRace</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={goLanding}
|
||||
className="font-extrabold text-xl mt-2 tracking-wide text-white hover:text-green-500 transition-colors"
|
||||
>
|
||||
SerpentRace
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Oldalak */}
|
||||
<div className="flex flex-col gap-1 footer-animate">
|
||||
<span className="text-lg font-semibold text-green-400 underline underline-offset-4 mb-2 drop-shadow-sm">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-lg font-semibold text-green-600 underline underline-offset-4 mb-2 drop-shadow-sm">
|
||||
Oldalak
|
||||
</span>
|
||||
<a href="/" className="hover:underline hover:text-green-400 transition">Főoldal</a>
|
||||
<a href="/about" className="hover:underline hover:text-green-400 transition">
|
||||
<button
|
||||
onClick={goLanding}
|
||||
className="text-left hover:underline hover:text-green-500 transition-colors"
|
||||
>
|
||||
Főoldal
|
||||
</button>
|
||||
<button
|
||||
onClick={goAbout}
|
||||
className="text-left hover:underline hover:text-green-500 transition-colors"
|
||||
>
|
||||
Rólunk
|
||||
</a>
|
||||
<a href="/contact" className="hover:underline hover:text-green-400 transition">Kapcsolat</a>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Közösség */}
|
||||
<div className="flex flex-col gap-1 footer-animate">
|
||||
<span className="text-lg font-semibold text-green-400 underline underline-offset-4 mb-2 drop-shadow-sm">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-lg font-semibold text-green-600 underline underline-offset-4 mb-2 drop-shadow-sm">
|
||||
Közösség
|
||||
</span>
|
||||
<a href="https://discord.gg/" target="_blank" rel="noopener noreferrer" className="hover:underline hover:text-green-400 transition">Discord</a>
|
||||
<a href="https://github.com/" target="_blank" rel="noopener noreferrer" className="hover:underline hover:text-green-400 transition">GitHub</a>
|
||||
<a
|
||||
href="https://discord.gg/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline hover:text-green-500"
|
||||
>
|
||||
Discord
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline hover:text-green-500"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Elérhetőség */}
|
||||
<div className="flex flex-col gap-1 footer-animate">
|
||||
<span className="text-lg font-semibold text-green-400 underline underline-offset-4 mb-2 drop-shadow-sm">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-lg font-semibold text-green-600 underline underline-offset-4 mb-2 drop-shadow-sm">
|
||||
Elérhetőség
|
||||
</span>
|
||||
<span className="opacity-80">Email: info@serpentrace.hu</span>
|
||||
<span className="opacity-80">Telefon: +36 30 123 4567</span>
|
||||
<span className="opacity-85">Email: info@serpentrace.hu</span>
|
||||
<span className="opacity-85">Telefon: +36 30 123 4567</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center mt-8 text-sm opacity-70 footer-animate">
|
||||
<div className="text-center mt-8 text-sm opacity-70">
|
||||
© {new Date().getFullYear()} SerpentRace. Minden jog fenntartva.
|
||||
</div>
|
||||
|
||||
@@ -99,7 +113,7 @@ const Footer = () => {
|
||||
{isVisible && (
|
||||
<button
|
||||
onClick={scrollToTop}
|
||||
className="fixed bottom-6 right-6 bg-green-500 hover:bg-green-600 text-white p-3 rounded-full shadow-lg transition transform hover:scale-110"
|
||||
className="fixed bottom-6 right-6 bg-green-500 hover:bg-green-600 text-white p-3 rounded-full shadow-lg hover:scale-110 transition-transform"
|
||||
aria-label="Ugrás az oldal tetejére"
|
||||
>
|
||||
<ArrowUpIcon />
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user