From eeced22ea49017362acaab5b19eea2cb8620f9ec Mon Sep 17 00:00:00 2001 From: Stephanie Gredell Date: Wed, 24 Dec 2025 16:12:00 -0800 Subject: [PATCH] add registration --- backend/src/controllers/auth.controller.ts | 130 ++++++- backend/src/middleware/validation.ts | 24 ++ backend/src/routes/auth.routes.ts | 5 +- backend/src/routes/settings.routes.ts | 2 + frontend/src/App.tsx | 2 + frontend/src/components/Footer/Footer.tsx | 2 +- frontend/src/components/Navbar/Navbar.tsx | 387 +++++++++++---------- frontend/src/pages/LoginPage.tsx | 18 +- frontend/src/pages/RegisterPage.tsx | 185 ++++++++++ frontend/src/services/apiClient.ts | 3 + 10 files changed, 559 insertions(+), 199 deletions(-) create mode 100644 frontend/src/pages/RegisterPage.tsx diff --git a/backend/src/controllers/auth.controller.ts b/backend/src/controllers/auth.controller.ts index ee2f4bb..c1bed4c 100644 --- a/backend/src/controllers/auth.controller.ts +++ b/backend/src/controllers/auth.controller.ts @@ -1,7 +1,7 @@ import { Response } from 'express'; import { AuthRequest } from '../types/index.js'; import { db } from '../config/database.js'; -import { createTokens, refreshAccessToken, revokeRefreshToken, verifyPassword } from '../services/auth.service.js'; +import { createTokens, refreshAccessToken, revokeRefreshToken, verifyPassword, hashPassword } from '../services/auth.service.js'; import { env } from '../config/env.js'; import jwt from 'jsonwebtoken'; @@ -150,6 +150,134 @@ export async function logout(req: AuthRequest, res: Response) { } } +export async function register(req: AuthRequest, res: Response) { + try { + const { username, password, dateOfBirth } = req.body; + + if (!username || !password || !dateOfBirth) { + return res.status(400).json({ + success: false, + error: { + code: 'MISSING_FIELDS', + message: 'Username, password, and date of birth are required' + } + }); + } + + // Validate password length + if (password.length < 8) { + return res.status(400).json({ + success: false, + error: { + code: 'WEAK_PASSWORD', + message: 'Password must be at least 8 characters long' + } + }); + } + + // Validate date of birth and age + const birthDate = new Date(dateOfBirth); + if (isNaN(birthDate.getTime())) { + return res.status(400).json({ + success: false, + error: { + code: 'INVALID_DATE', + message: 'Invalid date of birth' + } + }); + } + + // Check if user is at least 18 years old + const today = new Date(); + const age = today.getFullYear() - birthDate.getFullYear(); + const monthDiff = today.getMonth() - birthDate.getMonth(); + const dayDiff = today.getDate() - birthDate.getDate(); + + let actualAge = age; + if (monthDiff < 0 || (monthDiff === 0 && dayDiff < 0)) { + actualAge--; + } + + if (actualAge < 18) { + return res.status(400).json({ + success: false, + error: { + code: 'AGE_RESTRICTION', + message: 'You must be at least 18 years old to register' + } + }); + } + + // Check if username already exists + const existing = await db.execute({ + sql: 'SELECT id FROM users WHERE username = ?', + args: [username] + }); + + if (existing.rows.length > 0) { + return res.status(409).json({ + success: false, + error: { + code: 'USERNAME_EXISTS', + message: 'Username already exists' + } + }); + } + + // Hash password + const passwordHash = await hashPassword(password); + + // Insert user with 'user' role (never 'admin' for public registration) + const result = await db.execute({ + sql: 'INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)', + args: [username, passwordHash, 'user'] + }); + + // Get created user + const newUserId = Number(result.lastInsertRowid); + const newUser = await db.execute({ + sql: 'SELECT id, username, role FROM users WHERE id = ?', + args: [newUserId] + }); + + // Automatically log in the newly registered user + const { accessToken, refreshToken } = await createTokens( + newUserId, + username + ); + + // Set refresh token as httpOnly cookie + res.cookie('refresh_token', refreshToken, { + httpOnly: true, + secure: env.nodeEnv === 'production', + sameSite: 'strict', + maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days + }); + + res.status(201).json({ + success: true, + data: { + user: { + id: newUser.rows[0].id, + username: newUser.rows[0].username, + role: newUser.rows[0].role || 'user' + }, + accessToken, + refreshToken + } + }); + } catch (error: any) { + console.error('Register error:', error); + res.status(500).json({ + success: false, + error: { + code: 'REGISTER_ERROR', + message: 'An error occurred during registration' + } + }); + } +} + export async function getCurrentUser(req: AuthRequest, res: Response) { try { if (!req.userId) { diff --git a/backend/src/middleware/validation.ts b/backend/src/middleware/validation.ts index d33b2c3..38f56c4 100644 --- a/backend/src/middleware/validation.ts +++ b/backend/src/middleware/validation.ts @@ -6,6 +6,30 @@ export const loginSchema = z.object({ password: z.string().min(8) }); +export const registerSchema = z.object({ + username: z.string().min(3).max(50), + password: z.string().min(8), + dateOfBirth: z.string().refine((dateStr) => { + const date = new Date(dateStr); + if (isNaN(date.getTime())) return false; + + // Check if user is at least 18 years old + const today = new Date(); + const age = today.getFullYear() - date.getFullYear(); + const monthDiff = today.getMonth() - date.getMonth(); + const dayDiff = today.getDate() - date.getDate(); + + let actualAge = age; + if (monthDiff < 0 || (monthDiff === 0 && dayDiff < 0)) { + actualAge--; + } + + return actualAge >= 18; + }, { + message: 'You must be at least 18 years old to register' + }) +}); + export const addChannelSchema = z.object({ channelInput: z.string().min(1) }); diff --git a/backend/src/routes/auth.routes.ts b/backend/src/routes/auth.routes.ts index d925a16..911026b 100644 --- a/backend/src/routes/auth.routes.ts +++ b/backend/src/routes/auth.routes.ts @@ -1,11 +1,12 @@ import { Router } from 'express'; -import { login, refresh, logout, getCurrentUser } from '../controllers/auth.controller.js'; +import { login, register, refresh, logout, getCurrentUser } from '../controllers/auth.controller.js'; import { authMiddleware } from '../middleware/auth.js'; -import { validateRequest, loginSchema } from '../middleware/validation.js'; +import { validateRequest, loginSchema, registerSchema } from '../middleware/validation.js'; import { loginLimiter } from '../middleware/rateLimiter.js'; const router = Router(); +router.post('/register', loginLimiter, validateRequest(registerSchema), register); router.post('/login', loginLimiter, validateRequest(loginSchema), login); router.post('/refresh', refresh); router.post('/logout', authMiddleware, logout); diff --git a/backend/src/routes/settings.routes.ts b/backend/src/routes/settings.routes.ts index 98deffe..7fd2425 100644 --- a/backend/src/routes/settings.routes.ts +++ b/backend/src/routes/settings.routes.ts @@ -1,5 +1,7 @@ import { Router } from 'express'; import { heartbeat, getConnectionStats } from '../controllers/settings.controller.js'; +import { authMiddleware } from '../middleware/auth.js'; +import { adminMiddleware } from '../middleware/admin.js'; import { optionalAuthMiddleware } from '../middleware/optionalAuth.js'; const router = Router(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 01a2451..0a7cefa 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -19,6 +19,7 @@ const StatsAdminPage = lazy(() => import('./pages/StatsAdminPage').then(module = const UsersAdminPage = lazy(() => import('./pages/UsersAdminPage').then(module => ({ default: module.UsersAdminPage }))); const SettingsProfilesAdminPage = lazy(() => import('./pages/SettingsProfilesAdminPage').then(module => ({ default: module.SettingsProfilesAdminPage }))); const LoginPage = lazy(() => import('./pages/LoginPage').then(module => ({ default: module.LoginPage }))); +const RegisterPage = lazy(() => import('./pages/RegisterPage').then(module => ({ default: module.RegisterPage }))); // Loading fallback component const PageLoader = () => ( @@ -67,6 +68,7 @@ function App() { ))} {/* Keep non-app routes separate */} } /> + } /> +