Compare commits
195 Commits
c9813a7ff4
..
ujgege
| Author | SHA1 | Date | |
|---|---|---|---|
| 322059ace0 | |||
| 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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -12,9 +12,9 @@ const Animation = ({ sizePercentage = 100 }) => {
|
||||
const pathRefs = Array.from({ length: 11 }, () => useRef(null));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="w-full flex justify-center">
|
||||
{/* prettier-ignore */}
|
||||
<svg className={styles.animation} width={width} height={height} viewBox="0 0 1319 198" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg className={styles.animation} width="100%" height="auto" viewBox="0 0 1319 198" fill="none" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet" style={{ maxWidth: `${width}px`, maxHeight: `${height}px` }}>
|
||||
<path ref={pathRefs[0]} className={styles.path0} d="M1261.64 32.9C1272.02 32.9 1281.15 34.9576 1289.1 39.0094L1289.86 39.4078C1297.97 43.7136 1304.29 49.9037 1308.86 58.026L1308.86 58.0328L1308.87 58.0406C1313.41 65.9983 1315.74 75.4878 1315.74 86.6002C1315.74 88.8329 1315.63 91.0662 1315.41 93.3004H1240.77L1240.94 95.9625C1241.36 102.425 1243.14 107.682 1246.63 111.328L1246.67 111.368L1246.71 111.407C1250.29 114.831 1254.8 116.5 1260.04 116.5C1263.69 116.5 1266.97 115.677 1269.77 113.917C1272.15 112.419 1274.06 110.315 1275.55 107.7H1312.61C1310.88 113.608 1308.06 118.989 1304.16 123.859L1303.71 124.408L1303.71 124.413C1299.18 129.919 1293.45 134.322 1286.48 137.611L1285.8 137.925C1278.56 141.229 1270.51 142.9 1261.64 142.9C1250.94 142.9 1241.49 140.648 1233.23 136.205C1225.37 131.905 1219.12 125.83 1214.46 117.933L1214.01 117.164C1209.46 108.936 1207.14 99.1765 1207.14 87.8004C1207.14 76.4113 1209.46 66.7169 1214.01 58.6256L1214.02 58.6187L1214.02 58.6109C1218.45 50.6085 1224.53 44.4249 1232.28 40.0143L1233.04 39.5934C1241.29 35.1536 1250.8 32.9 1261.64 32.9ZM1261.44 58.9C1256.17 58.9 1251.64 60.3691 1248.04 63.4723C1244.4 66.4788 1242.18 70.8761 1241.18 76.3473L1240.63 79.3004H1280.74V76.8004C1280.74 71.5541 1279.01 67.178 1275.39 63.985L1275.04 63.6793C1271.33 60.4557 1266.74 58.9 1261.44 58.9Z" stroke="white" strokeWidth="5"/>
|
||||
<path ref={pathRefs[1]} className={styles.path1} d="M1139.95 32.9C1153.73 32.9 1165.15 36.6867 1174.38 44.1441L1174.39 44.151L1174.4 44.1578C1182.91 50.9203 1188.68 60.2478 1191.63 72.3004H1154.9C1153.61 69.0944 1151.8 66.4744 1149.4 64.5846C1146.55 62.349 1143.08 61.3004 1139.15 61.3004C1133.38 61.3004 1128.7 63.7808 1125.31 68.5533L1125.31 68.5602L1125.3 68.566C1122.08 73.1723 1120.65 79.708 1120.65 87.8004C1120.65 95.9013 1122.08 102.479 1125.28 107.202L1125.31 107.247C1128.7 112.019 1133.38 114.5 1139.15 114.5C1143.13 114.5 1146.64 113.458 1149.5 111.215C1151.9 109.324 1153.68 106.702 1154.93 103.5H1191.63C1188.77 115.027 1183.29 124.135 1175.24 130.949L1174.38 131.656C1165.15 139.113 1153.73 142.9 1139.95 142.9C1129.25 142.9 1119.8 140.648 1111.55 136.205C1103.69 131.908 1097.51 125.841 1092.97 117.958L1092.54 117.189C1087.98 108.956 1085.65 99.188 1085.65 87.8004C1085.65 76.9027 1087.83 67.4559 1092.12 59.3873L1092.54 58.6109C1096.97 50.6085 1103.05 44.4249 1110.8 40.0143L1111.55 39.5934C1119.81 35.1513 1129.25 32.9 1139.95 32.9Z" stroke="white" strokeWidth="5"/>
|
||||
<path ref={pathRefs[2]} className={styles.path2} d="M995.014 32.9C1002.18 32.9 1008.26 34.2763 1013.33 36.9322L1013.81 37.193C1019.04 40.0563 1023.04 43.8802 1025.86 48.6695L1030.51 56.5602V34.3004H1064.71V141.5H1030.51V119.24L1025.86 127.13C1023.04 131.905 1019 135.728 1013.63 138.595L1013.61 138.607C1008.45 141.437 1002.27 142.9 995.014 142.9C986.807 142.9 979.357 140.83 972.608 136.697L971.956 136.291C965.401 132.037 960.089 125.994 956.045 118.069L955.657 117.296C951.72 108.895 949.714 99.0842 949.714 87.8004C949.714 76.5091 951.722 66.7655 955.656 58.5035L955.657 58.5045C959.747 50.1977 965.189 43.9003 971.956 39.5094C978.877 35.1054 986.542 32.9 995.014 32.9ZM1007.61 62.1002C1001.29 62.1002 995.894 64.2893 991.601 68.6617L991.217 69.0621C986.771 73.6617 984.714 80.0315 984.714 87.8004C984.714 95.4589 986.781 101.845 991.161 106.678L991.175 106.694L991.189 106.708C995.547 111.367 1001.08 113.7 1007.61 113.7C1014.02 113.7 1019.47 111.363 1023.81 106.738L1023.81 106.739C1028.38 102.021 1030.51 95.5962 1030.51 87.8004C1030.51 80.1231 1028.37 73.771 1023.81 69.0611H1023.81C1019.47 64.436 1014.01 62.1003 1007.61 62.1002Z" stroke="white" strokeWidth="5"/>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user