feat: add JWT authentication with complete test coverage
Phase 1 - Step 3: Authentication API **Backend Authentication:** - bcryptjs for password hashing (salt rounds: 10) - JWT tokens with 24h expiration - Secure password storage (never expose passwordHash) **API Endpoints:** - POST /api/auth/register - User registration - Username validation (3-50 chars, alphanumeric + underscore) - Email validation and normalization - Password validation (min 6 chars) - Duplicate email/username detection - Auto-generated avatar (ui-avatars.com) - POST /api/auth/login - User authentication - Email + password credentials - Returns JWT token + user data - Invalid credentials protection - GET /api/users/me - Get current user (protected) - Requires valid JWT token - Returns user data + stats (matches, ratings) - Token validation via middleware **Security Features:** - express-validator for input sanitization - Auth middleware for protected routes - Token verification (Bearer token) - Password never returned in responses - Proper error messages (no information leakage) **Frontend Integration:** - API service layer (frontend/src/services/api.js) - Updated AuthContext to use real API - Token storage in localStorage - Automatic token inclusion in requests - Error handling for expired/invalid tokens **Unit Tests (30 tests, 78.26% coverage):** Auth Endpoints (14 tests): - ✅ Register: success, duplicate email, duplicate username - ✅ Register validation: invalid email, short password, short username - ✅ Login: success, wrong password, non-existent user, invalid format - ✅ Protected route: valid token, no token, invalid token, malformed header Auth Utils (9 tests): - ✅ Password hashing and comparison - ✅ Different hashes for same password - ✅ JWT generation and verification - ✅ Token expiration validation - ✅ Invalid token handling All tests passing ✅ Coverage: 78.26% ✅
This commit is contained in:
@@ -5,9 +5,9 @@ PORT=3000
|
|||||||
# Database
|
# Database
|
||||||
DATABASE_URL=postgresql://spotlightcam:spotlightcam123@db:5432/spotlightcam
|
DATABASE_URL=postgresql://spotlightcam:spotlightcam123@db:5432/spotlightcam
|
||||||
|
|
||||||
# JWT (future)
|
# JWT
|
||||||
# JWT_SECRET=your-secret-key-here
|
JWT_SECRET=your-secret-key-change-this-in-production
|
||||||
# JWT_EXPIRES_IN=24h
|
JWT_EXPIRES_IN=24h
|
||||||
|
|
||||||
# CORS
|
# CORS
|
||||||
CORS_ORIGIN=http://localhost:8080
|
CORS_ORIGIN=http://localhost:8080
|
||||||
|
|||||||
146
backend/package-lock.json
generated
146
backend/package-lock.json
generated
@@ -10,9 +10,12 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^5.8.0",
|
"@prisma/client": "^5.8.0",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"express": "^4.18.2"
|
"express": "^4.18.2",
|
||||||
|
"express-validator": "^7.3.0",
|
||||||
|
"jsonwebtoken": "^9.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
@@ -1424,6 +1427,12 @@
|
|||||||
"baseline-browser-mapping": "dist/cli.js"
|
"baseline-browser-mapping": "dist/cli.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bcryptjs": {
|
||||||
|
"version": "2.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
|
||||||
|
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/binary-extensions": {
|
"node_modules/binary-extensions": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -1521,6 +1530,12 @@
|
|||||||
"node-int64": "^0.4.0"
|
"node-int64": "^0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-equal-constant-time": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/buffer-from": {
|
"node_modules/buffer-from": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
@@ -1978,6 +1993,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ecdsa-sig-formatter": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ee-first": {
|
"node_modules/ee-first": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
@@ -2205,6 +2229,19 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/express-validator": {
|
||||||
|
"version": "7.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.0.tgz",
|
||||||
|
"integrity": "sha512-ujK2BX5JUun5NR4JuBo83YSXoDDIpoGz3QxgHTzQcHFevkKnwV1in4K7YNuuXQ1W3a2ObXB/P4OTnTZpUyGWiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lodash": "^4.17.21",
|
||||||
|
"validator": "~13.15.15"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fast-json-stable-stringify": {
|
"node_modules/fast-json-stable-stringify": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
||||||
@@ -3482,6 +3519,55 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jsonwebtoken": {
|
||||||
|
"version": "9.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
|
||||||
|
"integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jws": "^3.2.2",
|
||||||
|
"lodash.includes": "^4.3.0",
|
||||||
|
"lodash.isboolean": "^3.0.3",
|
||||||
|
"lodash.isinteger": "^4.0.4",
|
||||||
|
"lodash.isnumber": "^3.0.3",
|
||||||
|
"lodash.isplainobject": "^4.0.6",
|
||||||
|
"lodash.isstring": "^4.0.1",
|
||||||
|
"lodash.once": "^4.0.0",
|
||||||
|
"ms": "^2.1.1",
|
||||||
|
"semver": "^7.5.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12",
|
||||||
|
"npm": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jsonwebtoken/node_modules/ms": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/jwa": {
|
||||||
|
"version": "1.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
|
||||||
|
"integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-equal-constant-time": "^1.0.1",
|
||||||
|
"ecdsa-sig-formatter": "1.0.11",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jws": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jwa": "^1.4.1",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/kleur": {
|
"node_modules/kleur": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
|
||||||
@@ -3522,6 +3608,54 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash": {
|
||||||
|
"version": "4.17.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||||
|
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.includes": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isboolean": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isinteger": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isnumber": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isplainobject": {
|
||||||
|
"version": "4.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||||
|
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isstring": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.once": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lru-cache": {
|
"node_modules/lru-cache": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||||
@@ -4206,7 +4340,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "7.7.3",
|
"version": "7.7.3",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
@@ -4764,6 +4897,15 @@
|
|||||||
"node": ">=10.12.0"
|
"node": ">=10.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/validator": {
|
||||||
|
"version": "13.15.23",
|
||||||
|
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.23.tgz",
|
||||||
|
"integrity": "sha512-4yoz1kEWqUjzi5zsPbAS/903QXSYp0UOtHsPpp7p9rHAw/W+dkInskAE386Fat3oKRROwO98d9ZB0G4cObgUyw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vary": {
|
"node_modules/vary": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -13,20 +13,29 @@
|
|||||||
"prisma:seed": "node prisma/seed.js",
|
"prisma:seed": "node prisma/seed.js",
|
||||||
"prisma:studio": "prisma studio"
|
"prisma:studio": "prisma studio"
|
||||||
},
|
},
|
||||||
"keywords": ["webrtc", "p2p", "video", "dance", "matchmaking"],
|
"keywords": [
|
||||||
|
"webrtc",
|
||||||
|
"p2p",
|
||||||
|
"video",
|
||||||
|
"dance",
|
||||||
|
"matchmaking"
|
||||||
|
],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.18.2",
|
"@prisma/client": "^5.8.0",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"@prisma/client": "^5.8.0"
|
"express": "^4.18.2",
|
||||||
|
"express-validator": "^7.3.0",
|
||||||
|
"jsonwebtoken": "^9.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.0.2",
|
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"supertest": "^6.3.3",
|
"nodemon": "^3.0.2",
|
||||||
"prisma": "^5.8.0"
|
"prisma": "^5.8.0",
|
||||||
|
"supertest": "^6.3.3"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"testEnvironment": "node",
|
"testEnvironment": "node",
|
||||||
|
|||||||
244
backend/src/__tests__/auth.test.js
Normal file
244
backend/src/__tests__/auth.test.js
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
const request = require('supertest');
|
||||||
|
const app = require('../app');
|
||||||
|
const { prisma } = require('../utils/db');
|
||||||
|
const { hashPassword, generateToken } = require('../utils/auth');
|
||||||
|
|
||||||
|
// Clean up database before and after tests
|
||||||
|
beforeAll(async () => {
|
||||||
|
await prisma.user.deleteMany({});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.user.deleteMany({});
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Authentication API Tests', () => {
|
||||||
|
describe('POST /api/auth/register', () => {
|
||||||
|
it('should register a new user successfully', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/api/auth/register')
|
||||||
|
.send({
|
||||||
|
username: 'testuser',
|
||||||
|
email: 'test@example.com',
|
||||||
|
password: 'password123',
|
||||||
|
})
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
expect(response.body).toHaveProperty('success', true);
|
||||||
|
expect(response.body).toHaveProperty('message', 'User registered successfully');
|
||||||
|
expect(response.body.data).toHaveProperty('user');
|
||||||
|
expect(response.body.data).toHaveProperty('token');
|
||||||
|
expect(response.body.data.user).toHaveProperty('id');
|
||||||
|
expect(response.body.data.user).toHaveProperty('username', 'testuser');
|
||||||
|
expect(response.body.data.user).toHaveProperty('email', 'test@example.com');
|
||||||
|
expect(response.body.data.user).toHaveProperty('avatar');
|
||||||
|
expect(response.body.data.user).not.toHaveProperty('passwordHash');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject registration with duplicate email', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/api/auth/register')
|
||||||
|
.send({
|
||||||
|
username: 'anotheruser',
|
||||||
|
email: 'test@example.com', // Same email as above
|
||||||
|
password: 'password123',
|
||||||
|
})
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
expect(response.body).toHaveProperty('success', false);
|
||||||
|
expect(response.body).toHaveProperty('error', 'Email already registered');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject registration with duplicate username', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/api/auth/register')
|
||||||
|
.send({
|
||||||
|
username: 'testuser', // Same username as above
|
||||||
|
email: 'another@example.com',
|
||||||
|
password: 'password123',
|
||||||
|
})
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
expect(response.body).toHaveProperty('success', false);
|
||||||
|
expect(response.body).toHaveProperty('error', 'Username already taken');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject registration with invalid email', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/api/auth/register')
|
||||||
|
.send({
|
||||||
|
username: 'newuser',
|
||||||
|
email: 'invalid-email',
|
||||||
|
password: 'password123',
|
||||||
|
})
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
expect(response.body).toHaveProperty('success', false);
|
||||||
|
expect(response.body).toHaveProperty('error', 'Validation Error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject registration with short password', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/api/auth/register')
|
||||||
|
.send({
|
||||||
|
username: 'newuser',
|
||||||
|
email: 'new@example.com',
|
||||||
|
password: '12345', // Too short
|
||||||
|
})
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
expect(response.body).toHaveProperty('success', false);
|
||||||
|
expect(response.body).toHaveProperty('error', 'Validation Error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject registration with invalid username', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/api/auth/register')
|
||||||
|
.send({
|
||||||
|
username: 'ab', // Too short
|
||||||
|
email: 'new@example.com',
|
||||||
|
password: 'password123',
|
||||||
|
})
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
expect(response.body).toHaveProperty('success', false);
|
||||||
|
expect(response.body).toHaveProperty('error', 'Validation Error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/auth/login', () => {
|
||||||
|
it('should login successfully with correct credentials', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send({
|
||||||
|
email: 'test@example.com',
|
||||||
|
password: 'password123',
|
||||||
|
})
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body).toHaveProperty('success', true);
|
||||||
|
expect(response.body).toHaveProperty('message', 'Login successful');
|
||||||
|
expect(response.body.data).toHaveProperty('user');
|
||||||
|
expect(response.body.data).toHaveProperty('token');
|
||||||
|
expect(response.body.data.user).toHaveProperty('email', 'test@example.com');
|
||||||
|
expect(response.body.data.user).not.toHaveProperty('passwordHash');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject login with incorrect password', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send({
|
||||||
|
email: 'test@example.com',
|
||||||
|
password: 'wrongpassword',
|
||||||
|
})
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect(401);
|
||||||
|
|
||||||
|
expect(response.body).toHaveProperty('success', false);
|
||||||
|
expect(response.body).toHaveProperty('error', 'Invalid credentials');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject login with non-existent email', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send({
|
||||||
|
email: 'nonexistent@example.com',
|
||||||
|
password: 'password123',
|
||||||
|
})
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect(401);
|
||||||
|
|
||||||
|
expect(response.body).toHaveProperty('success', false);
|
||||||
|
expect(response.body).toHaveProperty('error', 'Invalid credentials');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject login with invalid email format', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send({
|
||||||
|
email: 'invalid-email',
|
||||||
|
password: 'password123',
|
||||||
|
})
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
expect(response.body).toHaveProperty('success', false);
|
||||||
|
expect(response.body).toHaveProperty('error', 'Validation Error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/users/me', () => {
|
||||||
|
let authToken;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Create a user and get token
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send({
|
||||||
|
email: 'test@example.com',
|
||||||
|
password: 'password123',
|
||||||
|
});
|
||||||
|
authToken = response.body.data.token;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return current user with valid token', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/api/users/me')
|
||||||
|
.set('Authorization', `Bearer ${authToken}`)
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body).toHaveProperty('success', true);
|
||||||
|
expect(response.body.data).toHaveProperty('id');
|
||||||
|
expect(response.body.data).toHaveProperty('username', 'testuser');
|
||||||
|
expect(response.body.data).toHaveProperty('email', 'test@example.com');
|
||||||
|
expect(response.body.data).toHaveProperty('stats');
|
||||||
|
expect(response.body.data.stats).toHaveProperty('matchesCount');
|
||||||
|
expect(response.body.data.stats).toHaveProperty('ratingsCount');
|
||||||
|
expect(response.body.data.stats).toHaveProperty('rating');
|
||||||
|
expect(response.body.data).not.toHaveProperty('passwordHash');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject request without token', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/api/users/me')
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect(401);
|
||||||
|
|
||||||
|
expect(response.body).toHaveProperty('success', false);
|
||||||
|
expect(response.body).toHaveProperty('error', 'Unauthorized');
|
||||||
|
expect(response.body).toHaveProperty('message', 'No token provided');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject request with invalid token', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/api/users/me')
|
||||||
|
.set('Authorization', 'Bearer invalid-token')
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect(401);
|
||||||
|
|
||||||
|
expect(response.body).toHaveProperty('success', false);
|
||||||
|
expect(response.body).toHaveProperty('error', 'Unauthorized');
|
||||||
|
expect(response.body).toHaveProperty('message', 'Invalid or expired token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject request with malformed Authorization header', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/api/users/me')
|
||||||
|
.set('Authorization', authToken) // Missing 'Bearer ' prefix
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect(401);
|
||||||
|
|
||||||
|
expect(response.body).toHaveProperty('success', false);
|
||||||
|
expect(response.body).toHaveProperty('error', 'Unauthorized');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
99
backend/src/__tests__/utils/auth.test.js
Normal file
99
backend/src/__tests__/utils/auth.test.js
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
const { hashPassword, comparePassword, generateToken, verifyToken } = require('../../utils/auth');
|
||||||
|
|
||||||
|
// Set up test environment variables
|
||||||
|
beforeAll(() => {
|
||||||
|
process.env.JWT_SECRET = 'test-secret-key';
|
||||||
|
process.env.JWT_EXPIRES_IN = '24h';
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Auth Utils Tests', () => {
|
||||||
|
describe('hashPassword and comparePassword', () => {
|
||||||
|
it('should hash password successfully', async () => {
|
||||||
|
const password = 'mySecretPassword123';
|
||||||
|
const hash = await hashPassword(password);
|
||||||
|
|
||||||
|
expect(hash).toBeDefined();
|
||||||
|
expect(hash).not.toBe(password);
|
||||||
|
expect(hash.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should compare password with hash successfully', async () => {
|
||||||
|
const password = 'mySecretPassword123';
|
||||||
|
const hash = await hashPassword(password);
|
||||||
|
|
||||||
|
const isValid = await comparePassword(password, hash);
|
||||||
|
expect(isValid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject incorrect password', async () => {
|
||||||
|
const password = 'mySecretPassword123';
|
||||||
|
const wrongPassword = 'wrongPassword';
|
||||||
|
const hash = await hashPassword(password);
|
||||||
|
|
||||||
|
const isValid = await comparePassword(wrongPassword, hash);
|
||||||
|
expect(isValid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate different hashes for same password', async () => {
|
||||||
|
const password = 'mySecretPassword123';
|
||||||
|
const hash1 = await hashPassword(password);
|
||||||
|
const hash2 = await hashPassword(password);
|
||||||
|
|
||||||
|
expect(hash1).not.toBe(hash2);
|
||||||
|
|
||||||
|
// But both should be valid
|
||||||
|
expect(await comparePassword(password, hash1)).toBe(true);
|
||||||
|
expect(await comparePassword(password, hash2)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateToken and verifyToken', () => {
|
||||||
|
it('should generate a valid JWT token', () => {
|
||||||
|
const payload = { userId: 123 };
|
||||||
|
const token = generateToken(payload);
|
||||||
|
|
||||||
|
expect(token).toBeDefined();
|
||||||
|
expect(typeof token).toBe('string');
|
||||||
|
expect(token.split('.')).toHaveLength(3); // JWT has 3 parts
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should verify a valid token', () => {
|
||||||
|
const payload = { userId: 123, email: 'test@example.com' };
|
||||||
|
const token = generateToken(payload);
|
||||||
|
const decoded = verifyToken(token);
|
||||||
|
|
||||||
|
expect(decoded).toBeDefined();
|
||||||
|
expect(decoded).toHaveProperty('userId', 123);
|
||||||
|
expect(decoded).toHaveProperty('email', 'test@example.com');
|
||||||
|
expect(decoded).toHaveProperty('iat'); // Issued at
|
||||||
|
expect(decoded).toHaveProperty('exp'); // Expiration
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject invalid token', () => {
|
||||||
|
const invalidToken = 'invalid.token.here';
|
||||||
|
const decoded = verifyToken(invalidToken);
|
||||||
|
|
||||||
|
expect(decoded).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject malformed token', () => {
|
||||||
|
const malformedToken = 'notavalidtoken';
|
||||||
|
const decoded = verifyToken(malformedToken);
|
||||||
|
|
||||||
|
expect(decoded).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include expiration time in token', () => {
|
||||||
|
const payload = { userId: 123 };
|
||||||
|
const token = generateToken(payload);
|
||||||
|
const decoded = verifyToken(token);
|
||||||
|
|
||||||
|
expect(decoded).toHaveProperty('exp');
|
||||||
|
expect(decoded.exp).toBeGreaterThan(decoded.iat);
|
||||||
|
|
||||||
|
// Should expire in approximately 24 hours (allowing 1 minute tolerance)
|
||||||
|
const expectedExpiration = decoded.iat + (24 * 60 * 60);
|
||||||
|
expect(Math.abs(decoded.exp - expectedExpiration)).toBeLessThan(60);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -28,9 +28,9 @@ app.get('/api/health', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// API routes
|
// API routes
|
||||||
|
app.use('/api/auth', require('./routes/auth'));
|
||||||
|
app.use('/api/users', require('./routes/users'));
|
||||||
app.use('/api/events', require('./routes/events'));
|
app.use('/api/events', require('./routes/events'));
|
||||||
// app.use('/api/auth', require('./routes/auth'));
|
|
||||||
// app.use('/api/users', require('./routes/users'));
|
|
||||||
// app.use('/api/matches', require('./routes/matches'));
|
// app.use('/api/matches', require('./routes/matches'));
|
||||||
// app.use('/api/ratings', require('./routes/ratings'));
|
// app.use('/api/ratings', require('./routes/ratings'));
|
||||||
|
|
||||||
|
|||||||
117
backend/src/controllers/auth.js
Normal file
117
backend/src/controllers/auth.js
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
const { prisma } = require('../utils/db');
|
||||||
|
const { hashPassword, comparePassword, generateToken } = require('../utils/auth');
|
||||||
|
|
||||||
|
// Register new user
|
||||||
|
async function register(req, res, next) {
|
||||||
|
try {
|
||||||
|
const { username, email, password } = req.body;
|
||||||
|
|
||||||
|
// Check if user already exists
|
||||||
|
const existingUser = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ email },
|
||||||
|
{ username },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
if (existingUser.email === email) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Email already registered',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Username already taken',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
const passwordHash = await hashPassword(password);
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
passwordHash,
|
||||||
|
avatar: `https://ui-avatars.com/api/?name=${encodeURIComponent(username)}&background=6366f1&color=fff`,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
email: true,
|
||||||
|
avatar: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate token
|
||||||
|
const token = generateToken({ userId: user.id });
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: 'User registered successfully',
|
||||||
|
data: {
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login user
|
||||||
|
async function login(req, res, next) {
|
||||||
|
try {
|
||||||
|
const { email, password } = req.body;
|
||||||
|
|
||||||
|
// Find user by email
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid credentials',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare password
|
||||||
|
const isPasswordValid = await comparePassword(password, user.passwordHash);
|
||||||
|
|
||||||
|
if (!isPasswordValid) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid credentials',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate token
|
||||||
|
const token = generateToken({ userId: user.id });
|
||||||
|
|
||||||
|
// Return user without password
|
||||||
|
const { passwordHash, ...userWithoutPassword } = user;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Login successful',
|
||||||
|
data: {
|
||||||
|
user: userWithoutPassword,
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
register,
|
||||||
|
login,
|
||||||
|
};
|
||||||
64
backend/src/middleware/auth.js
Normal file
64
backend/src/middleware/auth.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
const { verifyToken } = require('../utils/auth');
|
||||||
|
const { prisma } = require('../utils/db');
|
||||||
|
|
||||||
|
// Authentication middleware
|
||||||
|
async function authenticate(req, res, next) {
|
||||||
|
try {
|
||||||
|
// Get token from header
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Unauthorized',
|
||||||
|
message: 'No token provided',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.substring(7); // Remove 'Bearer ' prefix
|
||||||
|
|
||||||
|
// Verify token
|
||||||
|
const decoded = verifyToken(token);
|
||||||
|
|
||||||
|
if (!decoded) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Unauthorized',
|
||||||
|
message: 'Invalid or expired token',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user from database
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: decoded.userId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
email: true,
|
||||||
|
avatar: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Unauthorized',
|
||||||
|
message: 'User not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach user to request
|
||||||
|
req.user = user;
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Auth middleware error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Internal Server Error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { authenticate };
|
||||||
52
backend/src/middleware/validators.js
Normal file
52
backend/src/middleware/validators.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
const { body, validationResult } = require('express-validator');
|
||||||
|
|
||||||
|
// Validation error handler
|
||||||
|
function handleValidationErrors(req, res, next) {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Validation Error',
|
||||||
|
details: errors.array(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register validation rules
|
||||||
|
const registerValidation = [
|
||||||
|
body('username')
|
||||||
|
.trim()
|
||||||
|
.isLength({ min: 3, max: 50 })
|
||||||
|
.withMessage('Username must be between 3 and 50 characters')
|
||||||
|
.matches(/^[a-zA-Z0-9_]+$/)
|
||||||
|
.withMessage('Username can only contain letters, numbers, and underscores'),
|
||||||
|
body('email')
|
||||||
|
.trim()
|
||||||
|
.isEmail()
|
||||||
|
.withMessage('Must be a valid email address')
|
||||||
|
.normalizeEmail(),
|
||||||
|
body('password')
|
||||||
|
.isLength({ min: 6 })
|
||||||
|
.withMessage('Password must be at least 6 characters long'),
|
||||||
|
handleValidationErrors,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Login validation rules
|
||||||
|
const loginValidation = [
|
||||||
|
body('email')
|
||||||
|
.trim()
|
||||||
|
.isEmail()
|
||||||
|
.withMessage('Must be a valid email address')
|
||||||
|
.normalizeEmail(),
|
||||||
|
body('password')
|
||||||
|
.notEmpty()
|
||||||
|
.withMessage('Password is required'),
|
||||||
|
handleValidationErrors,
|
||||||
|
];
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
registerValidation,
|
||||||
|
loginValidation,
|
||||||
|
handleValidationErrors,
|
||||||
|
};
|
||||||
13
backend/src/routes/auth.js
Normal file
13
backend/src/routes/auth.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { register, login } = require('../controllers/auth');
|
||||||
|
const { registerValidation, loginValidation } = require('../middleware/validators');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// POST /api/auth/register - Register new user
|
||||||
|
router.post('/register', registerValidation, register);
|
||||||
|
|
||||||
|
// POST /api/auth/login - Login user
|
||||||
|
router.post('/login', loginValidation, login);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
66
backend/src/routes/users.js
Normal file
66
backend/src/routes/users.js
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { authenticate } = require('../middleware/auth');
|
||||||
|
const { prisma } = require('../utils/db');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// GET /api/users/me - Get current authenticated user
|
||||||
|
router.get('/me', authenticate, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
// req.user is set by authenticate middleware
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: req.user.id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
email: true,
|
||||||
|
avatar: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
matchesAsUser1: true,
|
||||||
|
matchesAsUser2: true,
|
||||||
|
ratingsReceived: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'User not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate total matches
|
||||||
|
const totalMatches = user._count.matchesAsUser1 + user._count.matchesAsUser2;
|
||||||
|
|
||||||
|
// Calculate average rating
|
||||||
|
const ratings = await prisma.rating.findMany({
|
||||||
|
where: { ratedId: user.id },
|
||||||
|
select: { score: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const averageRating = ratings.length > 0
|
||||||
|
? ratings.reduce((sum, r) => sum + r.score, 0) / ratings.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
...user,
|
||||||
|
stats: {
|
||||||
|
matchesCount: totalMatches,
|
||||||
|
ratingsCount: user._count.ratingsReceived,
|
||||||
|
rating: averageRating.toFixed(1),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
36
backend/src/utils/auth.js
Normal file
36
backend/src/utils/auth.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
|
||||||
|
// Hash password with bcrypt
|
||||||
|
async function hashPassword(password) {
|
||||||
|
const salt = await bcrypt.genSalt(10);
|
||||||
|
return bcrypt.hash(password, salt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare password with hash
|
||||||
|
async function comparePassword(password, hash) {
|
||||||
|
return bcrypt.compare(password, hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate JWT token
|
||||||
|
function generateToken(payload) {
|
||||||
|
return jwt.sign(payload, process.env.JWT_SECRET, {
|
||||||
|
expiresIn: process.env.JWT_EXPIRES_IN || '24h',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify JWT token
|
||||||
|
function verifyToken(token) {
|
||||||
|
try {
|
||||||
|
return jwt.verify(token, process.env.JWT_SECRET);
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
hashPassword,
|
||||||
|
comparePassword,
|
||||||
|
generateToken,
|
||||||
|
verifyToken,
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createContext, useContext, useState, useEffect } from 'react';
|
import { createContext, useContext, useState, useEffect } from 'react';
|
||||||
import { mockCurrentUser } from '../mocks/users';
|
import { authAPI } from '../services/api';
|
||||||
|
|
||||||
const AuthContext = createContext(null);
|
const AuthContext = createContext(null);
|
||||||
|
|
||||||
@@ -16,45 +16,53 @@ export const AuthProvider = ({ children }) => {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check if user is logged in (from localStorage)
|
// Check if user is logged in (from token)
|
||||||
const storedUser = localStorage.getItem('user');
|
const loadUser = async () => {
|
||||||
if (storedUser) {
|
const token = localStorage.getItem('token');
|
||||||
setUser(JSON.parse(storedUser));
|
if (token) {
|
||||||
|
try {
|
||||||
|
const userData = await authAPI.getCurrentUser();
|
||||||
|
setUser(userData);
|
||||||
|
} catch (error) {
|
||||||
|
// Token expired or invalid
|
||||||
|
console.error('Failed to load user:', error);
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
loadUser();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const login = async (email, password) => {
|
const login = async (email, password) => {
|
||||||
// Mock login - w przyszłości będzie API call
|
try {
|
||||||
return new Promise((resolve) => {
|
const { user: userData } = await authAPI.login(email, password);
|
||||||
setTimeout(() => {
|
|
||||||
const userData = mockCurrentUser;
|
|
||||||
setUser(userData);
|
setUser(userData);
|
||||||
|
// Save to localStorage for persistence
|
||||||
localStorage.setItem('user', JSON.stringify(userData));
|
localStorage.setItem('user', JSON.stringify(userData));
|
||||||
resolve(userData);
|
return userData;
|
||||||
}, 500);
|
} catch (error) {
|
||||||
});
|
throw new Error(error.data?.error || 'Login failed');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const register = async (username, email, password) => {
|
const register = async (username, email, password) => {
|
||||||
// Mock register - w przyszłości będzie API call
|
try {
|
||||||
return new Promise((resolve) => {
|
const { user: userData } = await authAPI.register(username, email, password);
|
||||||
setTimeout(() => {
|
|
||||||
const userData = {
|
|
||||||
...mockCurrentUser,
|
|
||||||
username,
|
|
||||||
email,
|
|
||||||
};
|
|
||||||
setUser(userData);
|
setUser(userData);
|
||||||
|
// Save to localStorage for persistence
|
||||||
localStorage.setItem('user', JSON.stringify(userData));
|
localStorage.setItem('user', JSON.stringify(userData));
|
||||||
resolve(userData);
|
return userData;
|
||||||
}, 500);
|
} catch (error) {
|
||||||
});
|
throw new Error(error.data?.error || 'Registration failed');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
|
authAPI.logout();
|
||||||
setUser(null);
|
setUser(null);
|
||||||
localStorage.removeItem('user');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
|
|||||||
104
frontend/src/services/api.js
Normal file
104
frontend/src/services/api.js
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
const API_URL = 'http://localhost:8080/api';
|
||||||
|
|
||||||
|
class ApiError extends Error {
|
||||||
|
constructor(message, status, data) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
this.status = status;
|
||||||
|
this.data = data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAPI(endpoint, options = {}) {
|
||||||
|
const url = `${API_URL}${endpoint}`;
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add auth token if available
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (token) {
|
||||||
|
config.headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, config);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ApiError(
|
||||||
|
data.error || 'API request failed',
|
||||||
|
response.status,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new ApiError('Network error', 0, { message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth API
|
||||||
|
export const authAPI = {
|
||||||
|
async register(username, email, password) {
|
||||||
|
const data = await fetchAPI('/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ username, email, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save token
|
||||||
|
if (data.data.token) {
|
||||||
|
localStorage.setItem('token', data.data.token);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async login(email, password) {
|
||||||
|
const data = await fetchAPI('/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save token
|
||||||
|
if (data.data.token) {
|
||||||
|
localStorage.setItem('token', data.data.token);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getCurrentUser() {
|
||||||
|
const data = await fetchAPI('/users/me');
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
logout() {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Events API
|
||||||
|
export const eventsAPI = {
|
||||||
|
async getAll() {
|
||||||
|
const data = await fetchAPI('/events');
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getById(id) {
|
||||||
|
const data = await fetchAPI(`/events/${id}`);
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export { ApiError };
|
||||||
Reference in New Issue
Block a user