From 7671bb05e4e418255e477075a2a62626732844a3 Mon Sep 17 00:00:00 2001 From: Michael Skrynski Date: Thu, 15 Jan 2026 06:30:48 +0100 Subject: [PATCH] Add ALLOW_REGISTRATION flag and dynamic UI --- .env.example | 5 +- backend/src/config/jwt.js | 6 +- backend/src/routes/auth.js | 668 ++++++++++++++++++++----------------- frontend/public/index.html | 106 ++++-- frontend/public/login.html | 461 ++++++++++++++++--------- 5 files changed, 755 insertions(+), 491 deletions(-) diff --git a/.env.example b/.env.example index 3845e48..34a68e2 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,9 @@ HOST=0.0.0.0 JWT_SECRET=your-super-secure-jwt-secret-key-change-this-in-production SESSION_SECRET=your-session-secret-change-this-in-production +# User Registration +ALLOW_REGISTRATION=true + # Database DATABASE_PATH=/app/database/data/edh-stats.db DATABASE_BACKUP_PATH=/app/database/data/backups @@ -26,4 +29,4 @@ RATE_LIMIT_MAX=100 # Monitoring HEALTH_CHECK_ENABLED=true -METRICS_ENABLED=false \ No newline at end of file +METRICS_ENABLED=false diff --git a/backend/src/config/jwt.js b/backend/src/config/jwt.js index 73312f4..904d89e 100644 --- a/backend/src/config/jwt.js +++ b/backend/src/config/jwt.js @@ -37,4 +37,8 @@ export const securityConfig = { usernameMinLength: 3, commanderNameMinLength: 2, maxNotesLength: 1000 -} \ No newline at end of file +} + +export const registrationConfig = { + allowRegistration: process.env.ALLOW_REGISTRATION !== 'false' +} diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 8b7d9dc..2786df0 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -1,12 +1,18 @@ // Authentication routes import { z } from 'zod' import User from '../models/User.js' +import { registrationConfig } from '../config/jwt.js' // Validation schemas const registerSchema = z.object({ - username: z.string().min(3).max(50).regex(/^[a-zA-Z0-9_-]+$/, { - message: 'Username can only contain letters, numbers, underscores, and hyphens' - }), + username: z + .string() + .min(3) + .max(50) + .regex(/^[a-zA-Z0-9_-]+$/, { + message: + 'Username can only contain letters, numbers, underscores, and hyphens' + }), password: z.string().min(8).max(100), email: z.string().email().optional() }) @@ -26,326 +32,382 @@ const updateProfileSchema = z.object({ }) export default async function authRoutes(fastify, options) { - + // Public endpoint to check if registration is allowed + fastify.get('/config', async (request, reply) => { + return { + allowRegistration: registrationConfig.allowRegistration + } + }) + // Register new user - fastify.post('/register', { - config: { rateLimit: { max: 3, timeWindow: '15 minutes' } } - }, async (request, reply) => { - try { - // Validate input - const validatedData = registerSchema.parse(request.body) - - // Create user - const user = await User.create(validatedData) - - // Generate JWT token - const token = await reply.jwtSign({ - id: user.id, - username: user.username - }, { - expiresIn: '15m' - }) - - reply.code(201).send({ - message: 'User registered successfully', - user: { - id: user.id, - username: user.username, - email: user.email, - created_at: user.created_at - }, - token - }) - - } catch (error) { - if (error instanceof z.ZodError) { - reply.code(400).send({ - error: 'Validation Error', - message: 'Invalid input data', - details: error.errors.map(e => e.message) - }) - } else if (error.message.includes('already exists')) { - reply.code(400).send({ - error: 'Registration Failed', - message: error.message - }) - } else { - fastify.log.error('Registration error:', error) - reply.code(500).send({ - error: 'Internal Server Error', - message: 'Failed to register user' + fastify.post( + '/register', + { + config: { rateLimit: { max: 3, timeWindow: '15 minutes' } } + }, + async (request, reply) => { + try { + // Check if registration is allowed + if (!registrationConfig.allowRegistration) { + reply.code(403).send({ + error: 'Registration Disabled', + message: 'User registration is currently disabled' + }) + return + } + + // Validate input + const validatedData = registerSchema.parse(request.body) + + // Create user + const user = await User.create(validatedData) + + // Generate JWT token + const token = await reply.jwtSign( + { + id: user.id, + username: user.username + }, + { + expiresIn: '15m' + } + ) + + reply.code(201).send({ + message: 'User registered successfully', + user: { + id: user.id, + username: user.username, + email: user.email, + created_at: user.created_at + }, + token }) + } catch (error) { + if (error instanceof z.ZodError) { + reply.code(400).send({ + error: 'Validation Error', + message: 'Invalid input data', + details: error.errors.map((e) => e.message) + }) + } else if (error.message.includes('already exists')) { + reply.code(400).send({ + error: 'Registration Failed', + message: error.message + }) + } else { + fastify.log.error('Registration error:', error) + reply.code(500).send({ + error: 'Internal Server Error', + message: 'Failed to register user' + }) + } } } - }) - + ) + // Login user - fastify.post('/login', { - config: { rateLimit: { max: 10, timeWindow: '15 minutes' } } - }, async (request, reply) => { - try { - const { username, password } = loginSchema.parse(request.body) - - // Find user - const user = await User.findByUsername(username) - if (!user) { - reply.code(401).send({ - error: 'Authentication Failed', - message: 'Invalid username or password' - }) - return - } - - // Verify password - const isValidPassword = await User.verifyPassword(password, user.password_hash) - if (!isValidPassword) { - reply.code(401).send({ - error: 'Authentication Failed', - message: 'Invalid username or password' - }) - return - } - - // Generate JWT token - const token = await reply.jwtSign({ - id: user.id, - username: user.username - }, { - expiresIn: '15m' - }) - - reply.send({ - message: 'Login successful', - user: { - id: user.id, - username: user.username, - email: user.email - }, - token - }) - - } catch (error) { - if (error instanceof z.ZodError) { - reply.code(400).send({ - error: 'Validation Error', - message: 'Invalid input data', - details: error.errors.map(e => e.message) - }) - } else { - fastify.log.error('Login error:', error) - reply.code(500).send({ - error: 'Internal Server Error', - message: 'Failed to authenticate user' + fastify.post( + '/login', + { + config: { rateLimit: { max: 10, timeWindow: '15 minutes' } } + }, + async (request, reply) => { + try { + const { username, password } = loginSchema.parse(request.body) + + // Find user + const user = await User.findByUsername(username) + if (!user) { + reply.code(401).send({ + error: 'Authentication Failed', + message: 'Invalid username or password' + }) + return + } + + // Verify password + const isValidPassword = await User.verifyPassword( + password, + user.password_hash + ) + if (!isValidPassword) { + reply.code(401).send({ + error: 'Authentication Failed', + message: 'Invalid username or password' + }) + return + } + + // Generate JWT token + const token = await reply.jwtSign( + { + id: user.id, + username: user.username + }, + { + expiresIn: '15m' + } + ) + + reply.send({ + message: 'Login successful', + user: { + id: user.id, + username: user.username, + email: user.email + }, + token }) + } catch (error) { + if (error instanceof z.ZodError) { + reply.code(400).send({ + error: 'Validation Error', + message: 'Invalid input data', + details: error.errors.map((e) => e.message) + }) + } else { + fastify.log.error('Login error:', error) + reply.code(500).send({ + error: 'Internal Server Error', + message: 'Failed to authenticate user' + }) + } } } - }) - + ) + // Refresh token - fastify.post('/refresh', { - config: { - rateLimit: { max: 20, timeWindow: '15 minutes' } - } - }, async (request, reply) => { - try { - await request.jwtVerify() - - const user = await User.findById(request.user.id) - if (!user) { + fastify.post( + '/refresh', + { + config: { + rateLimit: { max: 20, timeWindow: '15 minutes' } + } + }, + async (request, reply) => { + try { + await request.jwtVerify() + + const user = await User.findById(request.user.id) + if (!user) { + reply.code(401).send({ + error: 'Authentication Failed', + message: 'User not found' + }) + return + } + + // Generate new token + const token = await reply.jwtSign( + { + id: user.id, + username: user.username + }, + { + expiresIn: '15m' + } + ) + + reply.send({ + message: 'Token refreshed successfully', + token + }) + } catch (error) { reply.code(401).send({ error: 'Authentication Failed', - message: 'User not found' + message: 'Invalid or expired token' }) - return } - - // Generate new token - const token = await reply.jwtSign({ - id: user.id, - username: user.username - }, { - expiresIn: '15m' - }) - - reply.send({ - message: 'Token refreshed successfully', - token - }) - - } catch (error) { - reply.code(401).send({ - error: 'Authentication Failed', - message: 'Invalid or expired token' - }) } - }) - + ) + // Get current user profile - fastify.get('/me', { - preHandler: [async (request, reply) => { - try { - await request.jwtVerify() - } catch (err) { - reply.code(401).send({ - error: 'Unauthorized', - message: 'Invalid or expired token' - }) - } - }] - }, async (request, reply) => { - try { - const user = await User.findById(request.user.id) - if (!user) { - reply.code(404).send({ - error: 'Not Found', - message: 'User not found' - }) - return - } - - reply.send({ - user: { - id: user.id, - username: user.username, - email: user.email, - created_at: user.created_at + fastify.get( + '/me', + { + preHandler: [ + async (request, reply) => { + try { + await request.jwtVerify() + } catch (err) { + reply.code(401).send({ + error: 'Unauthorized', + message: 'Invalid or expired token' + }) + } } - }) - - } catch (error) { - fastify.log.error('Get profile error:', error) - reply.code(500).send({ - error: 'Internal Server Error', - message: 'Failed to get user profile' - }) + ] + }, + async (request, reply) => { + try { + const user = await User.findById(request.user.id) + if (!user) { + reply.code(404).send({ + error: 'Not Found', + message: 'User not found' + }) + return + } + + reply.send({ + user: { + id: user.id, + username: user.username, + email: user.email, + created_at: user.created_at + } + }) + } catch (error) { + fastify.log.error('Get profile error:', error) + reply.code(500).send({ + error: 'Internal Server Error', + message: 'Failed to get user profile' + }) + } } - }) - + ) + // Update user profile - fastify.patch('/me', { - preHandler: [async (request, reply) => { - try { - await request.jwtVerify() - } catch (err) { - reply.code(401).send({ - error: 'Unauthorized', - message: 'Invalid or expired token' - }) - } - }] - }, async (request, reply) => { - try { - const validatedData = updateProfileSchema.parse(request.body) - - const updated = await User.updateProfile(request.user.id, validatedData) - - if (!updated) { - reply.code(400).send({ - error: 'Update Failed', - message: 'No valid fields to update' - }) - return - } - - const user = await User.findById(request.user.id) - - reply.send({ - message: 'Profile updated successfully', - user: { - id: user.id, - username: user.username, - email: user.email, - updated_at: user.updated_at + fastify.patch( + '/me', + { + preHandler: [ + async (request, reply) => { + try { + await request.jwtVerify() + } catch (err) { + reply.code(401).send({ + error: 'Unauthorized', + message: 'Invalid or expired token' + }) + } } - }) - - } catch (error) { - if (error instanceof z.ZodError) { - reply.code(400).send({ - error: 'Validation Error', - message: 'Invalid input data', - details: error.errors.map(e => e.message) - }) - } else if (error.message.includes('already exists')) { - reply.code(400).send({ - error: 'Update Failed', - message: error.message - }) - } else { - fastify.log.error('Update profile error:', error) - reply.code(500).send({ - error: 'Internal Server Error', - message: 'Failed to update profile' - }) - } - } - }) - - // Change password - fastify.post('/change-password', { - preHandler: [async (request, reply) => { + ] + }, + async (request, reply) => { try { - await request.jwtVerify() - } catch (err) { - reply.code(401).send({ - error: 'Unauthorized', - message: 'Invalid or expired token' - }) - } - }], - config: { rateLimit: { max: 3, timeWindow: '1 hour' } } - }, async (request, reply) => { - try { - const { currentPassword, newPassword } = changePasswordSchema.parse(request.body) - - // Verify current password - const user = await User.findByUsername(request.user.username) - if (!user) { - reply.code(404).send({ - error: 'Not Found', - message: 'User not found' - }) - return - } - - const isValidPassword = await User.verifyPassword(currentPassword, user.password_hash) - if (!isValidPassword) { - reply.code(401).send({ - error: 'Authentication Failed', - message: 'Current password is incorrect' - }) - return - } - - // Update password - const updated = await User.updatePassword(request.user.id, newPassword) - - if (!updated) { - reply.code(500).send({ - error: 'Internal Server Error', - message: 'Failed to update password' - }) - return - } - - reply.send({ - message: 'Password changed successfully' - }) - - } catch (error) { - if (error instanceof z.ZodError) { - reply.code(400).send({ - error: 'Validation Error', - message: 'Invalid input data', - details: error.errors.map(e => e.message) - }) - } else { - fastify.log.error('Change password error:', error) - reply.code(500).send({ - error: 'Internal Server Error', - message: 'Failed to change password' + const validatedData = updateProfileSchema.parse(request.body) + + const updated = await User.updateProfile(request.user.id, validatedData) + + if (!updated) { + reply.code(400).send({ + error: 'Update Failed', + message: 'No valid fields to update' + }) + return + } + + const user = await User.findById(request.user.id) + + reply.send({ + message: 'Profile updated successfully', + user: { + id: user.id, + username: user.username, + email: user.email, + updated_at: user.updated_at + } }) + } catch (error) { + if (error instanceof z.ZodError) { + reply.code(400).send({ + error: 'Validation Error', + message: 'Invalid input data', + details: error.errors.map((e) => e.message) + }) + } else if (error.message.includes('already exists')) { + reply.code(400).send({ + error: 'Update Failed', + message: error.message + }) + } else { + fastify.log.error('Update profile error:', error) + reply.code(500).send({ + error: 'Internal Server Error', + message: 'Failed to update profile' + }) + } } } - }) -} \ No newline at end of file + ) + + // Change password + fastify.post( + '/change-password', + { + preHandler: [ + async (request, reply) => { + try { + await request.jwtVerify() + } catch (err) { + reply.code(401).send({ + error: 'Unauthorized', + message: 'Invalid or expired token' + }) + } + } + ], + config: { rateLimit: { max: 3, timeWindow: '1 hour' } } + }, + async (request, reply) => { + try { + const { currentPassword, newPassword } = changePasswordSchema.parse( + request.body + ) + + // Verify current password + const user = await User.findByUsername(request.user.username) + if (!user) { + reply.code(404).send({ + error: 'Not Found', + message: 'User not found' + }) + return + } + + const isValidPassword = await User.verifyPassword( + currentPassword, + user.password_hash + ) + if (!isValidPassword) { + reply.code(401).send({ + error: 'Authentication Failed', + message: 'Current password is incorrect' + }) + return + } + + // Update password + const updated = await User.updatePassword(request.user.id, newPassword) + + if (!updated) { + reply.code(500).send({ + error: 'Internal Server Error', + message: 'Failed to update password' + }) + return + } + + reply.send({ + message: 'Password changed successfully' + }) + } catch (error) { + if (error instanceof z.ZodError) { + reply.code(400).send({ + error: 'Validation Error', + message: 'Invalid input data', + details: error.errors.map((e) => e.message) + }) + } else { + fastify.log.error('Change password error:', error) + reply.code(500).send({ + error: 'Internal Server Error', + message: 'Failed to change password' + }) + } + } + } + ) +} diff --git a/frontend/public/index.html b/frontend/public/index.html index 7406d2b..8865e07 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -1,34 +1,82 @@ - + - - - + + + EDH Stats Tracker - - - - - -
-

EDH Stats

-

Track your Commander games and statistics

- - - -
-

Built with Fastify, SQLite, and Alpine.js

-

Ready to track your Commander victories!

-
+ + + + + +
+

+ EDH Stats +

+

+ Track your Commander games and statistics +

+ + + +
+

Built with Fastify, SQLite, and Alpine.js

+

Ready to track your Commander victories!

+
- - - \ No newline at end of file + + + + diff --git a/frontend/public/login.html b/frontend/public/login.html index 01b8446..e214faf 100755 --- a/frontend/public/login.html +++ b/frontend/public/login.html @@ -1,167 +1,314 @@ - + - - - + + + Login - EDH Stats Tracker - - - - -
- - -
-

EDH Stats

-

Sign in to your account

-
+ + + + +
+ +
+

+ EDH Stats +

+

Sign in to your account

+
- -
-
- -
- -
- -
- - - -
-
-

-
- - -
- -
- -
- - - -
- -
-

-
- - -
-
- - -
- - -
- - -
-
-
- - - -
-
-

-
-
-
- - -
- -
- - -
-

- Don't have an account? - - Sign up - -

-
-
-
- - -
-

Test Credentials (Development)

-
-

Username: testuser

-

Password: password123

-

Username: magictg

-

Password: password123

+ +
+
+ +
+ +
+ +
+ + + +
+

+
+ + +
+ +
+ +
+ + + +
+ +
+

+
+ + +
+
+ + +
+ + +
+ + +
+
+
+ + + +
+
+

+
+
+
+ + +
+ +
+ + +
+

+ Don't have an account? + + Sign up + +

+
+
+
+ + +
+

+ Test Credentials (Development) +

+
+

Username: testuser

+

Password: password123

+

Username: magictg

+

Password: password123

+
- + - - \ No newline at end of file + + +